spezifisches

CO2 Sensor Reverse Engineering

/ Updated co2 sensor reverse engineering uart

I recently bought the cheapest CO2 measurement device with an NDIR sensor that I could find: "BreeRainz DM1308A" for 25€ on Amazon.

product photo of a gadget with a color display showing the measured CO2 air concentration
Credit: BreeRainz on Amazon

I hoped to find something like the popular MH-Z19 inside which currently costs around 35€. My plan was to buy the cheaper device, save some bucks and to unsolder its CO2 sensor and use it in a DIY gadget with an ESP32.

TLDR: The device contains a CO2 sensor which is pin-compatible to MH-Z19 but with a different serial protocol. It's a bit obscure but you can find a datasheet and protocol description. The module is named HC8 (not to be confused with the Bluetooth module HC08) and it also has a PWM output (which isn't used in this gadget).

Disassembly

opened CO2 sensor gadget revealing some PCBs and a battery

I totally wrecked the enclosure prying it open because I didn't know there were screws behind the front glass of the display.

In the back you can see a 3.7V/1600mAh LiPo battery, a Micro USB charging port (actually you can also connect a PC to flash the display controller), and a temperature/humidity sensor (Sensirion SHT3x, or at least compatible).

In the front there's the main PCB on top of the display.

main PCB with annotations explained in the caption
Main PCB: 1=on/off switch; 2=connector for temperature/humidity sensor; 3=CO2 sensor; 4=Levetop LT268A display controller/microcontroller; 5=W25Q32JV 32MBit SPI flash; 6=bootloader switch; 7=LiPo battery connection (removed); 8=display connector

All the peripherals are connected to the display controller (Levetop LT268A) which is quite an interesting device. From the manufacturer's page:

LT268x/269 are Serial-Uart TFT Controller designed for small size MCU panels, integrated Cortex M4 32bit MCU core architecture. The main function is to provide Uart serial communication, so that the host computer MCU can easily transfer the content to be displayed to the TFT driver through simple command. [...]

The internal frequency of the LT268x/269 is 150MHz, including 512Kbytes Flash and 256Kbytes SRAM, provide Uart serial communication and SPI Flash interface. The external SPI Flash can be used to store pictures, animations, fonts and other information.

Pretty nice for a thing whose only job it is to receive a CO2 concentration via UART, temperature and humidity data via I2C and to display those.

Out of curiosity I dumped the SPI flash with a CH341A (flashrom -p ch341a_spi -r dump.bin) but this shouldn't be my focus here as there isn't much inside: Only roughly 1 MByte is used, probably for the displayed images as the display controller datasheet suggests. binwalk also couldn't find anything useful.

Finding out the CO2 sensor's protocol

Back to the CO2 sensor. How do we get its measurements? Fortunately RX, TX, and GND were labelled on the PCB, so I hooked up a logic analyzer (FX2 evaluation board with sigrok firmware and PulseView) to find out.

Pulseview trace of UART communication #1

The CO2 sensor communicates using UART with 9600 Baud, 8N1. After switching the device on it initially sends a packet starting with 42 4D and some bytes containing the CO2 measurement and a checksum.

Pulseview trace of UART communication #2
Command packet (look at RXD/TXD and ignore the other traces)

The display controller then sends 64 69 03 5E 4E to request a measurement. This also causes the sensor to leave the default mode where it automatically sends measurement data every second.

Pulseview trace of UART communication #3
Response packet

The CO2 sensor then responds with a packet starting with 64 69 03 and the CO2 measurement in a similar format.

This doesn't look like the MH-Z19 I hoped for.

What is it?

So here's how I found the datasheet for the CO2 sensor:

Google result of a search for '64 69 03 5E 4E' showing a single result in Chinese

I just searched for the sequence of bytes (64 69 03 5E 4E) the display controller sends. There was a single search result with a PDF. And its server was not reachable. Luckily Google still had a cached version of its content.

By the way you can also see part of the sensor's pinout in the image results in the screenshot above.

So here is the answer: The CO2 sensor is called HC8 and it's manufactured by 广州海谷电子科技有限公司 (Guangzhou Haigu Electronic Technology Co., Ltd.).

The datasheet contains this nice explanation (translated with DeepL, slightly edited for clarity):

The working principle of the non-dispersive infrared absorption (NDIR) gas sensor is based on the [Lambert-Beer law.] The absorption characteristics of infrared spectrum are calculated and confirmed through the relationship between gas concentration and absorption intensity (Lambert-Beer law). [...] Molecules composed of heterogeneous atoms such as CO2 and CO have absorption spectra in the infrared wavelength region. Its absorption intensity follows the Lambert-Beer law. When the light wave corresponding to the characteristic absorption wavelength of a certain gas passes through the measured gas [...] its intensity will be significantly weakened, and the degree of intensity attenuation is related to the concentration of the gas, and the relationship between the two follows [...] the Lambert-Beer law. The basic principle structure of the NDIR sensor is shown in the figure below.

Specs

Some interesting parts of the specifications which will be useful when implementing a DIY gadget around this sensor:

Measuring range: 400~5000 ppm
Resolution: 1 ppm
precision: ±(50ppm+5% *reading)
Response time T90: <120 seconds (s)
data update time: <3 (standard 1s) seconds (s)
Preheat time:
<25s (operational)
<2min (90% accuracy)
<10min (maximum accuracy)

Protocol

Some relevant parts of the datasheet:

The sensor has two output modes, active output and query output. After one query, the active output stops.
It needs to be powered on again to restore.
After power-on, the sensor actively outputs data: the output frequency is 1S once.

Data format in "active output" mode:

The output format is 16BYTE.
Data header: BYTE0 = 0X42; BYTE1=4D
BYTE6 data is high, BYTE7 data is low, indicating CO2 concentration.
BYTE15, data checksum. BYTE15= BYTE0+BYTE1+…….+BYTE13;

Example: 42 4D 0C 51 09 A2 07 2B 01 35 05 81 20 08 20 AD;
CO2 concentration = BYTE6 X 256 + BYTE7 = 07X256 + 2B = 1853;

It's interesting here that the checksum in this mode is just the sum of the previous bytes which isn't as robust as CRC.

Data format in "query output" mode:

Query reads:
Read CO2ppm value, return 522ppm
Send: 64 69 03 5E 4E
Return: 64 69 03 01 0A 02 00 00 00 00 00 00 9B F0

14 BYTEs are returned.
BYTE4, BYTE5 represent the concentration of CO2, converted into decimal not only the concentration value, BYTE5 is the upper 8 digits, BYTE4
for the lower 8 bits.
0x0A is the low byte of the 16bit integer, 0x02 is the high byte, and together it is 522
BYTE12, BYTE13 are CRC check data

And this one uses CRC. The datasheet also contains example code for the CRC check. So I'm going with this mode for my own gadget.

We can also now decode the response packet I showed in the screenshot before. Its bytes were:

64 69 03 01 B6 02 00 00 00 00 00 00 90 D1
^^^^^ ^^^^^ ^^^^^
header ppm CRC
^^^^^ ^^^^^^^^^^^^^^^^^
? seems always zero

To get the CO2 air concentration in parts per million (ppm) we do:

> 0x02 * 255 + 0xb6
(2 × 255) + 182 = 692

So it's 692 ppm!

Salvaging the CO2 sensor

CO2 sensor with a shiny plastic box sitting on a breadboard connected with some wires to a USB device
The CO2 sensor's pinout of the top row is from left to right: +5V, GND, NC (not connected), PWM; bottom row: NC, RX, TX, NC

Now that I knew enough I unsoldered the CO2 sensor, connected it to my PC using an FT232 board with 3.3V and supplied the sensor itself with 5V.

As a quick test I wrote a small Python script which uses pyserial and uses the "active output" mode of the sensor:

#!/usr/bin/python

import datetime
import serial
from binascii import hexlify

with serial.Serial("/dev/ttyUSB0", 9600, timeout=5) as ser:
while True:
b = ser.read(16)
ts = datetime.datetime.now().isoformat()

b_hex = ""
if b:
b_hex = hexlify(b)

if len(b) == 16:
header = b[:2]
ppm = b[6:8]
csum = b[14:16]

header_hex = hexlify(header)
ppm_hex = hexlify(ppm)
csum_hex = hexlify(csum)
ppm_value = int(ppm[0]) * 256 + int(ppm[1])
print(f"{ts} packet({b_hex}) header={header_hex} csum={csum_hex} ppm={ppm_hex} = {ppm_value} ppm co2")
else:
print(f"{ts} packet({b_hex})")

I left it running for a bit and it settled at around 950 ppm CO2 inside the room. I then started soldering something else next to the CO2 sensor and it quickly picked up:

2022-08-21T13:25:08.014641 packet(b'424d0b0a093f03b2012e0000e20623db') header=b'424d' csum=b'23db' ppm=b'03b2' = 946 ppm co2
2022-08-21T13:25:09.037387 packet(b'424d0b0a093f03b2012e0000e20623db') header=b'424d' csum=b'23db' ppm=b'03b2' = 946 ppm co2
2022-08-21T13:25:10.044122 packet(b'424d0b07093b03b2012e0000e20623d4') header=b'424d' csum=b'23d4' ppm=b'03b2' = 946 ppm co2
2022-08-21T13:25:11.050926 packet(b'424d0b07093b03b2012e0000e20623d4') header=b'424d' csum=b'23d4' ppm=b'03b2' = 946 ppm co2
2022-08-21T13:25:12.057797 packet(b'424d0b07093b03b2012e0000e20623d4') header=b'424d' csum=b'23d4' ppm=b'03b2' = 946 ppm co2
2022-08-21T13:25:13.064491 packet(b'424d0b02093a03b4012e0000e20623d0') header=b'424d' csum=b'23d0' ppm=b'03b4' = 948 ppm co2
2022-08-21T13:25:14.071280 packet(b'424d0b02093a03b4012e0000e20623d0') header=b'424d' csum=b'23d0' ppm=b'03b4' = 948 ppm co2
2022-08-21T13:25:15.077977 packet(b'424d0b02093a03b4012e0000e20623d0') header=b'424d' csum=b'23d0' ppm=b'03b4' = 948 ppm co2
2022-08-21T13:25:16.084777 packet(b'424d0afb093003b8012e0000e20623c2') header=b'424d' csum=b'23c2' ppm=b'03b8' = 952 ppm co2
2022-08-21T13:25:17.107521 packet(b'424d0afb093003b8012e0000e20623c2') header=b'424d' csum=b'23c2' ppm=b'03b8' = 952 ppm co2
2022-08-21T13:25:18.114331 packet(b'424d0afb093003b8012e0000e20623c2') header=b'424d' csum=b'23c2' ppm=b'03b8' = 952 ppm co2
2022-08-21T13:25:19.121116 packet(b'424d0afb093003b8012e0000e20623c2') header=b'424d' csum=b'23c2' ppm=b'03b8' = 952 ppm co2
2022-08-21T13:25:20.127911 packet(b'424d0aeb092d03c1012e0000e20623b8') header=b'424d' csum=b'23b8' ppm=b'03c1' = 961 ppm co2
2022-08-21T13:25:21.134689 packet(b'424d0aeb092d046c012e0000e2062364') header=b'424d' csum=b'2364' ppm=b'046c' = 1132 ppm co2
2022-08-21T13:25:22.141476 packet(b'424d0aeb092d046c012e0000e2062364') header=b'424d' csum=b'2364' ppm=b'046c' = 1132 ppm co2
2022-08-21T13:25:23.148319 packet(b'424d0ada092504de012e0000e20623bd') header=b'424d' csum=b'23bd' ppm=b'04de' = 1246 ppm co2
2022-08-21T13:25:24.171109 packet(b'424d0ada092504de012e0000e20623bd') header=b'424d' csum=b'23bd' ppm=b'04de' = 1246 ppm co2
2022-08-21T13:25:25.177861 packet(b'424d0ada092504de012e0000e20623bd') header=b'424d' csum=b'23bd' ppm=b'04de' = 1246 ppm co2
2022-08-21T13:25:26.184629 packet(b'424d0ac30924057d012e0000e2062345') header=b'424d' csum=b'2345' ppm=b'057d' = 1405 ppm co2
2022-08-21T13:25:27.191431 packet(b'424d0ac30924057d012e0000e2062345') header=b'424d' csum=b'2345' ppm=b'057d' = 1405 ppm co2
2022-08-21T13:25:28.198240 packet(b'424d0ac30924057d012e0000e2062345') header=b'424d' csum=b'2345' ppm=b'057d' = 1405 ppm co2
2022-08-21T13:25:29.205051 packet(b'424d0ab60925061d012e0000e20623da') header=b'424d' csum=b'23da' ppm=b'061d' = 1565 ppm co2
2022-08-21T13:25:30.211891 packet(b'424d0ab60925061d012e0000e20623da') header=b'424d' csum=b'23da' ppm=b'061d' = 1565 ppm co2
2022-08-21T13:25:31.218762 packet(b'424d0ab60925061d012e0000e20623da') header=b'424d' csum=b'23da' ppm=b'061d' = 1565 ppm co2
2022-08-21T13:25:32.241478 packet(b'424d0ab60925061d012e0000e20623da') header=b'424d' csum=b'23da' ppm=b'061d' = 1565 ppm co2
2022-08-21T13:25:33.248238 packet(b'424d0aac092406b6012e0000e2062368') header=b'424d' csum=b'2368' ppm=b'06b6' = 1718 ppm co2
2022-08-21T13:25:34.254979 packet(b'424d0aac092406b6012e0000e2062368') header=b'424d' csum=b'2368' ppm=b'06b6' = 1718 ppm co2
2022-08-21T13:25:35.261803 packet(b'424d0aac092406b6012e0000e2062368') header=b'424d' csum=b'2368' ppm=b'06b6' = 1718 ppm co2
2022-08-21T13:25:36.268576 packet(b'424d0aab09220710012e0000e20623c0') header=b'424d' csum=b'23c0' ppm=b'0710' = 1808 ppm co2
2022-08-21T13:25:37.275443 packet(b'424d0aab09220710012e0000e20623c0') header=b'424d' csum=b'23c0' ppm=b'0710' = 1808 ppm co2
2022-08-21T13:25:38.282322 packet(b'424d0aab09220710012e0000e20623c0') header=b'424d' csum=b'23c0' ppm=b'0710' = 1808 ppm co2
2022-08-21T13:25:39.305054 packet(b'424d0aa909240743012e0000e20623f3') header=b'424d' csum=b'23f3' ppm=b'0743' = 1859 ppm co2
2022-08-21T13:25:40.311911 packet(b'424d0aa909240743012e0000e20623f3') header=b'424d' csum=b'23f3' ppm=b'0743' = 1859 ppm co2
2022-08-21T13:25:41.318726 packet(b'424d0aa909240743012e0000e20623f3') header=b'424d' csum=b'23f3' ppm=b'0743' = 1859 ppm co2
2022-08-21T13:25:42.325456 packet(b'424d0aaa0925074a012e0000e20623fc') header=b'424d' csum=b'23fc' ppm=b'074a' = 1866 ppm co2
2022-08-21T13:25:43.332344 packet(b'424d0aaa0925074a012e0000e20623fc') header=b'424d' csum=b'23fc' ppm=b'074a' = 1866 ppm co2
2022-08-21T13:25:44.339140 packet(b'424d0aaa0925074a012e0000e20623fc') header=b'424d' csum=b'23fc' ppm=b'074a' = 1866 ppm co2
2022-08-21T13:25:45.346040 packet(b'424d0aaa0925074a012e0000e20623fc') header=b'424d' csum=b'23fc' ppm=b'074a' = 1866 ppm co2
2022-08-21T13:25:46.352791 packet(b'424d0aaa0927074e012e0000e2062302') header=b'424d' csum=b'2302' ppm=b'074e' = 1870 ppm co2
2022-08-21T13:25:47.375527 packet(b'424d0aaa0927074e012e0000e2062302') header=b'424d' csum=b'2302' ppm=b'074e' = 1870 ppm co2

Around 10 minutes later the soldering fumes (do they actually contain CO2?) seemed to have dispersed:

2022-08-21T13:33:53.650576 packet(b'424d0b08094003e7012e0000e206230f') header=b'424d' csum=b'230f' ppm=b'03e7' = 999 ppm co2 
2022-08-21T13:33:54.657334 packet(b'424d0b08094003e7012e0000e206230f') header=b'424d' csum=b'230f' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:33:55.664082 packet(b'424d0b08094003e7012e0000e206230f') header=b'424d' csum=b'230f' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:33:56.670883 packet(b'424d0b08094003e7012e0000e206230f') header=b'424d' csum=b'230f' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:33:57.677658 packet(b'424d0b08093f03e7012e0000e206230e') header=b'424d' csum=b'230e' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:33:58.700458 packet(b'424d0b08093f03e7012e0000e206230e') header=b'424d' csum=b'230e' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:33:59.707267 packet(b'424d0b08093f03e7012e0000e206230e') header=b'424d' csum=b'230e' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:34:00.713988 packet(b'424d0b08094003e7012e0000e206230f') header=b'424d' csum=b'230f' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:34:01.720828 packet(b'424d0b08094003e7012e0000e206230f') header=b'424d' csum=b'230f' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:34:02.727688 packet(b'424d0b08094003e7012e0000e206230f') header=b'424d' csum=b'230f' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:34:03.734467 packet(b'424d0b08094103e7012e0000e2062310') header=b'424d' csum=b'2310' ppm=b'03e7' = 999 ppm co2
2022-08-21T13:34:04.741267 packet(b'424d0b08094103e7012e0000e2062310') header=b'424d' csum=b'2310' ppm=b'03e7' = 999 ppm co2

I then opened the window with a good draft and it quickly approached the outside CO2 air concentration:

2022-08-21T13:41:47.796995 packet(b'424d0b4e095401a3012e0000e2062323') header=b'424d' csum=b'2323' ppm=b'01a3' = 419 ppm co2 
2022-08-21T13:41:48.803683 packet(b'424d0b4e095401a0012e0000e2062320') header=b'424d' csum=b'2320' ppm=b'01a0' = 416 ppm co2
2022-08-21T13:41:49.810507 packet(b'424d0b4e095401a0012e0000e2062320') header=b'424d' csum=b'2320' ppm=b'01a0' = 416 ppm co2
2022-08-21T13:41:50.817180 packet(b'424d0b4e095401a0012e0000e2062320') header=b'424d' csum=b'2320' ppm=b'01a0' = 416 ppm co2
2022-08-21T13:41:51.823981 packet(b'424d0b4e0954019f012e0000e206231f') header=b'424d' csum=b'231f' ppm=b'019f' = 415 ppm co2
2022-08-21T13:41:52.846689 packet(b'424d0b4e0954019f012e0000e206231f') header=b'424d' csum=b'231f' ppm=b'019f' = 415 ppm co2
2022-08-21T13:41:53.853479 packet(b'424d0b4e0954019f012e0000e206231f') header=b'424d' csum=b'231f' ppm=b'019f' = 415 ppm co2
2022-08-21T13:41:54.860302 packet(b'424d0b4e0954019e012e0000e206231e') header=b'424d' csum=b'231e' ppm=b'019e' = 414 ppm co2
2022-08-21T13:41:55.867120 packet(b'424d0b4e0954019e012e0000e206231e') header=b'424d' csum=b'231e' ppm=b'019e' = 414 ppm co2
2022-08-21T13:41:56.873917 packet(b'424d0b4e0954019e012e0000e206231e') header=b'424d' csum=b'231e' ppm=b'019e' = 414 ppm co2
2022-08-21T13:41:57.880692 packet(b'424d0b4e0954019e012e0000e206231e') header=b'424d' csum=b'231e' ppm=b'019e' = 414 ppm co2

Nice!

Also since I was wondering, usual CO2 levels are:

400 ppm: average outdoor air level.
400–1,000 ppm: typical level found in occupied spaces with good air exchange.
1,000–2,000 ppm: level associated with complaints of drowsiness and poor air.
2,000–5,000 ppm: level associated with headaches, sleepiness, and stagnant, stale, stuffy air.

Source: Wisconsin Department of Health Services