Reverse-engineering of the X2D protocol
I want to integrate my home heater to my smart home server. Operations on my home heater are currently carried out through a remote controller. So, perhaps I can reverse-engineer the wireless protocol and make a custom controller that my smart home server can manage through USB or through any other practical means.
So, first things first, here is a picture of my remote controller:
At the bottom left-hand corner it states “RADIO 868 X2D”. It is thus safe to assume that the remote controller operates on the 868Mhz ISM band. So, let’s bring out GNU Radio with a SDR device (a Nuand BladeRF x40 in my case but a cheap RTL-SDR would work perfectly for any transmission in the ISM bands) in order to have a look at what’s happening in the neighborhood of that frequency.
The capture hereafter is showing exactly that when I operate the wheel of the remote controller and turn it right 1 notch.
There is clearly a peak on 868.38Mhz and only on that specific frequency. This means that 380Khz will be the center of the channel used by the remote controller on the 868Mhz ISM band. This also means that it will use some sort of amplitude modulation (any other form of modulation would imply two or more peaks).
Hereafter is a capture of a simple GNU Radio graph that demodulates that signal and dump it into a WAV file (sample rate of 2MSPS).
Simple but more than enough to get a clear signal (the SDR device and the remote controller were close (but not too close of course)), as shown by the following capture:
Now we can open the WAV file with Audacity. In the following capture, we can see that 6 frames were sent even though, again, I only turned it right 1 notch.
Hereafter is a zoom in on one of the 6 frames:
A trained eye is going to recognize some sort of Manchester or Biphase line code (hereafter is a picture from Wikipedia showing the most common line code pattern). We will determine that later. Let’s now focus on capturing the actual 0’s and 1’s with something more compact, less expensive and less CPU-hungry that a SDR device: a CC1111EMK868-915 USB device running the RfCat firmware.
Hereafter is a zoom in on the beginning of the frame. We can see two things:
The preamble used to synchronize the data transmission is the following pattern of 32 symbols: 11001100110011001100101010101010;
The total duration of the preamble is 13319 samples, which means, at a sample rate of 2MSPS, that the clock frequency is 1/(13133/32/2000000) = 4873Hz (well, let’s go with 4870Hz).
After the preamble, there is the sync word. It consists of 16 symbols (well, a word, hence the name) with the following pattern: 1100101101010101.
Now, we have all we need to run a script based on RfCat:
import sys
from rflib import *
from bitstring import BitArray
sync_word = 0b1100101101010101
try:
........d = None
........d = RfCat()
........d.setFreq(868380000)
........d.setMdmModulation(MOD_ASK_OOK)
........d.setMdmDRate(4870)
........d.setMdmChanSpc(24000)
........d.setChannel(0)
........d.lowball(level=3, sync=sync_word, length=250, pqt=0)
........d.setEnableMdmManchester(0)
........d.setModeRX()
........print "Starting..."
........while True:
................try:
........................frame, t = d.RFrecv(1)
........................frame_bin = BitArray(hex=("%04x" % sync_word)).bin + BitArray(frame).bin
........................print frame_bin
................except ChipconUsbTimeoutException:
........................pass
except KeyboardInterrupt:
........if d != None:
................d.setModeIDLE()
d.setModeIDLE()
print "Exiting..."
sys.exit()
It works, the script is outputting frames, 6 at a time, whenever I turn the wheel on the remote controller. Now we need to determine the length of the frame. In the script hereinabove, I randomly asked for 250 bytes but we need a proper way to know the end of the frame. At first, I used the trailing 0’s but it turned out that the information was already out there. Remember that at the bottom left-hand corner of the remote controller, it states “RADIO 868 X2D”. A little bit of internet searching reveals that X2D is the name of a proprietary protocol from a company named Delta Dore. Then, a little bit of crawling on their website reveals a training presentation with one particular interesting slide given hereafter.
This slide states that 10011 is the pattern of 5 bits used as a end-of-frame delimiter by the X2D protocol. This is exactly what we have right before the trailing 0’s but in reverse order, indicating, by the way, that bit fields and most likely bytes are ordered using big-endian representation (MSB at the lowest address). Now that we know how to properly spot the end of frame and that we will have to be careful later on regarding the endianness, we need to find which line code pattern is used.
To do that, no other options than exhaustively try all form of Manchester coding and Biphase coding and then observing the result hoping for a clue that this is the right one. Interestingly, with Biphase-S, the first 5 decoded bits are, in reverse order (endianness...), exactly the ones stated in the Delta Dore slide as the start-of-frame delimiter (01011).
Let’s assume for now that Biphase-S is the correct line code and study the data between the start-of-frame delimiter (stripped after decoding) and the end-of-frame delimiter (stripped before decoding). Surprisingly, there’s a lot of bits for one frame. This is a surprise because I was expecting short fixed-length data from such HVAC device. Hereafter is an example of such data:
‘00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000001101000001111010101111111111111111010111111111011011001111111111110100000100100001000000000100000011010000011110101011111111111111110101111111110110110011111111111101000001001000010000000001000000110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000001101000001111010101111111111111111010111111111011011001111111111110100000100100001000000000100000011010000011110101011111111111111110101111111110110110011111111111101000001001000010000000001000000110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000001101000001111010101111111111111111010111111111011011001111111111110100000100100001000000000100000011010000011110101011111111111111110101111111110110110011111111111101000001001000010000000001000000110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000001'
We can see that there is pattern as shown in the following string splits:
-----------0000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000001-----
Obviously, the frame contains several copies of the same message. I assume this is a fault-tolerant mechanism and now at least, the actual message is indeed short and fixed-length. However, there are missing bits at the beginning of the first message and at the end of the last message.
Regarding the first message, the missing bits are actually the sync word and the end of the preamble. This means that the once thought start-of-frame delimiter (01011) is actually a start-of-message delimiter with additional zeroes from the decoded preamble (0000101100). In the end, with the RfCat script, we must use ‘1100110011001100110010101010’ as the preamble and ‘1010110010110101’ as the sync word (that is, the first 8 bits of the start-of-message delimiter encoded with Biphase-S). It is worth noting that the sync word must be re-added at the beginning of the received data before decoding.
Regarding the last message, likewise, the missing bits are the once thought end-of-frame delimiter (that is, before decoding, ‘010011’). If this is indeed an end-of-message delimiter, then it must be interpreted as an end-of-last-message delimiter (00001110) since all of the previous copies of the message have another end-of-message delimiter(00010000). In the end, the string splits should look as follow:
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000010000
00110100000111101010111111111111111101011111111101101100111111111111010000010010000100000000001110
Hereafter is a link to a Python script that can extract the actual message from X2D frames found in a WAV file created with GNU Radio or from data sent by a RfCat device. It is worth noting that, to retrieve the message that is duplicated several times in each frame, stripped of both delimiters, I chose to rely on a majority-logic approach: the value of each bit of the message will be the value that is most represented within the several copies of the message (if, first of all, the delimiters are correct, this is useful to reject totally scrambled copies of the message). It is also worth noting that the Python code that analyze a WAV file is slow and totally suboptimal but I don’t care :-) Last but not least, the Python script can also forge and send X2D frames if used with a RfCat device (maybe I should rename the script).
http://offsec.online.fr/x2d/x2d-decode.py
Let’s now take a look at the messages depending on what I do with the remote controller. First of all, about the 6 frames, the first three are exact copies of the last three. I assume this is also a fault-tolerant mechanism. Here after are the messages from the 3 unique frames sent by the remote controller depending on how I operate the wheel but also depending on the room temperature triggering (or not) the heater (or the cooler).
power-to-freeze-device-off 5efdffebbf f5 eb 0b ea 01
power-to-freeze-device-off 5efdffebbf f9 eb 0b e2 01
power-to-freeze-device-off 5efdffebbf cd ff 0b 12 02
power-to-night-device-off 5efdffebbf f5 ff 0b c2 01
power-to-night-device-off 5efdffebbf f9 ff 0b ba 01
power-to-night-device-off 5efdffebbf cd ff 0b 12 02
power-to-auto-device-off 5efdffebbf f5 e3 0b fa 01
power-to-auto-device-off 5efdffebbf f9 f3 0b d2 01
power-to-auto-device-off 5efdffebbf cd ff 0b 12 02
power-to-day-device-off 5efdffebbf f5 f3 0b da 01
power-to-day-device-off 5efdffebbf f9 f3 0b d2 01
power-to-day-device-off 5efdffebbf cd ff 0b 12 02
power-to-cool-device-off 5efdffebbf f5 f3 0b da 01
power-to-cool-device-off 5efdffebbf f9 f3 0b d2 01
power-to-cool-device-off 5efdffebbf cd ff 0b 12 02
any-to-power-device-off 5efdffebbf f5 ef 0b e2 01
any-to-power-device-off 5efdffebbf f9 ef 0b da 01
any-to-power-device-off 5efdffebbf cd ff 0b 12 02
power-to-freeze-device-on 5efdffebbf f5 eb 0b ea 01
power-to-freeze-device-on 5efdffebbf f9 eb 0b e2 01
power-to-freeze-device-on 5efdffebbf cd 0f 0b f2 03
power-to-night-device-on 5efdffebbf f5 ff 0b c2 01
power-to-night-device-on 5efdffebbf f9 ff 0b ba 01
power-to-night-device-on 5efdffebbf cd 0f 0b f2 03
power-to-auto-device-on 5efdffebbf f5 e3 0b fa 01
power-to-auto-device-on 5efdffebbf f9 f3 0b d2 01
power-to-auto-device-on 5efdffebbf cd 0f 0b f2 03
power-to-day-device-on 5efdffebbf f5 f3 0b da 01
power-to-day-device-on 5efdffebbf f9 f3 0b d2 01
power-to-day-device-on 5efdffebbf cd 0f 0b f2 03
power-to-cool-device-on 5efdffebbf f5 f3 0b da 01
power-to-cool-device-on 5efdffebbf f9 f3 0b d2 01
power-to-cool-device-on 5efdffebbf cd 0f 0b f2 03
any-to-power-device-on 5efdffebbf f5 ef 0b e2 01
any-to-power-device-on 5efdffebbf f9 ef 0b da 01
any-to-power-device-on 5efdffebbf cd f7 0b 22 02
Several things can be deduced from these messages:
- Freeze-to-night and power-to-night are identical (for example), which means the starting point when rotating the wheel doesn’t matter, only the arrival point;
- The first 2 frames are identical whether the room temperature triggers (or not) the heater (or the cooler), which means these two are related to the “mode of operation” ;
- The last frame is only changing when the room temperature is triggering (or not) the heater (or the cooler), which means this is the actual frame that can turn off (or on) the heater (or the cooler), presumably if the correcte “mode of operation” is set ;
From that point, if I send the appropriate combination of 3 messages using the command “python x2d-decode.py –send <message>”, then I can indeed operate my home heater using a RfCat device. Note that I do not understand the meaning of the different values of the different fields of the message format but this is not really necessary for my needs (but clearly, there are patterns to be figured out).
Jonathan.