1. 先把Part 1的底子说清楚
1.1 为什么Part 1还不够用
Part 1写完之后,我把那套“树莓派Pico W + 红外LED + 一个能发几个按键码的简单Web页面”的方案在客厅里用了将近一个月。说实话,单设备测试的时候一切都很美好,电视开机、关机、音量加减都灵,发一个NEC码也没啥延迟。但一旦真正放到日常使用场景里,问题立刻就暴露出来了——电视、空调、机顶盒、风扇、投影仪,五个遥控器摆在茶几上,而我的iPad只能控制其中一台设备,每次切设备还得重新烧录固件或者改配置文件,这种体验连“半成品”都算不上。
Part 2要解决的事情很清楚:让iPad变成一个真正的“万能遥控中心”。不是再做一个测试用的玩具,而是具备多设备切换、红外码库管理、红外学习、场景联动、基础定时这些实用功能的完整工具。如果你手上已经有一套能用的红外发射硬件,哪怕是ESP32而不是Pico W,这篇文章里的软件架构和代码思路也完全能搬过去。
1.2 Part 2要做的三件事
我给自己定了三个核心目标,整个Part 2的代码和调试都是围绕这三件事展开的:
第一件事是码库结构化。Part 1里我是把每一个按键当成一个独立函数写的,广播一个红外码就写一个方法,换设备简直噩梦。Part 2的码库必须变成数据驱动的JSON结构,新增设备只需要往配置里加一段数据,不需要改代码。
第二件事是让iPad上的操作界面像一个真正的遥控器。既然用的是iPad,那就得把iPad的大屏和触控优势用起来:左侧设备列表、右侧按键面板,支持横竖屏切换,按键要有按下反馈,空调这种需要温控调节的设备还要有加减按钮和温度显示。
第三件事是加红外学习功能。市面上的万能遥控器贵不说,很多品牌的空调码在公开库里根本找不到,最靠谱的办法就是自己拿原装遥控器对着接收头“录一遍”。这部分是Part 2里工程量最大、也最容易踩坑的地方,后面我会详细说。
2. 红外遥控的原理与红外码库设计
2.1 红外的“敲门暗号”:载波与协议
在写码库之前,得先把红外遥控的底层原理理清楚,不然你录回来的码是什么格式都分不清。红外遥控本质上是利用波长940nm左右的红外光来传输信号。但有个关键的物理特性:如果直接让红外LED亮灭来传信号,在室内环境光下接收端几乎无法区分这是遥控器还是太阳反射,所以所有红外遥控都会先把信号调制到38kHz(也有个别用36kHz或40kHz)的载波上,接收端只认这个频率的脉冲,其他光统统忽略。
打个比方,红外遥控就像两个人隔着很远用灯光交流,如果直接开灯关灯,环境里其他光源会干扰;如果约定一个固定的闪烁频率,比如一秒钟闪38000次,接收端只盯着这个闪烁频率看,就能从嘈杂环境里准确抓到信号。这个“闪烁频率”就是载波,而遥控器要表达的实际数据是靠载波脉冲的长短组合来编码的。
编码方式有很多种,NEC协议、SONY SIRC协议、Philips RC5协议、日系空调常用的NEC变种等等。好消息是市面上绝大多数家电用的都是NEC协议或者它的变种,所以我们把NEC吃透,就能覆盖80%以上的场景。
2.2 NEC协议信号结构
NEC协议的一个完整帧长这样:
- 引导码:9ms的高电平载波 + 4.5ms的低电平空载,相当于对接收端喊一声“注意,我要开始发数据了”
- 8位地址码(客户码)
- 8位地址反码
- 8位命令码(按键码)
- 8位命令反码
- 如果按键一直按着,会每隔110ms发送一个重复码(9ms高 + 2.25ms低)
编码的规则也很简单:逻辑“1”是560微秒高 + 1690微秒低,逻辑“0”是560微秒高 + 560微秒低。简单记就是“高电平都是560微秒,关键看后面的低电平多长”——长的是1,短的是0。
这个结构对码库设计极其重要。因为NEC协议是“先解码成16进制码再存储”还是“存原始脉冲时序”会直接决定你码库的通用性。我的做法是两种都存:已知协议标准的情况下存NEC码(比如0x00FF00FF这种格式),并标注协议类型为NEC;如果是录制的未知码,可能是不完整的NEC变种或者空调的长时序码,就存原始脉冲数组(数组里每个元素是微秒级的电平持续时间)。
2.3 码库数据结构设计
码库的JSON结构我设计成了三层:设备层、按键层、信号层。一个设备下面挂多个按键,每个按键对应一个信号描述。以下是我在Part 2里实际使用的结构:
{ "devices": [ { "name": "客厅电视", "brand": "Sony", "protocol": "SIRC", "remote_code": 1, "keys": { "power": { "code": "0x15", "type": "hex" }, "volume_up": { "code": "0x12", "type": "hex" }, "volume_down": { "code": "0x13", "type": "hex" } } }, { "name": "主卧空调", "brand": "Gree", "protocol": "custom", "remote_code": 0, "keys": { "power": { "type": "raw", "raw": [9000, 4500, 560, 560, 560, 1690, 560, 560, 560, 1690] } } } ] }type为hex时直接存协议参数,由设备端根据协议类型重新编码成红外波形;type为raw时存原始脉冲数组,设备端原样发送。这样的好处很明显:NEC设备占空间极小,一个按键就一行配置;空调这种复杂长码不需要费力去解析,直接录制原始波形,成功率反而更高。
3. 整体方案选型:为什么我坚持用Web App
3.1 技术栈对比
做iPad上的控制界面,有几种技术路线可以走:原生iOS App、微信小程序、Web App(PWA)。我最后选了Web App,理由很实在。
原生App最大的问题是门槛:需要Apple开发者账号(一年不便宜),需要用Mac做签名打包,而且写Swift/Objective-C的工程量对一次周末项目来说太重了。微信小程序更麻烦,iPad上的微信小程序的硬件接口限制多,局域网socket能力也受限,而且你总不能让用户为了控制个电视先打开微信。
Web App的好处在于:iPad自带Safari就是一等公民浏览器,HTML+CSS+JavaScript写完直接访问,不需要安装任何证书;配合PWA的manifest,用“添加到主屏幕”功能,图标和全屏窗口都像原生App;而且局域网内任何设备——手机、电脑——都能打开同一个控制面板。也就是说,同一套代码,iPad能用,你的手机也能用,这就是Web技术的天然优势。
3.2 设备端与iPad端的通信设计
通信这块我选用了最朴素的HTTP + JSON。设备端(Pico W)跑一个轻量TCP服务器,监听80端口,iPad用fetch请求就能发命令。为什么不用WebSocket?因为红外遥控是典型的事件型、低频率控制,不需要实时双通道,HTTP请求一次一来回,干净利落,也不容易断线重连出问题。
API设计就两个核心接口:
POST /api/send Body: {"device": "客厅电视", "key": "power"} 作用:发送指定设备指定按键的红外码 POST /api/learn Body: {"duration": 1000} 作用:让设备端进入红外学习模式,采样指定时长的红外信号,返回脉冲数组码库不直接存在设备端。设备端只保留一份最基本的NEC编码器和原始波形发送器,完整的码库JSON放在iPad本地(用localStorage持久化),iPad每次发命令时把要发的信号描述一起POST给设备端。这样设计的好处是设备端固件几乎不用改,所有遥控器逻辑都在iPad端,以后换一个接收设备,iPad端配置基本不变。
4. 设备端实操:从Wi-Fi到红外发射
4.1 硬件准备与驱动电路计算
Part 2的发射电路我沿用了Part 1的方案,但做了两个改进:增加了一个NPN三极管(S8050)做电流放大,并把红外LED从1颗增加到了2颗(并联)。直接拿GPIO驱动红外LED不是不行,但Pico W的GPIO只能输出大约3.3V、几mA电流,驱动距离通常不到2米,放客厅根本不够用。
加三极管的原理很简单:GPIO引脚只负责提供基极信号(小电流开关),真正的发射电流由3.3V或5V电源通过三极管提供。IR LED的正向压降Vf大概是1.2V到1.5V,如果供电电压是5V,限定工作电流IF取80mA到100mA,那么限流电阻阻值就是:
R = (VCC - Vf) / IF = (5 - 1.3) / 0.09 ≈ 41欧姆取标称值33欧姆,实际电流略高于100mA,在PWM发射(通常占空比低于50%)时完全能承受。注意不要用1欧姆或者直接不加限流电阻,不然LED会过流烧掉,我Part 1就烧过两颗。
Pico W的PWM输出频率设置为38000Hz,占空比我用50%。控制逻辑是:需要发载波时PWM输出38kHz方波,空闲时输出低电平。
4.2 Pico W服务器代码实现
设备端我用的是MicroPython。核心逻辑分三块:Wi-Fi连接、HTTP服务器、红外发射。
import network import socket import machine import json import time # 使用 PWM 输出 38kHz 载波 IR_PIN = machine.Pin(16) ir_pwm = machine.PWM(IR_PIN) ir_pwm.freq(38000) ir_pwm.duty_u16(0) # 默认不输出 WIFI_SSID = "your_wifi" WIFI_PASSWORD = "your_password" # 连接 Wi-Fi wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect(WIFI_SSID, WIFI_PASSWORD) while not wlan.isconnected(): time.sleep(0.5) print("Connected:", wlan.ifconfig())红外发送的核心是处理NEC编码。我写了一个send_nec函数,把十六进制码转成高低电平脉宽序列:
def send_nec(addr, cmd): # 引导码:9ms 高 + 4.5ms 低 pulse_ir(9000) time.sleep_us(4500) # 地址码 + 地址反码 data = ((addr & 0xFF) << 24) | (((~addr) & 0xFF) << 16) | \ ((cmd & 0xFF) << 8) | ((~cmd) & 0xFF) for i in range(31, -1, -1): bit = (data >> i) & 0x01 pulse_ir(560) if bit: time.sleep_us(1690) else: time.sleep_us(560) # 结束脉冲 pulse_ir(560) time.sleep_us(10000) def pulse_ir(us): ir_pwm.duty_u16(32768) # 50% 占空比,开始发载波 time.sleep_us(us) ir_pwm.duty_u16(0) # 停止载波原始波形发送也很简单,接收到的raw数组里偶数位是高电平时间、奇数位是低电平时间,轮流pulse和sleep即可。HTTP服务器用socket模块监听80端口,解析请求路径,简单分发:
def handle_request(client): request = client.recv(1024).decode() path = request.split(" ")[1] if len(request.split(" ")) > 1 else "/" if path == "/api/send": # 读取 body,解析 JSON body_start = request.find("\r\n\r\n") + 4 body = request[body_start:] data = json.loads(body) # 根据 type 发码 if data.get("type") == "hex": addr = data.get("addr", 0) cmd = data.get("cmd", 0) send_nec(addr, cmd) elif data.get("type") == "raw": send_raw(data.get("raw")) client.send(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n") client.send(json.dumps({"status": "ok"})) client.close()一个容易忽略的坑:MicroPython的time.sleep_us在长时间(比如引导码的9000微秒)下计时还算准,但如果你把整段码分成过小的片段频繁调用,协议栈和GPIO切换的开销可能导致帧间距过大,接收端解析失败。实测下来NEC这种短码影响不大,但空调的raw长码一旦超过几百个脉冲,最好把整个序列一次性放进数组,用一个循环连续发射,不要中途处理网络请求。
5. iPad端实操:遥控面板与学习功能
5.1 遥控器页面实现
iPad端我用纯HTML+CSS+JavaScript实现,没有引入任何框架。为什么不用Vue或者React?因为这个项目的复杂度没有到需要框架的程度,一个HTML文件加一个CSS文件加一个JS文件就够了,纯手写反而更容易理解每一行逻辑,也方便以后塞到iPad本地存储里离线使用。
界面布局参考了iOS系统自带的遥控器设计:底部有一排设备Tab(电视、空调、机顶盒、风扇),点击切换设备时,上方按键区域根据当前设备的keys字段动态渲染。每个按键都是一个大按钮,至少60像素高,因为要用手指直接按,不能按手机那种小按钮的习惯来设计。
核心的发送函数长这样:
async function sendKey(deviceName, keyName) { const device = config.devices.find(d => d.name === deviceName); const key = device.keys[keyName]; let payload = { device: deviceName, key: keyName, type: key.type }; if (key.type === 'hex') { payload.addr = key.addr; payload.cmd = key.cmd; } else if (key.type === 'raw') { payload.raw = key.raw; } const resp = await fetch('http://192.168.1.100/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const result = await resp.json(); if (result.status !== 'ok') { alert('发送失败'); } }按钮的按下反馈是给每个按键添加CSS的active伪类做背景变色,同时用navigator.vibrate触发轻震(iPad不支持震动,所以这段逻辑要捕获异常,不影响其他设备使用)。按键布局通过CSS Grid实现,电视是3x4网格,空调是2x5网格加一个温度显示区域,根据不同设备类型的keys自动适配。
5.2 红外学习功能实现
红外学习是Part 2重头戏,也是我在调试阶段流汗最多的地方。硬件上需要一个IR接收头(VS1838B)接Pico W的GPIO15,学习原理就是采样引脚上的电平变化,记录每个高电平或低电平的持续时间,形成raw数组。
MicroPython采样这块要特别小心。Python解释器不像C语言那样有硬实时保证,电平中断响应可能会抖动,所以采样循环必须用纯汇编级的中断和机器计时。我实际使用的方案是用machine.time_pulse_us()函数,它能精确测量引脚上单个脉冲的持续时间,虽然不能同时捕捉高和低,但配合逻辑判断可以完整记录:
def learn_raw(max_duration_ms=500): ir_pin = machine.Pin(15, machine.Pin.IN) raw = [] start = time.ticks_ms() last_value = ir_pin.value() while time.ticks_diff(time.ticks_ms(), start) < max_duration_ms: pulse_us = machine.time_pulse_us(ir_pin, last_value, 10000) if pulse_us < 0: break raw.append(pulse_us) last_value = 1 - last_value return raw这段代码会记录500毫秒内的所有电平脉冲长度,然后iPad端拿到raw数组后直接存到码库。录制空调码的时候,500毫秒可能不够(空调温度/模式的信号通常较长),我把duration参数暴露出来,录制界面可以选500ms、1000ms、2000ms三档。
学习功能最容易踩的坑是环境光干扰和学习过程误触。如果接收头附近有阳光直射或者其他红外遥控器在发码,录制回来的raw数组里面会混入大量垃圾信号。我的经验是学习的时候把被录的遥控器贴着接收头1到5厘米,按下按键后等0.5秒再松手,确保完整录到引导码和所有数据位。
5.3 场景与定时功能
码库稳定之后,我发现切换设备一个个按键操作还是不够顺手。比如晚上看电影的流程是:开电视、关灯、开音响、电视切HDMI1、投影仪展开幕布,五个动作要按五下。于是我在iPad端加了一个“场景”功能:预设一组动作序列,每个动作包含设备和按键,以及一个间隔延迟。
const scenes = { "电影模式": [ { device: "客厅电视", key: "power", delay: 300 }, { device: "客厅灯", key: "off", delay: 500 }, { device: "客厅音响", key: "power", delay: 400 }, { device: "客厅电视", key: "hdmi1", delay: 300 } ] }; async function runScene(name) { const actions = scenes[name]; for (const action of actions) { await sendKey(action.device, action.key); await sleep(action.delay); } }定时功能更简单,直接用setTimeout或者定时器组件,到点后执行场景。我做了一个“晚安模式”:每天23:30自动关电视、关灯、关空调。iPad需要保持浏览器页面打开,不然休眠后定时器会挂掉。这个限制我接受了,毕竟纯粹用iPad做定时中枢本来就不是它的强项,但能用一个简单的Web页面实现“伪定时”,已经比拿一堆遥控器强。
定时这块还衍生出一个实用功能:按时间区分信号。比如空调的“制热模式”在早上和晚上需要的温度不同,我可以定义“早上7点自动发送26℃制热”、“晚上睡前发送28℃制热”,本质上就是场景加时间条件。
6. 常见问题与排查技巧实录
6.1 iPad连不上设备怎么办
这是被问得最多的问题。症状是iPad浏览器打开控制页面,点按键无反应,fetch报错“NetworkError”。排查顺序基本是固定的:
先看iPad和设备是不是同一Wi-Fi网段。如果路由器开了“AP隔离”(很多双频路由器的访客网络默认开这个),无线设备之间是不允许互相访问的,这是最常见的原因。解决方法是关闭AP隔离,或者把设备端改接到网线且iPad连接同一个主SSID。
再看设备端的Wi-Fi连接是否还活着。Pico W这类开发板对Wi-Fi断线重连处理得很弱,路由器一重启或者信道变化,板子可能就默默离线了。日志打印是个好东西,每次请求都在串口打印一次IP和路径,iPad连不上时先看串口有没有收到报文。
最后别忘了iPad的“本地网络”权限。iOS 14之后,App访问局域网设备会弹权限请求,但Safari里的网页有时不会弹,你需要在系统设置 -> 隐私 -> 本地网络中检查Safari的开关。这个权限问题非常隐蔽,很多人改了半天代码,结果只是Safari没权限访问局域网。
6.2 信号发射距离短或没反应
发射距离短是最能直接暴露电路设计问题的症状。如果你站在设备旁边能控,稍微走远一步就不行,多半是红外LED驱动电流不够。检查三极管基极的限流电阻,我用的是1k欧姆,如果用了10k欧姆,基极电流太小,三极管无法完全导通,LED电流会远低于预期。
也有可能是载波频率不准。Pico W的PWM是基于系统时钟分频的,如果Wi-Fi功能开启导致处理器频率变化,38kHz可能会有几百赫兹的偏差,这个偏差对于大多数接收头在可容忍范围内,但如果你的接收头老化了,就会出现“看起来在闪但设备没反应”的情况。我习惯用手机摄像头对着遥控器按一下,肉眼看不到的红外光在手机CMOS传感器上会显示成紫白色,这是最快速验证LED是否在发射的方法。
6.3 录制信号“有码但没反应”
能录到码,回放时设备却不响应,这是学习模式最让人抓狂的问题。我自己碰到过三种情况。
第一种是录制的raw数组采样时间精度不够。MicroPython的time_pulse_us函数在高负载时会丢精度,导致录回来的脉冲长度整体偏小或偏大。解决办法是录完后对比已知NEC码的raw,看引导码的9000微秒是否准确,如果偏差超过10%,说明采样环境有问题,可以尝试降低CPU负载或者用C语言重写采样逻辑。
第二种是空调的高电平脉冲很宽,需要额外放大。很多空调的红外信号里,单个载波脉冲的宽度可能超过8毫秒,如果接收头或者采样代码对长脉冲处理不当,会在中途误判为结束信号,导致录回来的数据被截断。把录制时长拉长到2000ms,并且确保接收头供电稳定,能解决大部分问题。
第三种是回放时使用了错误的电平极性。接收头输出的电平定义高、低电平时间在录回来时可能是反的(因为接收头是反相输出),如果发送时按原样发,相当于把整个信号翻转了。最简单的方法是录制时加一个“是否反相”的开关,如果发现录制数据明显异常,把每个脉冲长度翻转一下再看效果。
6.4 开发工具和文件管理上的坑
最后说说使用iPad做开发时遇到的两个工具问题,都是踩过坑才知道的:
第一个是HBuilderX无法直接运行在iPad上。很多人在电脑上用HBuilderX写前端项目,顺手想复制到iPad里跑,结果发现iPad的iPadOS根本没有安装桌面级IDE的入口。这其实不是某个软件的问题,而是iPadOS不开放传统桌面软件的运行环境。替代思路有两个:一是用RunJS、Play.js这类的在线代码运行器,直接在iPad里写JavaScript和Python脚本;二是把开发好的HTML文件通过文件App或云盘同步到iPad,然后本地打开。但注意,如果你在iPad上打开一个本地HTML文件,这个页面的fetch请求可能会受到跨域限制,访问局域网设备需要额外处理CORS,所以更省事的做法是把页面放到设备端的Web服务器上,iPad浏览器直接访问局域网IP。
第二个是iPad编辑Windows上的文件很麻烦。你电脑上挂着一个码库JSON,想在iPad上改两行代码,发现iPad文件App默认看不到Windows的本地目录。我的办法是把码库文件放到一个局域网HTTP服务器上(就是Pico W设备端的/static路径),Windows端用任意编辑器改完上传,iPad端通过一个“刷新配置”按钮拉取新码库。另外,如果是局域网共享文件夹方案,用支持SMB协议的文件管理App(比如文件App自带的“连接服务器”功能)也能访问Windows共享目录,但格式要求是SMB://IP/共享名,别用中文路径。
6.5 关于iPad已停用/恢复的提醒
最后想特别说一句:网上有很多“iPad已停用连接iTunes解锁教程”,我不建议大家照着类似教程乱操作。iPad如果因为多次输错密码进入停用状态,正规流程就一个——用之前同步过的电脑做恢复,或者直接找官方支持。不要去试那些绕过激活锁的所谓“解锁服务”,既容易把数据整没,也可能违反使用条款。
与其等到出问题再找教程,不如提前做好备份。iPad在设置里开启iCloud云备份,平时重要的照片、文件都会自动同步;如果还想多一层保障,超过一年以上的数据定期用电脑做加密备份。这个习惯比任何教程都更有用。我在这篇文章里写的所有内容,都是日常使用的软件和硬件方案,不包含任何旁门左道。
7. 写在最后的实操心得
整个Part 2从码库重构到学习功能调通,断断续续花了我两个周末。回头去看,最有价值的部分其实不是某个具体代码,而是确定了“码库驱动一切”的架构思路。现在的系统里,新买一台设备只需要对着原装遥控器录一遍码,存进JSON,iPad端的控制面板就能自动多出一个设备。家里来客人问这iPad上的遥控面板怎么弄出来的,我说原理就一句话:红外光脉冲的长短组合,加上一台能按Wi-Fi指令发光的小板子。
最后再分享一个小技巧:发送红外码之后,最好等50毫秒再发下一条。很多设备在接收完一帧数据后需要时间处理,连续两条命令间隔太短会导致第二条被忽略。这个时间在NEC协议的重试帧间隔(110ms)基础上我取了个经验值——所有场景动作之间延迟300毫秒起步,实测下来稳定,很少丢命令。
如果后续有空,我打算把码库做成一个可导入导出的文件格式,再接入Home Assistant,让iPad控制面板和智能家居自动化联动起来。这个项目目前的状态我已经很满意,至少家里的茶几上,再也看不到那一堆乱七八糟的遥控器了。