├── README.md ├── gr_queue.py └── rtlsdr_FS20_decoder.py /README.md: -------------------------------------------------------------------------------- 1 | Decoding FS20 home automation protocol data with RTL-SDR and GNU Radio 2 | ======================================================================== 3 | 4 | Thomas Frisch 5 | 6 | email: dev@e-tom.de 7 | 8 | Since i have some FS20 based components in use and under development, 9 | there was a need for an universal decoder for the FS20 protocol. Normaly i had done 10 | this as a stand alone µC solutiuon, but my intention was to dig deeper into the RTL-SDR stuff. 11 | 12 | The concept of the OOK decoder and accessing GNU Radio samples live from Python is based in the remote thermometer script 13 | posted by Kevin Mehall. 14 | https://github.com/kevinmehall/rtlsdr-433m-sensor 15 | 16 | This script decodes the packets used within a FS20 home automation system which uses the 868MHz SDR 17 | band for wireless communication. 18 | Typical FS20 components are: 19 | - wireless phase control dimmer 20 | - Switches 21 | - remote controls 22 | - wireless door bells 23 | - alarm system 24 | 25 | Features of the decoder: 26 | - Live monitor of: 27 | - Housecode 28 | - Adresses / Subadresses 29 | - Command with resolution of the meaning 30 | - Extension Byte 31 | - Parity and checksum checksum 32 | 33 | 34 | Each packet is send multiple times by the transmitter (usually 2 or 3 times). The modulation is 35 | OOK with the following symbols: 36 | - logical 1: pulse of 600µs 37 | - logical 0: pulse of 400µs 38 | 39 | Each packet consists of 58 or 67 bits, depending on whether a extension byte is included or not. 40 | 41 | Details on the protocoll can be found here: 42 | http://fhz4linux.info/tiki-index.php?page=FS20%20Protocol 43 | 44 | Currenty only the positive signal portion is used for pulse length measurement, 45 | while the modulation varies also the follwoing low portion. To increase the robustness the detection 46 | could be improved. For me it worked fine with the simple way. 47 | 48 | By the way, this is my first python code, so please don't be too scared. ;-) 49 | 50 | 51 | -------------------------------------------------------------------------------- /gr_queue.py: -------------------------------------------------------------------------------- 1 | # Copyright 2008 Free Software Foundation, Inc. 2 | # 3 | # This file is part of GNU Radio 4 | # 5 | # GNU Radio is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 3, or (at your option) 8 | # any later version. 9 | # 10 | # GNU Radio is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with GNU Radio; see the file COPYING. If not, write to 17 | # the Free Software Foundation, Inc., 51 Franklin Street, 18 | # Boston, MA 02110-1301, USA. 19 | # 20 | # Modified by Kevin Mehall Apr 2012 21 | # * Optimize 22 | # * Add iterator interface 23 | 24 | 25 | from gnuradio import gr 26 | import gnuradio.gr.gr_threading as _threading 27 | import numpy 28 | 29 | ####################################################################################### 30 | ## Queue Sink Thread 31 | ####################################################################################### 32 | class queue_sink_thread(_threading.Thread): 33 | """! 34 | Read samples from the queue sink and execute the callback. 35 | """ 36 | 37 | def __init__(self, queue_sink, callback): 38 | """! 39 | Queue sink thread contructor. 40 | @param queue_sink the queue to pop messages from 41 | @param callback the function of one argument 42 | """ 43 | self._queue_sink = queue_sink 44 | self._callback = callback 45 | _threading.Thread.__init__(self) 46 | self.setDaemon(1) 47 | self.keep_running = True 48 | self.start() 49 | 50 | def run(self): 51 | while self.keep_running: 52 | self._callback(self._queue_sink.pop()) 53 | 54 | ####################################################################################### 55 | ## Queue Sink 56 | ####################################################################################### 57 | class _queue_sink_base(gr.hier_block2): 58 | """! 59 | Queue sink base, a queue sink for any size queue. 60 | Easy read access to a gnuradio data stream from python. 61 | Call pop to read a sample from a gnuradio data stream. 62 | Samples are cast as python data types, complex, float, or int. 63 | """ 64 | 65 | def __init__(self, vlen=1): 66 | """! 67 | Queue sink base contructor. 68 | @param vlen the vector length 69 | """ 70 | self._vlen = vlen 71 | #initialize hier2 72 | gr.hier_block2.__init__( 73 | self, 74 | "queue_sink", 75 | gr.io_signature(1, 1, self._item_size*self._vlen), # Input signature 76 | gr.io_signature(0, 0, 0) # Output signature 77 | ) 78 | #create message sink 79 | self._msgq = gr.msg_queue(4) 80 | message_sink = gr.message_sink(self._item_size*self._vlen, self._msgq, False) #False -> blocking 81 | #connect 82 | self.connect(self, message_sink) 83 | 84 | self.arr = None 85 | self.idx = 0 86 | 87 | def pop(self): 88 | """! 89 | Pop a new sample off the front of the queue. 90 | @return a new sample 91 | """ 92 | 93 | if self.arr is None: 94 | msg = self._msgq.delete_head() 95 | self.arr = numpy.fromstring(msg.to_string(), self._numpy) 96 | 97 | sample = self.arr[self.idx:self.idx+self._vlen] 98 | self.idx += self._vlen 99 | 100 | if self.idx >= len(self.arr): 101 | self.idx = 0 102 | self.arr = None 103 | 104 | sample = map(self._cast, sample) 105 | if self._vlen == 1: return sample[0] 106 | return sample 107 | 108 | next = pop 109 | 110 | def __iter__(self): return self 111 | 112 | class queue_sink_c(_queue_sink_base): 113 | _item_size = gr.sizeof_gr_complex 114 | _numpy = numpy.complex64 115 | def _cast(self, arg): return complex(arg.real, arg.imag) 116 | 117 | class queue_sink_f(_queue_sink_base): 118 | _item_size = gr.sizeof_float 119 | _numpy = numpy.float32 120 | _cast = float 121 | 122 | class queue_sink_i(_queue_sink_base): 123 | _item_size = gr.sizeof_int 124 | _numpy = numpy.int32 125 | _cast = int 126 | 127 | class queue_sink_s(_queue_sink_base): 128 | _item_size = gr.sizeof_short 129 | _numpy = numpy.int16 130 | _cast = int 131 | 132 | class queue_sink_b(_queue_sink_base): 133 | _item_size = gr.sizeof_char 134 | _numpy = numpy.int8 135 | _cast = int 136 | 137 | ####################################################################################### 138 | ## Queue Source 139 | ####################################################################################### 140 | class _queue_source_base(gr.hier_block2): 141 | """! 142 | Queue source base, a queue source for any size queue. 143 | Easy write access to a gnuradio data stream from python. 144 | Call push to to write a sample into the gnuradio data stream. 145 | """ 146 | 147 | def __init__(self, vlen=1): 148 | """! 149 | Queue source base contructor. 150 | @param vlen the vector length 151 | """ 152 | self._vlen = vlen 153 | #initialize hier2 154 | gr.hier_block2.__init__( 155 | self, 156 | "queue_source", 157 | gr.io_signature(0, 0, 0), # Input signature 158 | gr.io_signature(1, 1, self._item_size*self._vlen) # Output signature 159 | ) 160 | #create message sink 161 | message_source = gr.message_source(self._item_size*self._vlen, 1) 162 | self._msgq = message_source.msgq() 163 | #connect 164 | self.connect(message_source, self) 165 | 166 | def push(self, item): 167 | """! 168 | Push an item into the back of the queue. 169 | @param item the item 170 | """ 171 | if self._vlen == 1: item = [item] 172 | arr = numpy.array(item, self._numpy) 173 | msg = gr.message_from_string(arr.tostring(), 0, self._item_size, self._vlen) 174 | self._msgq.insert_tail(msg) 175 | 176 | class queue_source_c(_queue_source_base): 177 | _item_size = gr.sizeof_gr_complex 178 | _numpy = numpy.complex64 179 | 180 | class queue_source_f(_queue_source_base): 181 | _item_size = gr.sizeof_float 182 | _numpy = numpy.float32 183 | 184 | class queue_source_i(_queue_source_base): 185 | _item_size = gr.sizeof_int 186 | _numpy = numpy.int32 187 | 188 | class queue_source_s(_queue_source_base): 189 | _item_size = gr.sizeof_short 190 | _numpy = numpy.int16 191 | 192 | class queue_source_b(_queue_source_base): 193 | _item_size = gr.sizeof_char 194 | _numpy = numpy.int8 195 | 196 | -------------------------------------------------------------------------------- /rtlsdr_FS20_decoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Decoder for ELV FS20 home automation protocol 4 | using RTL-SDR and GNU Radio 5 | Frequ.: 868.35 MHz 6 | (C) 2013 Th. Frisch 7 | Licensed under the terms of the GNU GPLv3+ 8 | 9 | NOTE: The data aquisition part and OOK decoder is originally from Kevin Mehall 10 | """ 11 | 12 | from gnuradio import gr 13 | import gr_queue 14 | from gnuradio import blks2 15 | from gnuradio import audio 16 | from gnuradio.gr import firdes 17 | import osmosdr 18 | 19 | 20 | freq = 868.23e6 21 | freq_offs = 120e3 #Offset to avoid noise at 0Hz downconversion 22 | 23 | # Threshold for OOK 24 | level = -0.35 25 | 26 | class rtlsdr_am_stream(gr.top_block): 27 | """ A GNU Radio top block that demodulates AM from RTLSDR and acts as a 28 | python iterator for AM audio samples. 29 | 30 | Optionally plays the audio out the speaker. 31 | """ 32 | 33 | def __init__(self, center_freq, offset_freq, decimate_am=1): 34 | """Configure the RTL-SDR and GNU Radio""" 35 | super(rtlsdr_am_stream, self).__init__() 36 | 37 | audio_rate = 48000 38 | device_rate = audio_rate * 25 39 | output_rate = audio_rate / float(decimate_am) 40 | self.rate = output_rate 41 | 42 | self.osmosdr_source = osmosdr.source_c("") 43 | self.osmosdr_source.set_sample_rate(device_rate) 44 | self.osmosdr_source.set_center_freq(freq) 45 | 46 | self.osmosdr_source.set_freq_corr(0, 0) 47 | self.osmosdr_source.set_gain_mode(1, 0) 48 | self.osmosdr_source.set_gain(20, 0) 49 | 50 | taps = firdes.low_pass(1, device_rate, 40000, 5000, firdes.WIN_HAMMING, 6.76) 51 | self.freq_filter = gr.freq_xlating_fir_filter_ccc(25, taps, -freq_offs, device_rate) 52 | 53 | self.am_demod = blks2.am_demod_cf( 54 | channel_rate=audio_rate, 55 | audio_decim=1, 56 | audio_pass=5000, 57 | audio_stop=5500, 58 | ) 59 | 60 | self.resampler = blks2.rational_resampler_fff( 61 | interpolation=1, 62 | decimation=decimate_am, 63 | ) 64 | 65 | self.sink = gr_queue.queue_sink_f() 66 | 67 | self.connect(self.osmosdr_source, self.freq_filter, self.am_demod) 68 | self.connect(self.am_demod, self.resampler, self.sink) 69 | 70 | 71 | def __iter__(self): 72 | return self.sink.__iter__() 73 | 74 | def transition(data, level=-0.35): 75 | """Threshold a stream and yield transitions and their associated timing. 76 | Used to detect the On-Off-Keying (OOK)""" 77 | last_state = False 78 | last_i = 0 79 | for i, y_sample in enumerate(data): 80 | state = (y_sample > level) 81 | if state != last_state: 82 | yield (state, i-last_i, i) 83 | last_i = i 84 | last_state = state 85 | 86 | def decode_osv1(stream, level=-0.35): 87 | """Decoder waits for a rising edge after 5ms lowlime and decodes pulse-width""" 88 | 89 | state = 'wait' 90 | pkt = [] 91 | for direction, time, abstime in transition(stream, level): 92 | # convert the time in samples to microseconds 93 | time = time / float(stream.rate) * 1e6 94 | if state == 'wait' and direction is True and time > 5000: 95 | # Wait for Rising edge after l5ms low phase 96 | state = 'data' 97 | pkt = [] 98 | framelength = 58 99 | elif state == 'data': 100 | # Receive Data Block 101 | if direction is True: 102 | # Rising edge (end of LOW level) 103 | if (290 < time < 800): 104 | pass #positive condition for debug reasons 105 | else: 106 | state = 'wait' 107 | 108 | else: 109 | # Falling edge (end of HIGH level) 110 | if (290 < time <= 510): 111 | pkt.append(0) 112 | elif (510 < time < 800): 113 | pkt.append(1) 114 | else: 115 | #print "invalid high time", time 116 | state = 'wait' 117 | if (len(pkt) == 13) and ((sum(pkt) != 1) or (pkt[12] != 1)): 118 | #print "corrupt preamble" 119 | state = 'wait' 120 | 121 | #Look at bit 42 while reception to determine the target length (set extension bit means 67 bit total length, otherwise 58) 122 | if len(pkt)==43 and pkt[42] == 1: 123 | framelength = 67 124 | 125 | #Detect end of frame 126 | if len(pkt) >= framelength: 127 | # Packet complete. 128 | # print "Packet complete, %i Bit received" %len(pkt) 129 | # print pkt #Debug 130 | yield Packet(pkt) 131 | state = 'wait' 132 | 133 | 134 | class Packet(object): 135 | def __init__(self, recdata): 136 | """ Parse received packet into FS20 fields. """ 137 | self.recdata = recdata 138 | self.length = len(recdata) 139 | #Housecode 1 140 | self.hc1 = (self.recdata[13]*2 + self.recdata[14] +1) * 1000 + (self.recdata[15]*2 + self.recdata[16] +1) * 100 + (self.recdata[17]*2 + self.recdata[18] +1) * 10 + (self.recdata[19]*2 + self.recdata[20] +1) 141 | #Housecode 1 parity check 142 | self.hc1_p = (sum(self.recdata[13:21])%2 == self.recdata[21]) 143 | #Housecode 2 144 | self.hc2 = (self.recdata[22]*2 + self.recdata[23] +1) * 1000 + (self.recdata[24]*2 + self.recdata[25] +1) * 100 + (self.recdata[26]*2 + self.recdata[27] +1) * 10 + (self.recdata[28]*2 + self.recdata[29] +1) 145 | #Housecode 2 parity check 146 | self.hc2_p = (sum(self.recdata[22:30])%2 == self.recdata[30]) 147 | #Adressgroup 148 | self.ag = (self.recdata[31]*2 + self.recdata[32] +1) * 10 + (self.recdata[33]*2 + self.recdata[34] +1) 149 | #Subadressgroup 150 | self.sg = (self.recdata[35]*2 + self.recdata[36] +1) * 10 + (self.recdata[37]*2 + self.recdata[38] +1) 151 | #Adressgroup parity check 152 | self.asg_p = (sum(self.recdata[31:39])%2 == self.recdata[39]) 153 | #Command 154 | self.cmd = self.bits2num(43, 5, 2) 155 | #Extension 156 | self.ext = self.recdata[42] 157 | #Bidirectional 158 | self.bidi = self.recdata[41] 159 | #Answer 160 | self.answer = self.recdata[40] 161 | #Command parity check 162 | self.cmd_p = (sum(self.recdata[40:48])%2 == self.recdata[48]) 163 | 164 | #Apply offset in case of extension bit is set 165 | if self.ext == 1 and len(self.recdata) == 67 and self.cmd_p is True: 166 | ext_offset = 9 167 | self.extension = self.bits2num(49, 8, 2) 168 | else: 169 | ext_offset = 0 170 | self.extension = 0 171 | 172 | #Checksum 173 | self.checksum = self.recdata[ext_offset+49]*128 + self.recdata[ext_offset+50]*64 + self.recdata[ext_offset+51]*32+ self.recdata[ext_offset+52]*16 +self.recdata[ext_offset+53]*8 +self.recdata[ext_offset+54]*4 +self.recdata[ext_offset+55]*2+self.recdata[ext_offset+56] 174 | #Calculate Checksum 175 | self.calc_checksum = self.bits2num(13, 8, 2)+self.bits2num(22, 8, 2)+self.bits2num(31, 8, 2)+self.bits2num(40, 8, 2) + 6 176 | 177 | if self.ext == 1: 178 | #self.calc_checksum += self.recdata[49]*128 + self.recdata[50]*64 + self.recdata[51]*32+ self.recdata[52]*16 +self.recdata[53]*8 +self.recdata[54]*4 +self.recdata[55]*2+self.recdata[56]+6 179 | self.calc_checksum += self.bits2num(49, 8, 2) + 6 180 | 181 | while self.calc_checksum > 255: 182 | self.calc_checksum = self.calc_checksum - 256 183 | 184 | #Check Checksum 185 | self.check = (self.checksum == self.calc_checksum) 186 | 187 | def bits2num(self, pos, length, base): 188 | sum = 0 189 | i = 0 190 | while i < length: 191 | sum *= base 192 | sum += self.recdata[pos+i] 193 | i+=1 194 | return sum 195 | 196 | if __name__ == '__main__': 197 | import sys 198 | import time 199 | 200 | # Command description resolution 201 | cmdres = {00 : '"off"', 202 | 1 : '"on" with 6.25%', \ 203 | 2 : '"on" with 12.5%', \ 204 | 3 : '"on" with 18.75%', \ 205 | 4 : '"on" with 25%', \ 206 | 5 : '"on" with 31.25%', \ 207 | 6 : '"on" with 37.5%', \ 208 | 7 : '"on" with 43.75%' , \ 209 | 8 : '"on" with 50%', \ 210 | 9 : '"on" with 56.25%' , \ 211 | 10 : '"on" with 62.5%' , \ 212 | 11 : '"on" with 68.75%' , \ 213 | 12 : '"on" with 75%' , \ 214 | 13 : '"on" with 81.25%' , \ 215 | 14 : '"on" with 87.5%' ,\ 216 | 15 : '"on" with 93.75%' , \ 217 | 16 : '"on" with 100%' ,\ 218 | 17 : '"on" with last value', \ 219 | 18 : 'toggle between on - off - last_value' , \ 220 | 19 : 'dim up', \ 221 | 20 : 'dim down', \ 222 | 21 : 'dim in a loop up-pause-down-pause', \ 223 | 22 : 'set timer (start, end)', \ 224 | 23 : 'status request (only for bidirectional devices)', \ 225 | 24 : 'off, timer', \ 226 | 25 : 'on, timer', \ 227 | 26 : 'last value, timer', \ 228 | 27 : 'reset to default', \ 229 | 29 : 'not used', \ 230 | 30 : 'not used', \ 231 | 31 : 'not used'} 232 | 233 | 234 | stream = rtlsdr_am_stream(freq, freq_offs, decimate_am=2) 235 | stream.start() 236 | print"Decoder for FS20 home automation protocol \nusing RTL-SDR and GNU Radio\nFrequ.: 868.35 MHz\n(C) 2013 Th. Frisch\n" 237 | packet_nbr=1; 238 | for packet in decode_osv1(stream): 239 | 240 | if packet.cmd_p is True and packet.hc1_p is True and packet.hc2_p is True and packet.asg_p is True and packet.check is True: 241 | result = '\033[1;32mPacket OK\033[1;m' 242 | else: 243 | result = '\033[1;31mParity/Checksum error\033[1;m' 244 | 245 | 246 | print "-- FS20 Packet - No. %i --------- Packet Length: %i bit ---------- %s --------------" %(packet_nbr, packet.length, result) 247 | packet_nbr+=1 248 | print "Housecode: %s-%s "%(packet.hc1, packet.hc2) 249 | print "Housecode Parity ok (code1/code2): %s/%s" %(packet.hc1_p, packet.hc2_p) 250 | print "Adressgroup/Subadressgroup: %i-%i " %(packet.ag, packet.sg) 251 | print "Adress Parity ok: %s" %(packet.asg_p) 252 | print "Command: %i, %s" %(packet.cmd, cmdres[packet.cmd]) 253 | print "Message extenstion Bit: %s" % packet.ext 254 | print "Bidirectional Bit: %s" % packet.bidi 255 | print "Answer from receiver Bit: %s" % packet.answer 256 | print "Command Parity ok: %s " % packet.cmd_p 257 | if packet.ext == 1: 258 | print "Extension Byte: %i" % packet.extension 259 | print "Checksum: %i " % packet.checksum 260 | print "Checksum ok: %s\n" % packet.check 261 | 262 | 263 | --------------------------------------------------------------------------------