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 PlötzHenryk Plötz 05/14/2015 at 15:278 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

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