Close

All your base are belong to us

A project log for Reverse-Engineering a low-cost USB CO₂ monitor

I'm trying to get data out of a relatively low-cost (80€) CO₂ monitor that appears to have a USB connection for data as well as for power

henryk-pltzHenryk Plötz 05/14/2015 at 15:2713 Comments

So now that we have figured out all the puzzle pieces (do we?) we can go over to the last project phase and actually build something that extracts data from the sensor on its own initiative and feeds it into something with a purpose.

Setting: Ubuntu 14.04 LTS, Linux 3.13.0-52-generic, Python 2.7.6. As we recall from one of the earliest logs we can initiate data transmission by issuing a HID SET_REPORT on the USB device. Luckily, Linux offers a very simple API to do just that by way of the hidraw device node we were using at the very beginning. Since we're not interested in any security (which the protocol doesn't offer anyway) we'll just use a static key.

with file("/dev/hidraw0","a+b") as co2:
	# Key retrieved from /dev/random, guaranteed to be random ;)
	key = [0xc4, 0xc6, 0xc0, 0x92, 0x40, 0x23, 0xdc, 0x96]
	co2.write( "\x00" + "".join(chr(e) for e in key) )
	while True:
		data = co2.read(8)
		print " ".join("%02X" % e for e in data)
If we run that code (after fixing permissions, we'll get to the part later) we get … nothing. Bummer.

This means it's time to compare exactly what we're doing on the USB with what the Windows software is doing, breaking out the wireshark again:

It certainly does look right: there's 8 bytes of payload (note that we wrote 9 bytes into the device file, but the first byte only specifies the report number, 0 in this case; also note that we have seen the same convention earlier in the call to HidD_SetFeature in the Windows code). By carefully comparing this USB packet to the one observed earlier we can figure out what's wrong: Our wValue is 0x0200, theirs was 0x0300. When looking into the USB HID Device Class specification (section 7.2.1 on page 51), or alternatively just looking at the wireshark parse, we see: We sent an "Output" SET_REPORT, they sent a "Feature" SET_REPORT. Returning to the Linux HIDRAW API documentation tells us: In order to send a feature report we can't use a simple write but need to use the HIDIOCSFEATURE ioctl.

In Python, ioctls can be sent using the fcntl.ioctl() method, but we need the ioctl number for that. We find HIDIOCSFEATURE defined in hidraw.h as:

#define HIDIOCSFEATURE(len) _IOC(_IOC_WRITE|_IOC_READ, 'H', 0x06, len)
which isn't terribly helpful. The pragmatic solution is to compile a short C program and let it print the actual value (note: we're already hard coding the len parameter as 9, which is the only value we will need):
#include <linux/hidraw.h>
#include <sys/ioctl.h>
#include <stdio.h>

int main(void)
{
	printf("0x%08X\n", HIDIOCSFEATURE(9));
	return 0;
}
This tells us that the value of HIDIOCSFEATURE(9) actually is 0xC0094806. So, now for the real, actually working Python code:
#!/usr/bin/env python

import sys, fcntl, time

def decrypt(key,  data):
    cstate = [0x48,  0x74,  0x65,  0x6D,  0x70,  0x39,  0x39,  0x65]
    shuffle = [2, 4, 0, 7, 1, 6, 5, 3]
    
    phase1 = [0] * 8
    for i, o in enumerate(shuffle):
        phase1[o] = data[i]
    
    phase2 = [0] * 8
    for i in range(8):
        phase2[i] = phase1[i] ^ key[i]
    
    phase3 = [0] * 8
    for i in range(8):
        phase3[i] = ( (phase2[i] >> 3) | (phase2[ (i-1+8)%8 ] << 5) ) & 0xff
    
    ctmp = [0] * 8
    for i in range(8):
        ctmp[i] = ( (cstate[i] >> 4) | (cstate[i]<<4) ) & 0xff
    
    out = [0] * 8
    for i in range(8):
        out[i] = (0x100 + phase3[i] - ctmp[i]) & 0xff
    
    return out

def hd(d):
    return " ".join("%02X" % e for e in d)

if __name__ == "__main__":
    # Key retrieved from /dev/random, guaranteed to be random ;)
    key = [0xc4, 0xc6, 0xc0, 0x92, 0x40, 0x23, 0xdc, 0x96]
    
    fp = open(sys.argv[1], "a+b",  0)
    
    HIDIOCSFEATURE_9 = 0xC0094806
    set_report = "\x00" + "".join(chr(e) for e in key)
    fcntl.ioctl(fp, HIDIOCSFEATURE_9, set_report)
    
    values = {}
    
    while True:
        data = list(ord(e) for e in fp.read(8))
        decrypted = decrypt(key, data)
        if decrypted[4] != 0x0d or (sum(decrypted[:3]) & 0xff) != decrypted[3]:
            print hd(data), " => ", hd(decrypted),  "Checksum error"
        else:
            op = decrypted[0]
            val = decrypted[1] << 8 | decrypted[2]
            
            values[op] = val
            
            # Output all data, mark just received value with asterisk
            print ", ".join( "%s%02X: %04X %5i" % ([" ", "*"][op==k], k, v, v) for (k, v) in sorted(values.items())), "  ", 
            ## From http://co2meters.com/Documentation/AppNotes/AN146-RAD-0401-serial-communication.pdf
            if 0x50 in values:
                print "CO2: %4i" % values[0x50], 
            if 0x42 in values:
                print "T: %2.2f" % (values[0x42]/16.0-273.15), 
            if 0x44 in values:
                print "RH: %2.2f" % (values[0x44]/100.0), 
            print

Et voilà! I've skipped one intermediary step where the temperature readings didn't make any sense, since they don't follow the protocol documentation we discovered at first. Thankfully, the American vendor has directory listings active on its AppNotes directory, allowing us to discover another protocol spec that documents the correct formula for the temperature reading. It also describes the relative humidity data point (for which my device has no sensor), so I've added decoding support for all three items: CO₂ (in ppm), temperature (in °C), and relative humidity (in %).

This handy tool also prints all raw data items received, so that we may be able to make some sense of the additional data in the future. For easier reading each line prints the last value received for each item, the item received most recently is marked with an asterisk.

*42: 128F  4751    T: 23.79
 42: 128F  4751, *6D: 03E4   996    T: 23.79
 42: 128F  4751,  6D: 03E4   996, *6E: 514A 20810    T: 23.79
 42: 128F  4751,  6D: 03E4   996,  6E: 514A 20810, *71: 01C9   457    T: 23.79
 42: 128F  4751, *50: 01C8   456,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 42: 128F  4751, *4F: 21A3  8611,  50: 01C8   456,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 42: 128F  4751,  4F: 21A3  8611,  50: 01C8   456, *52: 269C  9884,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
*41: 0000     0,  42: 128F  4751,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751, *43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0, *42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884, *6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  6D: 03E4   996, *6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  6D: 03E4   996,  6E: 514A 20810, *71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611, *50: 01C8   456,  52: 269C  9884,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884, *57: 22C2  8898,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884, *56: 26A6  9894,  57: 22C2  8898,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
*41: 0000     0,  42: 128F  4751,  43: 0CEB  3307,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  56: 26A6  9894,  57: 22C2  8898,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0,  42: 128F  4751, *43: 0CE9  3305,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  56: 26A6  9894,  57: 22C2  8898,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79
 41: 0000     0, *42: 128F  4751,  43: 0CE9  3305,  4F: 21A3  8611,  50: 01C8   456,  52: 269C  9884,  56: 26A6  9894,  57: 22C2  8898,  6D: 03E4   996,  6E: 514A 20810,  71: 01C9   457    CO2:  456 T: 23.79

Next thing up: This Python script needs to be called with the hidraw device node as its first argument. In previous snippets I've always assumed /dev/hidraw0, but this may be different between different computers and maybe on each boot (if you have USB HID devices). Also we needed to manually give our user permissions on the device node. Let's fix it with an udev rule (this goes as 90-co2mini.rules into /etc/udev/rules.d):

ACTION=="remove", GOTO="co2mini_end"

SUBSYSTEMS=="usb", KERNEL=="hidraw*", ATTRS{idVendor}=="04d9", ATTRS{idProduct}=="a052", GROUP="plugdev", MODE="0660", SYMLINK+="co2mini%n", GOTO="co2mini_end"

LABEL="co2mini_end"

This will a) allow the group plugdev (change as appropriate, or put your user into that group) access to the device node and b) symlink the hidraw devices of all connected CO₂ monitors (if there's more than one :) in nice numbered files starting with /dev/co2mini0.

Perl code to receive data and FHEM integration are up next.

Discussions

Nikita Menkovich wrote 11/07/2021 at 18:19 point

All other co2 monitors in python didn't work for me, so I more or less copies basic algorithm from https://github.com/dmage/co2mon because it uses simpler hid API and it actually works

https://gist.github.com/librarian/306e06c51fe5f53ded6ebc761580b62b

  Are you sure? yes | no

Gennady wrote 02/17/2023 at 22:24 point

Just tiny changes to up-to-date python library `hidapi`:

https://gist.github.com/GennadySpb/5adddfcf5a45a96956ba73854b419b07

  Are you sure? yes | no

ullix wrote 01/20/2021 at 10:34 point

I run a variant of this code just fine on Ubuntu 16.04. I would like to run it also on Windows and Apple, but I guess this code won't work there, or does it?

Do you know of any Open Source Windows code, preferably in Python?

  Are you sure? yes | no

alyrot wrote 12/06/2020 at 22:16 point

I recently got my hands on a AIRCO2NTROL MINI from TFA that looks quite similar to your device and is also mentioned in some of the projects linked here. However all of them only produced garbage data. After some tweaking I found out that they dropped the "encryption"  on my device and send the data in plaintext. Vid and Pid are the same as described here, but "bcdDevice" has gone up to 2.0 from 1.0. Maybe this help someone :)

  Are you sure? yes | no

ullix wrote 06/01/2020 at 14:31 point

It is now about 5 years later and still very useful. Just acquired the device, made a Python 3 version with simple logging, and made it available here: https://sourceforge.net/projects/minimon/

  Are you sure? yes | no

Thomas Wiedereicher wrote 02/02/2017 at 19:22 point

Did anyone succeed in putting this to a fhem module?

  Are you sure? yes | no

heine wrote 01/08/2017 at 20:26 point

I wrapped this code into a simple Python class running with 2 and 3.

You can find it at https://github.com/heinemml/CO2Meter

  Are you sure? yes | no

petrman wrote 12/31/2016 at 12:58 point

Well done! Where can I wire you those two weekends you've just saved me?

  Are you sure? yes | no

ChrisH wrote 08/15/2016 at 20:42 point

Very useful write up!  I have a CO2mini connected to a Pi and I don't know if anyone noticed but it looks like the CO2 value seems to only update about every four minutes.  I was polling the mini every 20 seconds, and that may be kind of pointless.  Odd since the display seems to update every time the display cycles between temperature and CO2.

  Are you sure? yes | no

Victor Shmoylov wrote 04/20/2016 at 18:36 point

C language library to work with device: https://github.com/vshmoylov/libholtekco2

  Are you sure? yes | no

KristofR wrote 03/20/2016 at 08:06 point

Excellent work! Based on your code, I integrated the CO2 monitor in OpenHAB using REST - see 

https://github.com/KristofRobot/openhab-config/tree/master/co2mon

  Are you sure? yes | no

martin wrote 11/05/2015 at 20:25 point

Great Project! I created a Nodejs Plugin for the CO2 Monitor based on your great work. GitHub: https://github.com/maddindeiss/co2monitor

  Are you sure? yes | no

Hossemd wrote 10/25/2015 at 16:26 point

FHEM WOULD BE GREAT. Are you still working on it? Please please please ;)

  Are you sure? yes | no