4、红外信号发射与捕获:从原始码到空调控制

红外遥控,说白了就是咱们空调遥控器里那套老本行。我刚开始做这个项目时,觉得红外协议挺简单的——不就是发光管闪一闪嘛。结果真上手才发现,坑多着呢。

这一章,咱们要搞定三件事:发射红外信号、捕获空调遥控器的原始码、以及解析和存储这些码。说白了,就是让咱们的板子能“听懂”空调遥控器在说什么,然后自己也能说同样的话。

核心思路:先捕获空调遥控器发出的红外波形,解析出其中的协议和数据,然后把这些数据存起来。需要控制空调时,直接调取数据发射就行。

红外信号捕获与发射流程 红外接收头捕获 原始码(脉冲序列) 协议解析 存储(JSON/文件) 红外发射管发射 捕获 → 解析 → 存储 → 发射,这是红外自学习的完整闭环

4.1 红外接收头:捕获遥控器的“耳朵”

先说说硬件。红外接收头,常见的有 VS1838B、TSOP38238 这些型号。它们内部集成了光电二极管、放大器和解调电路,输出的是解调后的数字信号。

我习惯用 VS1838B,便宜又好买。它的引脚定义很简单:

引脚 名称 说明
1 OUT 信号输出,接 GPIO
2 GND 接地
3 VCC 3.3V 或 5V 供电

小技巧:接收头输出是低电平有效。没信号时输出高电平,收到红外载波时输出低电平。这个特性在捕获时要注意。

4.2 使用 pulseio 捕获原始码

CircuitPython 的 pulseio 模块,专门用来捕获脉冲宽度。说白了,就是记录信号高低电平的持续时间。

我给大家写个捕获函数:

import board
import pulseio
import array

def capture_ir(pin, timeout=0.1, max_pulses=200):
    """
    捕获红外信号,返回脉冲列表
    每个元素是 (duration, level) 元组
    duration 单位:微秒
    level: True=高电平, False=低电平
    """
    with pulseio.PulseIn(pin, maxlen=max_pulses, idle_state=True) as pulses:
        # 等待信号开始
        pulses.clear()
        pulses.resume()
        
        # 等待超时或缓冲区满
        while len(pulses) < max_pulses:
            if pulses.paused:
                break
            if pulses[-1] > timeout * 1000000:  # 超时判断
                break
        
        # 转换为列表
        result = []
        for i in range(len(pulses)):
            result.append((pulses[i], i % 2 == 0))
        
        return result

嗯,这里要注意几个点:

  • idle_state=True:表示空闲时是高电平。因为接收头没信号时输出高。
  • 超时判断:红外信号结束后,接收头会一直输出高电平。我设置 100ms 超时,够用了。
  • 缓冲区大小:空调遥控器的码一般不超过 200 个脉冲,设 200 足够。

避坑指南:我曾经遇到过捕获到的脉冲数量忽多忽少,后来发现是遥控器电池电量不足,导致信号不稳定。换新电池后问题解决。捕获时尽量用新电池。

4.3 原始码解析:从脉冲到数据

捕获到的是脉冲宽度列表,但空调遥控器用的是某种红外协议。常见的空调协议有 NEC、SONY、RC-5 等。不过说实话,很多空调厂商会魔改协议,尤其是美的、格力这些。

解析的核心思路:

  1. 识别引导码:通常是一段较长的低电平 + 一段较长的高电平。
  2. 识别数据位:0 和 1 的脉冲宽度不同。比如 NEC 协议,0 是 560us 低 + 560us 高,1 是 560us 低 + 1690us 高。
  3. 提取数据:按位解析出地址码、命令码等。

我写了个简单的解析器,针对 NEC 协议:

def parse_nec(pulses):
    """
    解析 NEC 协议的红外脉冲
    返回 (address, command) 元组
    """
    if len(pulses) < 68:  # NEC 最少 68 个脉冲
        return None
    
    # 跳过引导码(前两个脉冲)
    data_pulses = pulses[2:]
    
    # 提取数据位
    bits = []
    for i in range(0, len(data_pulses), 2):
        if i + 1 >= len(data_pulses):
            break
        low_time = data_pulses[i][0]
        high_time = data_pulses[i + 1][0]
        
        # 判断是 0 还是 1
        if high_time > 1000:  # 高电平 > 1ms 认为是 1
            bits.append(1)
        else:
            bits.append(0)
        
        if len(bits) >= 32:  # NEC 是 32 位数据
            break
    
    if len(bits) < 32:
        return None
    
    # 组装成 4 个字节
    address = (bits[0] << 7) | (bits[1] << 6) | ...  # 简化处理
    command = (bits[16] << 7) | (bits[17] << 6) | ...
    
    return (address, command)

实际项目经验:我遇到过一款空调,它的协议和标准 NEC 不完全一样——引导码长度不同,数据位顺序也反了。这种情况下,我建议直接存储原始脉冲序列,而不是解析后的数据。发射时原样重放,省去协议适配的麻烦。

4.4 存储方案:把码保存下来

解析后的数据,或者原始脉冲序列,总得存起来。我推荐用 JSON 格式,方便读写,也方便调试。

存储结构示例:

{
  "protocol": "nec",
  "carrier_freq": 38000,
  "pulses": [
    {"duration": 9000, "level": false},
    {"duration": 4500, "level": true},
    {"duration": 560, "level": false},
    ...
  ],
  "address": 0x00,
  "command": 0xBF
}

存成文件后,下次要用直接读取。我习惯把每个按键的码存成一个文件,文件名用按键名命名,比如 power_on.json、temp_up.json。

小建议:如果板子有文件系统(比如 SD 卡或 SPI Flash),直接存文件。如果内存紧张,可以存成字节数组,用 struct 模块打包,能省不少空间。

4.5 发射红外信号:把码“说”出去

发射比捕获简单。用 pulseio.PulseOut 配合红外发射管就行。注意发射管要串联一个限流电阻,一般 100Ω 左右。

发射代码:

import board
import pulseio
import array

def send_ir(pin, pulses, carrier_freq=38000):
    """
    发射红外信号
    pulses: 脉冲列表,每个元素是 (duration, level)
    carrier_freq: 载波频率,默认 38KHz
    """
    # 将脉冲转换为 PulseOut 需要的格式
    # 注意:PulseOut 只接受高电平脉冲,低电平用 0 表示
    pwm_pulses = array.array('H')
    for duration, level in pulses:
        if level:
            pwm_pulses.append(int(duration))
        else:
            pwm_pulses.append(0)  # 低电平不发射载波
    
    with pulseio.PulseOut(pin, frequency=carrier_freq, duty_cycle=2**15) as pulse:
        pulse.send(pwm_pulses)

重要提醒:发射时,红外发射管的正极接 GPIO,负极通过电阻接地。GPIO 的驱动能力有限,如果距离远,建议加三极管放大。我曾经试过直接用 GPIO 驱动,结果 1 米外就控制不了空调了。

4.6 实战:捕获空调遥控器的“开”键

咱们来走一遍完整流程。假设你要捕获空调遥控器的“开机”键:

  1. 连接红外接收头到板子(OUT 接 GPIO,GND 接地,VCC 接 3.3V)。
  2. 运行捕获脚本,按下遥控器的“开”键。
  3. 查看捕获到的脉冲序列,确认长度合理(一般 100-200 个脉冲)。
  4. 解析协议,或者直接存储原始脉冲。
  5. 用发射脚本测试,看空调是否有反应。

我给大家一个完整的测试脚本:

import board
import pulseio
import time
import json

# 捕获
def capture_and_save(pin, filename):
    pulses = capture_ir(pin)
    if pulses:
        data = {
            "protocol": "raw",
            "carrier_freq": 38000,
            "pulses": [{"d": p[0], "l": p[1]} for p in pulses]
        }
        with open(filename, 'w') as f:
            json.dump(data, f)
        print(f"已保存 {len(pulses)} 个脉冲到 {filename}")
    else:
        print("捕获失败,请重试")

# 发射
def load_and_send(pin, filename):
    with open(filename, 'r') as f:
        data = json.load(f)
    pulses = [(p['d'], p['l']) for p in data['pulses']]
    send_ir(pin, pulses, data['carrier_freq'])
    print(f"已发射 {len(pulses)} 个脉冲")

# 使用示例
pin = board.GP16  # 接收头接 GP16
capture_and_save(pin, "power_on.json")

time.sleep(1)

pin_tx = board.GP17  # 发射管接 GP17
load_and_send(pin_tx, "power_on.json")

我的经验:第一次测试时,建议把空调的电源插头拔掉再插上,确保空调处于待机状态。然后发射捕获到的码,看空调是否开机。如果没反应,检查脉冲序列是否完整,或者载波频率是否匹配。有些空调用 38KHz,有些用 40KHz,甚至 56KHz。