Extending the Range of a Wireless Temperature Sensor with Walkie-Talkies

Here I describe a hack where I extend the signal from a 433.92MHz wireless temperature (and humidity) sensor to a few kilometers by repeating it using PMR446 walkie-talkies. For visitors from the U.S./Canada, this roughly translates to saying "using FRS walkie-talkies to a few miles". I used AFSK (audio frequency-shift keying) modulated AX.25 frames (a.k.a. packet radio) (at 1200 bps) to be compatible with amateur radio standards. Please keep in mind that this is a hack and I tried to use stuff that I had already lying around. Things could probably have been accomplished easier, but I had fun and learned new things.


After reverse engineering a wireless weather station, I wanted to deploy more temperature sensors in our garden. One interesting place would be our greenhouse. However, it is behind our garage and not very close to our house (and my receiving server). The sensor was occluded by several stone walls, and signals did not come through---not even after building a Yagi-Uda antenna at the receiving side. I went through my equipment and decided to make a system where a cheap China-quality 433MHz receiver is placed in the range of the sensor. The receiver is connected to an ATmega88 microcontroller which decodes the sensor readings. The Atmega drives a walkie-talkie, and translates the reading into a series of AFSK beeps. My server is connected to another walkie-talkie, and using its sound card it translates the beeps back into temperature/humidity pairs. Schematically, that looks like this:

Dude, your browser sucks!

My description of this project (hack) is split into two parts:

Here is a graph produced with data that travelled from the sensor at 433.92MHz to my microcontroller, which AFSK modulated it and transmitted it via a walkie-talkie at 446.08125MHz to a receiver connected to my server which captured it with its soundcard. Yeah, a bit crazy indeed.

Between 09:00 and 10:00, there is a flat line segment of 10 minutes. At that position a frame (with 10 measurements of 1 minute each) was lost. Unless I missed it, this is the only place where things went wrong, which yields a succes rate of >99% for this test run. Not bad I think, considering the amount of hackery.




Breadboard with ATmega

1. Producing Packets: AFSK Modulation, NRZI and AX.25


AFSK stands for audio frequency-shift keying. When performing frequency-shift keying (FSK), one changes the frequency of a carrier signal to encode symbols. For binary FSK, two frequencies are used, one to encode a `1' and another to encode a `0'. AFSK is not much different, but instead of changing the frequency of a carrier signal, the frequency of an audio signal is changed. This changing audio signal can be transmitted via radio or telephone, like voice. It is not quite difficult to do (binary) AFSK: to encode a `0', one outputs a sine wave of one frequency, for a `1' one outputs a sine wave of another frequency. That looks this:

The wave on top is the 0/1 signal, the wave below it is the corresponding AFSK signal. There is one problem: the ugly sharp transitions. As you might know, such sharp signals contain high frequencies, and that is a waste of bandwidth. To avoid them, we should take care of the current phase of the sine when changing frequency. When we do so, it looks as follows:

That is prettier, isn't it? One easy way to do this in software, this. For each sample, output a wave sin(phase), and increase `phase' with a small step for the lower frequency, or a bigger step for the lower frequency. For the low frequency `f0', this step is 2*pi*f0/fsample; for the high frequency `f1', this step is 2*pi*f1/fsample, where `fsample' is your sample rate, e.g. 44.1kHz or so. For packet radio, Bell 202 AFSK is used: 1.2kHz encodes a `mark', 2.2kHz encodes a `space'. We do not have to care about how `0's and `1's are related with those marks and spaces, as we will see in the next section on NRZI.


Packet radio uses Non-Return-to-Zero Inverted (NRZI) encoding. When the input signal is a `0', the output signal changes. When the input signal is a `1', the output signal does not change. This means that we do not have to produce [some frequency] for [some bit value], we only have to change or keep our currently produced frequency for `0' and `1' respectively.


AX.25 stems from the X.25 protocol, and was designed for amateur radio usage. It is described here. It is rather complex and sometimes also rather... special. I only considered producing unnumbered information frames, that do not require a connection, which is quite convenient for one-way communication. APRS uses it, and some amateur satellites do the same. A frame starts and ends with a flag, encoded as 0b01111110. After it, the address field follows. For my use, it is 14 bytes/octets, for simple `from: A to: B' addressing (it can be longer if the frames are to be repeated by another given station). The 14-byte address consists of a six-byte destination callsign, where each byte is shifted one bit to the left. Next, a destination (SSID+some other bits)-byte follows (for details, see AX.25 reference). After that comes a six-byte source callsign and finally a source (SSID+some other bits)-byte, this time with the LSB set to one as terminator. That makes 14 bytes. Shorter than six-bytes callsigns are padded with spaces. In my case, the address fields for a frame from TH3RMO-0 to WORLD-0 looks like this:

pos	value	meaning

 0	0xae	W shifted to the left
 1	0x9e	O shifted...
 2	0xa4	R etc.
 3	0x98	L
 4	0x88	D
 5	0x40	(space)
 6	0x60	SSID=0, reserved bits = 11, lsb=0
 7	0xa8	T shifted...
 8	0x90	H
 9	0x66	3
10	0xa4	R
11	0x9a	M
12	0x9e	O
13	0x61	SSID=0, reserved bits = 11, lsb=1

After the address field, follows the control byte. Set it to 0x03 (0b00000011) for an unnumbered information frame. Next, the PID follows. Set it to 0xf0. After that, the payload/body/data follows. It is terminated with a 2-byte frame check sequence (fcs). The way to calculate it can be found on this excellent page (among other helpful ax.25 information). Finally, another 0b01111110 flag marks the end of the frame (or the start of any next frame). One important thing before actually sending such a frame is that it one needs to perform bit-stuffing on it---except for the flags. After sending a sequence of 5 `1' bits, a `0' is inserted. In this way, flags can never occur inside the frame, and this also guarantees that the signal changes at least every 6 bits which makes syncing easier. Of course, when receiving a frame, the inverse operation has to be done, the stuffed 0s after 5 1s have to be removed. Ok, this is getting lengthier than I intended, for any questions contact me.

Implementation (Doing the above with a Microcontroller)

I prototyped a modulator in C# that did the above, made my sound card produce the beeps, and verified correct reception with a piece of commercial sound card reception software. It worked, so I started to port it to my ATmega88 in C. I was a bit disappointed that I overlooked something: when searching the ATmega88 datasheet for how the D/A converter works, I found out that it has no D/A. First, I considered doing D/A with pulse-width modulation (PWM) and a RC filter. It did not produce anything close to a sine wave. Next, I considered a resistor ladder network to approximate a sine wave. Unfortunately, I had not enough resistors in stock (and not the right ones). Then I had another idea. I assumed that the walkie-talkie was supposed to have a strong filter, in order to limit the swing of the FM carrier to the allowed bandwidth. I simply fed in a square wave into the radio, and checked what I received. That made me really happy:

Sure, not a perfect sine, but close enough, and far away enough from a square wave (by the way, note how much the filters attenuate the 2.2kHz signal compared to the 1.k2Hz. I don't know if this happens at the sending or receiving side, or probably both). Anyway, I was confident thas this would work, and wrote the code to produce square waves. Producing the sounds is not the only thing, the ``push-to-talk'' (PTT) button of the radio must be pressed. By accident (by plugging a mono jack plug into the radio) I found out that connecting one of the two jack signals to the ground would make it transmit. I programmed the ATMega to set that pin in hi-z when idle, or the 0 when transmitting. The code can be downloaded below.

2. Receiving Packets: AFSK Demodulation with a Sound Card

My idea was to use existing FOSS to do all the work: demodulation and decoding AX.25 that would run under the Linux console. Apparently the standard program to this was ``soundmodem''. I had some trouble to configure it; the receiving part was console, but the configuration part was graphical. The configuration file was poorly documented. Finally, I was able to run the receiving part, and found out that it did nothing but using 100% CPU. So I wrote my own. Please note that my approach is not state of the art, people have most likely devised way more intelligent and robust methods.

From Beeps Back to a Binary Signal

Had I never seen this DTMF detector on Hack a Day, I probably would have started with a FFT. I decided not to do. Using this great page, I designed two fourth-order Butterworth filters, a low pass and a high pass one, both at 1553Hz (that is 2/(1/1200+1/2200) which seemed to make sense). In theory, for perfect filters, the output of the LPF is only the 1.2kHz part, the output of the HPF is only the 2.2kHz part. In theory. In practice, it is close enough. See:

The LPF is performing excellent, but some of the low still passes through the HPF. I believe that this might be due to the first harmonic of the square wave I used to generate/approximate the sines. A band pass filter to only obtain 2.2kHz might improve things, but I didn't test that. As you can see, in the input signal the higher frequency signal has a lower amplitude than the lower frequency signal. This is due to some low-pass filtering by the radios, when sending, receiving or probably both. I compensate for that by multiplying the output of the HPF with an estimated gain of 1.8. This can be done automatically, but I did not bother. Since I wanted to follow the peaks of both filter outputs, I squared their outputs, and ran it through something I called a ``maxer'': a piece of code that returns the maximum value of the past N samples. Now for both two filtered, squared and ``maxed'' signals, I do a simple comparison. If the signal through the LPF is larger than the HPF, I output a one, and a zero otherwise. That looks like this:

And to my surprise, this works good enough. Most of the time.

Symbol Sampling and Processing

To sample symbols, I use a very naive but most of the time good enough solution. I sample the AFSK signal at 9600Hz, and the bit rate is 1200Hz, so a symbol is 8 samples long. The bit-stuffing guarantees that the input signal changes often, so I do this: I have a counter running, and when its value modulo 8 equals 4, I sample the reconstructed binary signal. At every transition of the binary signal I reset the counter, but only if it is approximately a multiple of 8. This is not optimal, since it does not use all the information it can, but only the last transition. Anyway, it works most of the time. And it looks like this:

The bit stream produced by the symbol sampler is ready for use. It needs to be NRZI decoded, and then AX.25 frames can be extracted from it. I wrote a rather simple finite state machine (FSM) to do the job. Yes, the Linux kernel has AX.25 support since almost its beginning, I know. I wrote the following tools for the entire process:


Input: a stream of signed bytes of some signal sampled at 9.6kHz.

Output: a stream of '0's and '1's

Oddities: has a hard coded noise gate to free CPU, has a hard coded gain at 2.2kHz, samples symbols at 1200bps. And has probably some `features' that I don't even know.


Input: an NRZI-encoded stream of '0's and '1's

Output: an NRZI-decoded stream of '0's and '1's


Input: a stream of '0's and '1's

Output: decoded AX.25 frames, like:

fm TH3RMO-0 to WORLD-0 UI pid=f0 len=64

Command line parameters: -v shows debugging output, -c allows display of frames with an invalid checksum.

Connecting the Whole Machinery

Using pipes, one can connect the lose programs. I use ALSA's arecord to capture the line-in, pipe it through afskdemod, then through denrzi and finally through ax25dec. (To be honest, in practice I use yet another program, to decode the temperature and humidity values and put them into a data file that is fed to gnuplot, but you probably don't care). Anyway, from audio to decoded AX.25 frames can be done like this:

roel@wintermuteii~$ arecord -r 9600 -f s8 -t raw | afskdemod | denrzi | ax25dec
Recording raw data 'stdin' : Signed 8 bit, Rate 9600 Hz, Mono
fm TH3RMO-0 to WORLD-0 UI pid=f0 len=64
fm TH3RMO-0 to WORLD-0 UI pid=f0 len=92
This is my PMR446 greenhouse thermometer link @ QTH ....... Please send receipt reports or any questions to `########## at xs4all dot nl', thanks!

3. Some Random Notes

Odds and ends:

4. Source Code

Altough this is a hack, I tried to create somewhat readable code. The sending part targets an ATMega88 (but is quite portable to other hardware that can be targeted by gcc) and is written in C, the receiving part is written in c++ and is structured as a collection of three programs that do the various steps: afskdemod, denrzi and ax25dec. The latter handles only unnumbered information frames (the kind I use).

Receiver source (c++)

Sender source (c for an ATmega88) (contains a few not so obvious hacks and peculiarities, requires some cleaning which I might do in the future)

Questions or comments? I'm not yet web 2.0, so mail me.



Creative Commons License
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.