├── README.md ├── decode_os433.py └── gr_queue.py /README.md: -------------------------------------------------------------------------------- 1 | Decoding Oregon Scientific wireless sensor data with RTL-SDR and GNU Radio 2 | =========================================================================== 3 | 4 | Kevin Mehall 5 | http://kevinmehall.net 6 | 7 | This script decodes the packets that Oregon Scientific remote 8 | thermometers (like the one pictured below) send to the display unit. It also 9 | serves as example code for accessing [rtl-sdr][] / GNU Radio samples live from 10 | Python. 11 | 12 | ![Picture of sensor](http://kevinmehall.net/s/2012/oregon-scientific-sensor.jpeg) 13 | 14 | Each sensor transmits every 30 seconds on 433.9MHz. The packet is repeated 15 | twice. Modulation is [On-off keying][ook], and the 32 data bits are 16 | [manchester encoded][manchester]. Alexander Yerezeyev implemeted a 17 | [decoder for AVR][avr-code] microcontrollers, and wrote up a 18 | [description of the protocol][alyer]. 19 | 20 | My sensors use the V1 protocol, but if you have newer sensors, take a look at 21 | [JeeLabs' description][jeelabs-v2] of the V2 protocol. It would probably be 22 | simple to adapt my code. 23 | 24 | [rtl-sdr]: http://sdr.osmocom.org/trac/wiki/rtl-sdr 25 | [ook]: http://en.wikipedia.org/wiki/On-off_keying 26 | [manchester]: http://en.wikipedia.org/wiki/Manchester_encoding 27 | [alyer]: http://alyer.frihost.net/thn128decoding.htm 28 | [avr-code]: http://code.google.com/p/thn128receiver/source/browse/osv1_dec.c 29 | [jeelabs-v2]: http://jeelabs.net/projects/11/wiki/Decoding_the_Oregon_Scientific_V2_protocol 30 | 31 | The GNU Radio [osmosdr block] captures from the [device][p160]. 32 | It's tuned slightly to the side to avoid the DC noise at the local oscillator 33 | frequency. A `freq_xlating_fir_filter_ccc` block selects and downsamples the 34 | correct region of the captured frequency range. Then it AM demodulates that band, and 35 | uses a message sink and queue to bring the samples into Python. (see gr_queue.py). 36 | A Python state machine detects the preamble, manchester-decodes the bits, and 37 | then parses the packet. 38 | 39 | [osmosdr block]: http://cgit.osmocom.org/cgit/gr-osmosdr/ 40 | [p160]: http://blog.kevinmehall.com/post/21103573304/my-10-96-software-defined-radio-arrived 41 | 42 | 43 | You can also (with the `-a` flag) play the AM audio to your speakers. The sensor 44 | packets sound like beeps, and you can hear other devices transmitting on the ISM 45 | band. 46 | -------------------------------------------------------------------------------- /decode_os433.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Decoder for Oregon Scientifics wireless temperature sensors (version 1 protocol) 4 | using RTL-SDR and GNU Radio 5 | 6 | (C) 2012 Kevin Mehall 7 | Licensed under the terms of the GNU GPLv3+ 8 | """ 9 | 10 | from gnuradio import gr 11 | import gr_queue 12 | from gnuradio import blks2 13 | from gnuradio import audio 14 | from gnuradio.gr import firdes 15 | import osmosdr 16 | 17 | # Sensors transmit on 433.9MHz 18 | # The RTL-SDR has more noise near the center frequency, so we tune to the side 19 | # and then shift the frequency in the low-pass filter. 20 | freq = 433.8e6 21 | freq_offs = 100e3 22 | 23 | # Threshold for OOK HIGH level 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, play_audio=False): 34 | """Configure the RTL-SDR and GNU Radio""" 35 | super(rtlsdr_am_stream, self).__init__() 36 | 37 | audio_rate = 44100 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_center_freq(freq) 44 | self.osmosdr_source.set_sample_rate(device_rate) 45 | 46 | taps = firdes.low_pass(1, device_rate, 40000, 5000, firdes.WIN_HAMMING, 6.76) 47 | self.freq_filter = gr.freq_xlating_fir_filter_ccc(25, taps, -freq_offs, device_rate) 48 | 49 | self.am_demod = blks2.am_demod_cf( 50 | channel_rate=audio_rate, 51 | audio_decim=1, 52 | audio_pass=5000, 53 | audio_stop=5500, 54 | ) 55 | self.resampler = blks2.rational_resampler_fff( 56 | interpolation=1, 57 | decimation=decimate_am, 58 | ) 59 | self.sink = gr_queue.queue_sink_f() 60 | 61 | self.connect(self.osmosdr_source, self.freq_filter, self.am_demod) 62 | self.connect(self.am_demod, self.resampler, self.sink) 63 | 64 | if play_audio: 65 | self.audio_sink = audio.sink(audio_rate, "", True) 66 | self.connect(self.am_demod, self.audio_sink) 67 | 68 | def __iter__(self): 69 | return self.sink.__iter__() 70 | 71 | def transition(data, level=-0.35): 72 | """Threshold a stream and yield transitions and their associated timing. 73 | Used to detect the On-Off-Keying (OOK)""" 74 | 75 | last = False 76 | last_i = 0 77 | for i, val in enumerate(data): 78 | state = (val > level) 79 | if state != last: 80 | yield (state, i-last_i, i) 81 | last_i = i 82 | last = state 83 | 84 | def decode_osv1(stream, level=-0.35): 85 | """Generator that takes an audio stream iterator and yields packets. 86 | State machine detects the preamble, and then manchester-decodes the packet """ 87 | 88 | state = 'wait' 89 | count = 0 90 | bit = False 91 | pkt = [] 92 | 93 | for direction, time, abstime in transition(stream, level): 94 | # convert the time in samples to microseconds 95 | time = time / float(stream.rate) * 1e6 96 | 97 | if state == 'wait' and direction is True: 98 | # Start of the preamble 99 | state = 'preamble' 100 | count = 0 101 | pkt = [] 102 | 103 | elif state == 'preamble': 104 | if direction is False: 105 | if (900 < time < 2250): 106 | # Valid falling edge in preamble 107 | count += 1 108 | else: 109 | state = 'wait' 110 | else: 111 | if (700 < time < 1400): 112 | # Valid rising edge in preamble 113 | pass 114 | elif count>8 and (2700 < time < 5000): 115 | # Preamble is over, this was the rising edge of the sync pulse 116 | state = 'sync' 117 | else: 118 | state = 'wait' 119 | elif state == 'sync': 120 | if direction is False: 121 | if (4500 < time < 6800): 122 | # Falling edge of sync pulse 123 | pass 124 | else: 125 | state = 'wait' 126 | else: 127 | # The time after the sync pulse also encodes the first bit of data 128 | if (5000 < time < 6000): 129 | # Short sync time starts a 1 130 | # I haven't actually seen this. Time is a guess 131 | state = 'data' 132 | bit = 1 133 | pkt.append(1) 134 | 135 | elif (6000 < time < 7000): 136 | # Long sync time starts a 0 (because a manchester-encoded 0 begins low) 137 | state = 'data' 138 | bit = 0 139 | pkt.append(0) 140 | 141 | else: 142 | print "invalid after sync", time 143 | state = 'wait' 144 | elif state == 'data': 145 | # Manchester decoding 146 | if direction is True: 147 | # Rising edge (end of LOW level) 148 | if (700 < time < 1700): 149 | if bit == 0: 150 | # A short LOW time means the 0 bit repeats 151 | pkt.append(0) 152 | elif (1700 < time < 3500): 153 | # A long LOW time means the start of a 0 bit 154 | pkt.append(0) 155 | bit = 0 156 | else: 157 | state = 'wait' 158 | else: 159 | # Falling edge (end of HIGH level) 160 | if (1500 < time < 2500): 161 | if bit == 1: 162 | # a short HIGH time is a repeated 1 bit 163 | pkt.append(1) 164 | elif (2500 < time < 4000): 165 | # A long HIGH time means the start of a 1 bit 166 | pkt.append(1) 167 | bit = 1 168 | else: 169 | print "invalid l data time", time 170 | state = 'wait' 171 | if len(pkt) >= 32: 172 | # Packet complete. Reverse the bits in each byte, convert them to ints, and decode the data 173 | bytestr = [''.join(str(b) for b in pkt[i*8:i*8+8][::-1]) for i in range(0, 4)] 174 | bytes = [int(i, 2) for i in bytestr] 175 | yield Packet(bytes) 176 | state = 'wait' 177 | 178 | class Packet(object): 179 | def __init__(self, bytes): 180 | """ Parse a binary packet into usable fields. """ 181 | self.bytes = bytes 182 | 183 | checksum = bytes[0] + bytes[1] + bytes[2] 184 | self.valid = (checksum&0xff == bytes[3]) or (checksum&0xff + checksum>>8 == bytes[3]) 185 | 186 | self.channel = 1 + (bytes[0] >> 6) 187 | 188 | t2 = bytes[1] >> 4 189 | t3 = bytes[1] & 0x0f 190 | t1 = bytes[2] & 0x0f 191 | sign = bool(bytes[2] & (1<<5)) 192 | temp = t1*10 + t2 + t3 / 10.0 193 | if sign: temp *= -1 194 | self.temp_c = temp 195 | self.temp_f = temp * 9.0/5.0 + 32 196 | 197 | self.batt = bool(bytes[2] & (1<<7)) 198 | self.hbit = bool(bytes[2] & (1<<6)) 199 | 200 | def hex(self): 201 | return ' '.join('%02X'%x for x in self.bytes) 202 | 203 | if __name__ == '__main__': 204 | import sys 205 | import time 206 | from optparse import OptionParser 207 | 208 | parser = OptionParser(usage='%prog [options]') 209 | parser.add_option('-l', '--log', type='string', dest='log', 210 | metavar='NAME', help='Log readings to .csv') 211 | parser.add_option('-a', '--audio', action='store_true', dest='audio', 212 | help="Play AM-demodulated signal to the speakers") 213 | (options, args) = parser.parse_args(sys.argv[1:]) 214 | 215 | logfiles = {} 216 | if options.log: 217 | for channel in range(1, 3+1): 218 | logfiles[channel] = open("{0}{1}.csv".format(options.log, channel), 'at') 219 | 220 | stream = rtlsdr_am_stream(freq, freq_offs, decimate_am=2, play_audio=options.audio) 221 | stream.start() 222 | unit = 'F' 223 | for packet in decode_osv1(stream): 224 | flags = [] 225 | 226 | if not packet.valid: 227 | flags.append('[Invalid Checksum]') 228 | 229 | if packet.batt: 230 | flags.append('[Battery Low]') 231 | 232 | if packet.hbit: 233 | flags.append('[Sensor Failure]') 234 | 235 | if unit is 'F': 236 | temp = packet.temp_f 237 | else: 238 | temp = packet.temp_c 239 | 240 | print "{hex} = Channel {channel}: {temp} {unit} {flags}".format( 241 | channel=packet.channel, 242 | temp=temp, 243 | unit = unit, 244 | flags = ' '.join(flags), 245 | hex = packet.hex() 246 | ) 247 | 248 | logfile = logfiles.get(packet.channel, None) 249 | if logfile: 250 | logfile.write("{0},{1},{2}\n".format(time.asctime(),temp,packet.hex())) 251 | logfile.flush() 252 | 253 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------