如何用尽可能少的代码读取(多个)红外遥控器的编码
How to read codes from (many) IR remote controls with as little code as possible

原始链接: https://bitbanksoftware.blogspot.com/2026/11/how-to-read-codes-from-many-ir-remote.html

廉价红外遥控器无处不在;本项目使用低成本红外接收器,从沙发恢复 Mac 媒体控制。作者解释 NEC 协议解码:36–40 kHz 载波被解调为低电平有效脉冲,32 位编码地址和命令,以及用于校验的反相副本。时序使用约 562.5 µs 的倍数;位采用脉冲距离编码,起始由足够的标记与间隔持续时间识别。使用 Arduino 兼容的 Seeed Xiao nRF52840 板,作者编写了最小化解码代码,通过启用 nRF52 硬件周期计数器改进计时,并将识别到的遥控器代码映射到 USB HID 媒体键,如音量、静音、播放/暂停和跳过。学习模式允许用户按两次遥控器按钮来定义七个键,存储在闪存中。项目还支持 ESP32-S3,并使用 Adafruit TinyUSB 进行键盘模拟。总体而言,它展示了将任意红外遥控器变成 PC 或 Mac 媒体控制器的简单、低成本方法。

Hacker News 的一个帖子链接到一篇关于用最少代码读取红外遥控编码的文章。一名评论者说他们在 Raspberry Pi Pico 上用 MicroPython 做过类似的事情。他们指出遥控器使用不同的格式,经常重复数据,而分体空调遥控器会发送完整状态以及用于半度设置的第二条消息。发射红外更难,因为 LED 必须以约 38 kHz 脉冲,且波长可能不同;他们尝试控制分体空调失败,可能是因为 LED 波长不对。
相关文章

原文

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. If you're more interested in the finished project running on your own hardware, then you can skip ahead to The Project section below.

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

Setting up the hardware is not difficult if you don't mind a little soldering. I chose this spot on the PCB since the pin order of the IR receiver lines up with the pins on the Xiao (3.3V, GND, Out). The pin marked "D11" is actually GPIO 30 (don't ask me why Arduino still does this silliness with multiple names for the same pins). I soldered the receiver close to the PCB (short leads) because the leads of the receiver are not very stiff and I didn't want them getting bent or accidentally getting shorted together.

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

N.B. The first time you run this on your Mac/PC, it will ask you to set up the 'keyboard'. You can click the close button on this setup window and it will both work fine and not ask you again.

Learn Mode

Pressing the 'usr1' pushbutton toggles from RUN to LEARN mode (and back if you want to cancel). In LEARN mode it guides you to define the 7 predefined keys by pressing each remote control button twice to confirm each one. Once you've defined the 7 keys, the unique codes get written to internal FLASH (as a file) and the program returns to RUN. mode.

In RUN mode the firmware will wait for the reception of a recognized code (you have to define them in LEARN mode first) and turn it into a USB keypress. This will allow using any IR remote control to comfortably control your PC from across the room.

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 >

}

N.B. When using the USB HID Keyboard feature of the ESP32-S3 or nRF52840, you'll need to get the MCU back into bootloader mode for it to accept new code. On the ESP32-S3 you press+hold reset+boot, then release reset, then release boot. On the nRF52840 you can double-tap the reset button (on the Xiao nRF52840 you'll need good vision and a sharp fingernail to reach it).

//#define DEBUG_MODE

I added this when I was figuring things out and left it in there. When defined, the program will print all activity to the serial terminal instead of sending keypresses over USB. Uncomment this line if you need to experiment.

Building The Project

Download it into a folder with the same name as the .ino file (required by Arduino)

It depends on the Arduino IDE and the following libraries:
bb_spi_lcd (mine)
Adafruit_LittleFS (Adafruit)
Adafruit_TinyUSB (Adafruit)

Select 'Seeed Xiao nRF52840 Plus' as the target board. Then Compile/Upload and you're ready to use it.

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 project. As always, please share any questions or comments below.
联系我们 contact @ memedata.com