How to read codes from (many) IR remote controls with as little code as possible

Background

Inexpensive infrared (IR) remote controls have been ubiquitous in our lives for many many years. This is because the technology is inexpensive and works so well that it fades into the background. The IR signal gets 'sprayed' (transmitted/reflected/received) so effectively in most indoor environments that you rarely have to care in which direction the LED is pointing and it somehow 'just works'. The clever inventors of the technology discovered that by modulating the digital data on a 36 to 40KHz carrier wave, it would have a high signal to noise ratio (be detectable with a relatively weak transmitter in very noisy conditions). The wavelength of IR chosen (940nm) was useful because it is just beyond visible light, but not too far beyond the properties of light that it still reflects well off of ordinary surfaces. The reason for this article, and the software project I created, is to harness these ubiquitous controls to restore functionality to my MacBook that Apple took away several generations ago - to use an IR remote control to drive Mac media playback (e.g. watch a movie with the Mac connected to your TV) from the comfort of your sofa. After seeing the complicated (and sometimes expensive) projects and products to accomplish this, I decided to learn some of the gritty details and create it for myself. As an eternal optimizer, I also wanted to see the minimum amount of code needed to accomplish the task.

The Hardware

Vishay Semiconductors seems to be the "go-to" source of IR receiver hardware these days. They sell several low cost IR demodulators (e.g. TSOP1836) that are very efficient at detecting, amplifying and demodulating the digital signal from IR remote controls. The carrier frequency is stripped off and the output becomes an active-low (open collector) signal which is active when the carrier frequency is present. These modern demodulators are extremely power efficient too; a few microamps is all that is needed to continuously receive the data. I'm not going to cover the transmission side of IR remote control codes for this article. The job of modulating output signals on a 38KHz carrier is usually left for the MCU/CPU to do. IR transmit hardware normally consists of one or more IR LEDs and little else.

I was able to buy 10 pairs of VS1838B receivers plus 5mm IR LEDs for about $5. These are what I'm using for my project(s). Since this receiver can coexist on an I2C bus (it has open collector output), I made it easy to use with some of my IoT devices which have a QWIIC socket.

Photo of a VS1838B receiver soldered to an I2C QWIIC male cable

Existing Software

A quick search of existing blogs/info about this topic generally provides very little technical info and just links to the IRremote library for Arduino or the LIRC package for Linux. Both of these are mature, complete solutions with a lot of history. That's all well and good, but using other people's libraries is usually not interesting to me; I need to re-invent every wheel. I'm working with both Nordic and Espressif microcontrollers, so I started by looking at the IRremote library. The sheer bulk of the code and quantity of #ifdef's discouraged me from exploring that code too deeply. I decided instead to just look at the details of the protocol and write my own code from a blank slate.

The Protocol Standard(s)

There have been many IR control schemes over the years, but the two most prevalent were created by Philips and NEC. From a brief sampling of the remote controls in my house, NEC protocol is the current winner of this battle. Since I only see NEC codes coming out of my remotes, my project goal is to write software to receive these codes.

This is the image I used to figure out how to decode these signals:


The image came from this article: https://www.histo.cat/e/IR-NEC-Remote-Pic-Source-GitHub

(The solid red bars are actually a 50% duty cycle 36-40KHz square wave)

The article goes on to show how the 0's and 1's are encoded with PDM (pulse distance encoding):

  • Logical '0' – a 562.5µs mark followed by a 562.5µs space, with a total transmit time of 1.125ms
  • Logical '1' – a 562.5µs mark followed by a 1687.5µs space, with a total transmit time of 2.25ms

The protocol looks simple enough. After the start phase, there are 32 data bits which encode an 8-bit address, followed by 8 bits of the same value inverted, then an 8-bit command followed by its inverted value. This is a clever way to encode 16 bits in a fixed amount of time (it's guaranteed that there are an equal number of 1s and 0s) and have the data be self-verifying. I saw different values of 'address' on my various remote controls, so for my projects I combined the address and command values into a unique 16-bit value which I use to identify the buttons. 

My initial decoding concept: once I see the initial falling edge from the demodulator (active signal at the start phase of each code), measure that 'mark' period and the space period after it. If it checks out, then capture the next series of transitions as 32 bits and voilá.

All of the timing is based on a multiple of the 562.5us minimum period, so as long as I can count the time in terms of multiples of this period, I should be able to decode the signal.

My First Attempts

The Arduino API includes a function called micros() which returns the number of microseconds elapsed since the power was turned on. The problem with this function is that it's implemented very differently on different microcontrollers and the resolution of the counter can vary quite a bit. I decided to use the Seeed Xiao nRF52840 1.47" Touch LCD board as the basis of the project since it has support for native USB HID keyboard output and two pushbuttons and a capacitive touch layer for user input (photo below):


This board has a 'Xiao nRF52840 Plus' board underneath - this is a 64MHz Cortex-M4 MCU with plenty of speed and memory to handle the job. I soldered the IR demodulator onto the header pins directly since this board doesn't expose a QWIIC socket (see photo). My first try at capturing codes on the nRF52 was getting unreliable results - every button press from my chosen remote control resulted in a random or invalid value. I looked through the Arduino source code for the nRF52 and saw that the micros() function defaulted to using some interrupt-driven counter that had a low resolution. The nRF52840 has a hardware counter register that increments at the rate of the CPU clock. Its use is optional and requires that you enable it explicitly. This is the code I found in the cores/nRF5/delay.h file:

static inline uint32_t micros( void ) __attribute__((always_inline));

static inline uint32_t micros( void )

{

  // Use DWT cycle count if it is enabled, otherwise use rtos tick

  return dwt_enabled() ? (DWT->CYCCNT / 64) : tick2us(xTaskGetTickCount());

}


This led me to find the dwt_enable() function which switches it to use the highly accurate internal cycle counter. After making this change, my code began to work. The ESP32-S3's micro() function has enough resolution to work without any extra effort.

After a few false starts, here is what I came up with for the minimum viable set of code to properly decode the 32-bit NEC codes:

uint32_t getCode(void)
{
int mark, space, iBit = 0;
uint32_t u32Bits = 0;
bool bDone = false, bStarted = false;
long l, lStartTime = micros();

while (!bDone && micros() - lStartTime < 68000) { // max time to receive any code is 68ms
l = micros();
while (digitalRead(IR_PIN) == 0) { } // mark of a bit
l = micros() - l; // time of the active pulse (mark) in microseconds
mark = l / 512; // close estimate of how many 562.5us periods passed
l = micros();
while (digitalRead(IR_PIN) == 1 && (micros() - l) < 20000) {} // space of a bit
l = micros() - l; // time of the inactive period (space) in microseconds
if (l >= 20000) { // too long of a quiet period == invalid code; exit
bDone = true;
} else {
space = l / 512;
if (bStarted) {
u32Bits <<= 1;
if (space == 3) { // a 1 bit (inactive signal time == 3 x 562.5us)
u32Bits |= 1;
}
iBit++;
if (iBit == 32) bDone = true;
} // bStarted
if (mark >= 3 && space >= 3) bStarted = true; // start of a code is at least 1.5ms + 1.5ms (mark+space)
}
} // while collecting bits
return u32Bits;
} /* getCode() */

Notes:
- It's not necessary to check for timeouts while waiting for a 'mark' to finish; the IR signal can't continue forever.
- The duration of each start period didn't match the 9+4.5ms of the spec. On my remotes, it was closer to 3+3ms. This is why my code accepts a duration of >= 1.5ms of mark + >= 1.5ms of space as a valid start.
- I divide the time periods by 512 instead of 562.5 because it's close enough and this makes the code much simpler and more compact since dividing by 512 is just shifting right by 9 bits.

The Project

After getting the IR code reception working, my next step was to make it do something useful. There are 6 important keys to press for media playback:
Volume Up + Down, Mute, Play/Pause, Forward + Backward (optional - toggle closed captions)

The USB HID keyboard values for these are named as follows:
HID_KEY_VOLUME_UP
HID_KEY_VOLUME_DOWN
HID_KEY_MUTE
HID_KEY_SPACE (used to toggle play/pause on most media players)
HID_KEY_C (used to toggle closed captions)
HID_KEY_ARROW_RIGHT + KEYBOARD_MODIFIER_LEFTSHIFT
HID_KEY_ARROW_LEFT + KEYBOARD_MODIFIER_LEFTSHIFT

The firmware will wait for the reception of a recognized code (you define them) and turn it into a USB keypress. This will allow using any IR remote control to comfortably control your PC from across the room. I added a learning function to allow you to get the addr:cmd codes instead of hard coding them in the program.

The USB HID Keyboard part is managed by the Adafruit_TinyUSB library. As much as I like to re-invent wheels, rewriting this library is beyond the amount of free time I have to dedicate to this project. On the Xiao nRF52xxx, this is the code needed to initialize the library and send a key in response to receiving an IR code:

void setup() {
usb_hid.begin();
// wait until device mounted
while( !TinyUSBDevice.mounted() ) {
delay(10);
}
}
//
// Here is a snippet of the sendKey() function
// The 16-bit code is the 8 bit address combined with the 8 bit command
//
void sendKey(uint16_t code)
{
int i;
uint8_t const report_id = 0;
uint8_t const modifier = 0;
uint8_t keycode[6] = { 0 };

// see if we recognize this IR code
for (i=0; i<NUM_KEYS; i++) {
if (u16KeyMap[i] == code) break;
}
if (i == NUM_KEYS) {
return; // not found
}
if ( TinyUSBDevice.suspended()) {
// Wake up host if we are in suspend mode and REMOTE_WAKEUP feature is enabled by host
TinyUSBDevice.remoteWakeup();
}
// skip if hid is not ready e.g still transferring previous report
if ( !usb_hid.ready() ) {
return;
}
switch (i) {
case CMD_REVERSE:
keycode[0] = HID_KEY_ARROW_LEFT;
usb_hid.keyboardReport(report_id, KEYBOARD_MODIFIER_LEFTSHIFT, keycode);
delay(100);
usb_hid.keyboardRelease(0);
break;
< truncated - more key actions >
}

Conclusion

It's very satisfying to learn something new and use that knowledge to create a working project. With the ubiquity of inexpensive IR demodulators, much of the hard work has been done for you, but still satisfying nonetheless. It's even better when that project is well received by your family. I'm already gathering parts for a smaller and lower cost version of this. I will share some finished versions of this project once I finalize them. As always, please share any questions or comments below.

Comments

Popular posts from this blog

How much current do OLED displays use?

Surprise! ESP32-S3 has (a few) SIMD instructions

Fast SSD1306 OLED drawing with I2C bit banging