从TV-B-Gone到红外信号解析:逻辑分析仪与Python实战
1. 项目概述从零构建一个“电视终结者”几年前我在一个极客聚会上第一次见到Mitch Altman的TV-B-Gone那个能一键关闭公共场合电视机的小玩意儿让我印象深刻。它背后的想法很简单但实现起来却涉及信号处理、数据压缩和嵌入式编程的多个层面。如今有了像Circuit Playground ExpressCPX这样内置红外发射器的开发板我们完全可以自己动手复刻甚至改进这个经典项目。这个项目的核心目标是打造一个通用的红外遥控信号发射器它能存储并发射数百种不同品牌电视机的“关机”编码。这听起来像是个简单的“播放器”但难点在于如何将长达一分钟、包含数十万数据点的原始红外信号塞进一个只有32KB RAM的微型控制器里并让它准确地重放出来。整个过程就像一次微型的逆向工程实战你需要用逻辑分析仪充当“耳朵”去窃听原始信号用Python充当“大脑”去理解和压缩这些信号最后用CircuitPython让一块小小的开发板“开口说话”。无论你是想深入理解红外通信协议还是学习如何在资源受限环境下进行数据处理这个项目都是一次绝佳的练手机会。2. 硬件与信号捕获逻辑分析仪如何成为我们的“眼睛”2.1 为什么不用现成的红外接收头拿到这个项目很多人的第一反应可能是用个红外接收头比如VS1838B不就行了它专门解调38KHz信号电路简单库也成熟。这个想法很自然但在这里却行不通。TV-B-Gone这类设备为了兼容尽可能多的老式电视其发射的红外载波频率并非固定的38KHz而是从34KHz到56KHz甚至更高都有。红外接收头内部有一个带通滤波器通常只对38KHz附近最敏感。用它来接收56KHz的信号轻则信号衰减严重重则完全无法解调你会得到一堆乱码或者根本收不到信号。更关键的是接收头输出的是已经解调后的数字电平即去掉了载波只留下数据包络我们因此会丢失原始的载波频率信息而这是准确复现信号所必需的。注意在逆向任何未知红外协议时如果条件允许应优先考虑直接捕获发射端的原始波形而不是依赖接收头的解调输出。这能确保你获得最完整、最真实的信号特征。2.2 逻辑分析仪直击信号源头因此我们必须绕过接收头直接“监听”TV-B-Gone设备上驱动红外LED的那个GPIO引脚。这就是逻辑分析仪大显身手的时候。我手头用的是一台老款的Saleae Logic但完全可以用更便宜的开源方案替代比如基于FX2LP芯片的简易逻辑分析仪配合sigrok/pulseview软件成本可以控制在百元以内对于这个项目最高不到60KHz的信号速率绰绰有余。连接方式极其简单逻辑分析仪的一个通道CH0接TV-B-Gone的IR驱动引脚地线GND共地。设置采样率时根据奈奎斯特定律至少需要两倍于信号频率。我们的目标载波最高约57KHz考虑到谐波和边沿精度我将采样率设置为12MHz这提供了足够高的时间分辨率。一次完整的TV-B-Gone发射序列会持续超过一分钟因此需要确保逻辑分析仪有足够的内存深度来捕获这海量的数据点。捕获完成后你会得到一张类似下图所示的波形图。那些密集的“柱状”区域就是红外LED在以特定频率例如57KHz闪烁发光专业术语叫“调制脉冲串”。每个“柱子”内部其实是数十个周期的高频方波。如果你放大其中一个“柱子”就能清晰地看到这些方波而两个“柱子”之间的低电平区域则是信号间隙。2.3 从波形到数据导出原始过渡点捕获不是终点导出可供分析的数据才是关键。在Saleae Logic软件中我使用“导出数据”功能选择“过渡时间戳”格式。这个格式非常有用它不会记录每一个采样点的状态而只记录信号从高到低或从低到高变化的那个时刻点及其状态。对于像红外遥控这样大部分时间处于稳定状态的信号这能极大地压缩数据量。导出的CSV文件通常有两列Sample时间戳单位可能是秒、毫秒或采样点编号和Channel 0通道0的逻辑电平0或1。拿到这个raw_tvbgone.csv文件我们的硬件侦察阶段就告一段落了。接下来就要靠Python代码来解读这本用时间和电平写成的“天书”。3. 数据解析实战用Jupyter Notebook拆解红外密码3.1 搭建分析环境与读取数据面对一个包含60多万个过渡点的CSV文件用Excel手动分析简直是噩梦。我选择Jupyter Notebook因为它允许我将分析过程拆分成一个个可独立运行、随意修改的“代码块”交互性极强。第一块代码的任务很简单把数据读进来。import csv dataset [] with open(raw_tvbgone.csv, r) as f: reader csv.reader(f) next(reader) # 跳过标题行例如“Sample, Channel 0” next(reader) # 跳过可能存在的第一个无效数据点如预触发点 for row in reader: # 将字符串转换为数值时间戳和电平 sample_num int(row[0]) # 假设第一列是采样点编号 logic_level int(row[1]) # 第二列是逻辑电平 dataset.append([sample_num, logic_level]) print(f总共读取了 {len(dataset)} 个数据点)这里有个细节需要注意逻辑分析仪导出的时间戳单位。有时是微秒有时是采样点序号。我的文件里是采样点序号采样率是12MHz所以每个采样点间隔是1/12,000,000 ≈ 0.0833微秒。确认这一点对后续计算真实时间至关重要。3.2 核心解析从过渡点到频率脉冲对这是整个解析过程最核心的一步。我们需要遍历这些交替出现的高电平时间戳 低电平时间戳对计算出每一对高、低电平持续的“时间”进而得到这个脉冲串的载波频率。SAMPLERATE 12000000 # 12 MHz frequency_pairs [] # 存储最终结果[频率(Hz), 高电平持续时间(s), 低电平持续时间(s)] pulse_points [] # 临时存储一个“柱子”内所有脉冲的频率 unusual_codes [] # 存放非标准如曼彻斯特编码的脉冲以备后查 for p in range(0, len(dataset)-2, 2): # 每次步进2处理一对高低电平 hi_p dataset[p] # 高电平起始点 [时间戳, 1] lo_p dataset[p1] # 低电平起始点 [时间戳, 0] hi2_p dataset[p2] # 下一个高电平起始点用于计算低电平时长 # 1. 计算高电平和低电平持续的采样点数 delta_high_samples lo_p[0] - hi_p[0] delta_low_samples hi2_p[0] - lo_p[0] # 2. 计算周期和频率 period_seconds (delta_high_samples delta_low_samples) / SAMPLERATE freq_hz 1 / period_seconds # 3. 关键逻辑判断一个“柱子”是否结束 if delta_low_samples 30 * delta_high_samples: # 低电平远长于高电平标志一个脉冲串结束 if not pulse_points: # 如果pulse_points为空说明这是一个孤立的异常脉冲 unusual_codes.append([p, delta_high_samples, delta_low_samples]) continue # 计算这个脉冲串的平均频率 avg_freq sum(pulse_points) / len(pulse_points) # 检查脉冲串内所有脉冲频率是否一致允许10%误差 if all(0.9 * avg_freq f 1.1 * avg_freq for f in pulse_points): # 计算总发射时间(脉冲个数1) * 单个脉冲周期 total_on_time (1/avg_freq) * (len(pulse_points) 1) # 记录[平均频率 总发射时间 结束后的长低电平时间] frequency_pairs.append([avg_freq, total_on_time, delta_low_samples / SAMPLERATE]) pulse_points [] # 清空准备下一个脉冲串 else: # 频率不一致视为异常编码 unusual_codes.append(pulse_points.copy()) pulse_points [] else: # 脉冲串未结束将当前脉冲频率加入临时列表 pulse_points.append(freq_hz)这段代码的精华在于if delta_low_samples 30 * delta_high_samples:这个判断条件。数字“30”是我通过观察多个信号后确定的经验阈值。它用于区分一个“柱子”内部的密集脉冲低电平时间短和两个“柱子”之间的长间隔低电平时间长。这个阈值的选取需要根据实际信号调整如果设得太小可能会把一个长的脉冲串错误地切分成多个设得太大则可能把两个独立的编码误认为一个。3.3 数据聚合将脉冲对组合成完整指令上一步我们得到了frequency_pairs它把原始的方波转换成了更紧凑的格式[频率 发射时长 间隔时长]。但一个完整的“关机”指令通常由多个这样的脉冲串按特定顺序组成。接下来我们需要把这些脉冲串组合起来。观察数据会发现同一个指令通常会连续发送两遍中间有一个约65ms的短间隔而不同指令之间则有约250ms的长间隔。我们可以利用这个时间特征进行分组。all_codes [] # 存储所有完整的指令 current_code [] INTER_COMMAND_GAP 0.25 # 单位秒。假设超过250ms的间隔意味着新指令开始 for freq, on_time, off_time in frequency_pairs: current_code.append([freq, on_time, off_time]) if off_time INTER_COMMAND_GAP: # 长间隔出现意味着一个指令结束 if current_code: # 检查当前指令内所有脉冲串的频率是否一致 freqs_in_code [p[0] for p in current_code] avg_freq sum(freqs_in_code) / len(freqs_in_code) if all(0.9 * avg_freq f 1.1 * avg_freq for f in freqs_in_code): # 频率一致存储。只保留“开/关”时间对频率单独存。 pulses [[p[1], p[2]] for p in current_code] # 只取on_time和off_time all_codes.append({freq: avg_freq, pulses: pulses}) current_code [] # 重置准备下一个指令运行完这一步print(len(all_codes))可能会输出207这意味着我们从这次捕获中解析出了207种不同的电视关机指令。每个指令都用一个字典表示包含了载波频率和一系列[开时间 关时间]对。4. 数据压缩艺术如何在微控制器上存下200多个指令4.1 第一轮压缩浮点数转整数与重复指令消除原始的all_codes数据量很大因为Python默认使用双精度浮点数8字节存储时间一个指令就有几十对时间值。首先我们将时间单位从秒转换为微秒乘以1,000,000并用整数存储。这样不仅节省空间后续微控制器处理整数也更快。compressed_codes [] for code in all_codes: new_code {freq: int(code[freq])} # 频率取整 pulse_us [] for on_s, off_s in code[pulses]: pulse_us.append(int(on_s * 1_000_000)) # 开时间转微秒 pulse_us.append(int(off_s * 1_000_000)) # 关时间转微秒 # 最后一个时间通常是长间隔单独存为‘delay’ final_delay_s pulse_us.pop() / 1_000_000.0 new_code[delay] round(final_delay_s, 3) # 保留3位小数 new_code[pulses] pulse_us # 检查指令是否重复常见模式 half_len len(pulse_us) // 2 first_half pulse_us[:half_len] repeat_gap pulse_us[half_len] # 假设重复间隔在中间 second_half pulse_us[half_len1:] if is_similar(first_half, second_half): # 自定义函数检查两个列表是否近似相等 new_code[repeat] 2 new_code[repeat_delay] repeat_gap / 1_000_000.0 new_code[pulses] first_half # 只存一半 compressed_codes.append(new_code)这一步效果显著数据量从几百KB降到了约80KB。但这对只有192KB Flash的Gemma M0板子来说还是太大了。4.2 第二轮压缩编码表与索引化观察一个具体的指令脉冲序列例如[3968, 3993, 493, 2003, 493, 2003, 493, 1009,...]。你会发现数字种类很少主要是49310092003这几个值它们代表了红外编码中的“位”0或1的不同时间组合。我们可以建立一个“脉冲对查找表”。def compress_with_table(compressed_codes): final_codes [] for code in compressed_codes: pulse_pairs [] lookup_indices [] # 1. 建立脉冲对查找表 pair_table [] pulses code[pulses] for i in range(0, len(pulses), 2): if i1 len(pulses): pair (pulses[i], pulses[i1]) else: # 处理奇数个的情况最后一个单独的脉冲 pair (pulses[i], 0) # 补0或查找匹配项 if pair not in pair_table: pair_table.append(pair) # 记录当前脉冲对在表中的索引 lookup_indices.append(pair_table.index(pair)) # 2. 构建最终编码对象 final_code { f: code[freq], d: round(code[delay], 2), # 延迟只保留2位小数 t: pair_table, # table i: lookup_indices # index } if repeat in code: final_code[r] code[repeat] final_code[rd] round(code[repeat_delay], 3) final_codes.append(final_code) return final_codes经过这步操作一个指令的存储方式从一长串数字变成了一个短小的查找表t和一个索引列表i。数据量进一步锐减到45KB左右已经可以塞进CPX的存储空间了。最后我们移除所有空格并将最终列表写入codes.txt文件。实操心得压缩的权衡。这种“表索引”的压缩方式在脉冲模式重复度高时效果极好。但如果遇到脉冲时间值非常离散的协议如某些空调协议压缩率就会下降。在实际项目中需要根据目标协议的特性调整压缩策略有时甚至需要混合多种方法。5. CircuitPython 实现动态加载与信号发射5.1eval()的魔法在内存受限环境下读取复杂数据45KB的codes.txt文件对于ATSAMD21芯片32KB的RAM来说依然无法一次性全部加载。传统的C语言思路是预编译成二进制数组存到Flash。而CircuitPython作为解释型语言有一个“秘密武器”eval()函数。eval()可以执行一个字符串形式的Python表达式。我们的codes.txt每一行都是一个有效的Python字典字符串例如{f:56697,d:0.21,t:[[3968,3993],[493,2003],[493,1009]],i:[0,1,1,2,...],r:2,rd:0.008}。我们不需要自己写一个复杂的JSON或字典解析器只需要import board import pulseio import array import time # 打开数据文件 with open(/codes.txt, r) as f: for line in f: # 关键一步用eval将字符串“变回”字典对象 code eval(line.strip()) # 现在code就是一个标准的Python字典可以直接使用 frequency code[f] pulse_table code[t] index_list code[i] # ... 后续处理eval()在这里充当了一个极其轻量级的内置解析器。我们一次只从文件读取一行一个指令到内存解析成一个字典对象使用完即被垃圾回收内存占用始终很小。这巧妙地绕过了内存限制。警告在生产环境或处理不可信数据源时绝对不要使用eval()。因为它会执行字符串内的任何有效Python代码存在严重的安全风险。本例中codes.txt是我们自己生成的受控文件所以可以安全使用。5.2 硬件驱动与主循环设计硬件连接很简单因为CPX板载了红外发射LED连接到board.REMOTEOUT引脚。我们还需要利用板载的按键和开关进行交互。import digitalio import board # 硬件初始化 led digitalio.DigitalInOut(board.D13) # 红色LED用于非静默模式指示 led.direction digitalio.Direction.OUTPUT speaker_enable digitalio.DigitalInOut(board.SPEAKER_ENABLE) speaker_enable.direction digitalio.Direction.OUTPUT speaker_enable.value True # 启用扬声器 speaker digitalio.DigitalInOut(board.SPEAKER) speaker.direction digitalio.Direction.OUTPUT # 模式选择开关 mode_switch digitalio.DigitalInOut(board.SLIDE_SWITCH) mode_switch.direction digitalio.Direction.INPUT mode_switch.pull digitalio.Pull.UP # 默认上拉开关断开时为True # 触发按钮两个都可用 button_a digitalio.DigitalInOut(board.BUTTON_A) button_a.direction digitalio.Direction.INPUT button_a.pull digitalio.Pull.DOWN button_b digitalio.DigitalInOut(board.BUTTON_B) button_b.direction digitalio.Direction.INPUT button_b.pull digitalio.Pull.DOWN # 主循环 while True: # 等待任意按键按下 while not (button_a.value or button_b.value): time.sleep(0.01) # 短暂休眠以降低CPU占用 time.sleep(0.5) # 按下后等待半秒防止误触发 # 遍历所有编码并发射 with open(/codes.txt, r) as file: for line in file: code_dict eval(line.strip()) # 根据开关选择反馈方式 if mode_switch.value: # 开关拨到一侧通常标记为“ON”或“LED” led.value True else: # 静默模式 speaker.value True # 发出轻微“滴”声 # 重组脉冲序列 pulse_array [] table code_dict[t] for idx in code_dict[i]: pulse_array.extend(table[idx]) # 根据索引展开脉冲对 # 移除最后一个低电平脉冲由delay参数控制 if pulse_array: pulse_array.pop() # 使用PulseOut硬件外设发射 with pulseio.PulseOut(board.REMOTEOUT, frequencycode_dict[f], duty_cycle32768) as transmitter: # 50%占空比 repeat code_dict.get(r, 1) # 获取重复次数默认为1 for _ in range(repeat): transmitter.send(array.array(H, pulse_array)) # H表示无符号短整型 time.sleep(code_dict.get(rd, 0)) # 重复间隔 # 关闭反馈 led.value False speaker.value False # 等待本指令发射完成后的长延迟 time.sleep(code_dict[d])这段代码清晰地展示了整个工作流程等待触发 - 读取一个编码 - 根据索引和表重组脉冲序列 - 通过硬件PWMPulseOut以指定频率发射红外信号 - 等待指令间隔 - 继续下一个编码。PulseOut是CircuitPython中用于生成精确脉冲序列的硬件级功能比软件模拟要准确和高效得多。6. 调试技巧与常见问题排查6.1 信号捕获阶段的典型问题问题1逻辑分析仪捕获到的波形杂乱无章没有清晰的脉冲串。检查连接确保探头地线与TV-B-Gone板共地。高频信号下接地不良是噪声的主要来源。检查触发将触发模式设置为上升沿或下降沿触发触发电平设为1.6V左右对于3.3V系统确保能稳定捕获到信号起始点。检查采样率与深度确保采样率如12MHz远高于信号频率最高57KHz。同时内存深度要足够容纳超过1分钟的数据12MHz采样率下1分钟需要720M个点但“过渡时间戳”导出方式会极大减少数据量实际存储的是变化点可能只有几十万个点。问题2解析出的频率值偏差很大例如预期38KHz解析出20KHz。核对采样率这是最常见的原因。确认你在解析代码中使用的SAMPLERATE变量值与逻辑分析仪实际设置的采样率完全一致。一个数字错误会导致所有时间计算错误。检查时间戳单位确认CSV文件中的“时间”列是秒、毫秒、微秒还是采样点序号。根据单位调整计算周期的公式。6.2 数据解析与压缩阶段的逻辑错误问题3frequency_pairs列表为空或数量远少于预期。检查阈值if delta_low_samples 30 * delta_high_samples:这里的30是经验值。如果实际信号中“柱子”间的低电平间隔不够长这个条件可能永远不会触发。尝试在循环内打印delta_low_samples和delta_high_samples的比值观察其分布然后调整这个乘数因子比如尝试20或40。检查数据连续性确认导出的过渡点数据是严格的0101交替。如果出现连续两个1或两个0说明过渡点捕获可能有问题或者信号本身有毛刺。可以在解析前加入简单的数据清洗逻辑过滤掉极短时间的毛刺例如小于2个采样点宽度的脉冲。问题4压缩后的指令发射出去电视毫无反应。核对载波频率用逻辑分析仪或示波器测量一下CPX实际发射的红外信号频率看是否与code[f]一致。PulseOut的frequency参数是载波频率必须准确。检查脉冲时序将CPX发射的脉冲序列特别是第一个指令用逻辑分析仪捕获下来与原始的TV-B-Gone信号进行对比。重点看起始的“引导码”通常是一个长的高电平脉冲的时长是否正确。代表“0”和“1”的脉冲对如[493, 2003]和[493, 1009]的时间比例是否正确。整个指令的总体时长是否一致。验证发射电路确保红外LED方向正确发射面对准电视并尝试在LED上串联一个100欧姆的电阻直接连接到3.3V手动快速通断看电视是否有反应有些电视对信号强度很敏感。CPX板载的IR LED驱动能力可能较弱对于远距离或灵敏度低的电视可能需要外接一个三极管来驱动更大功率的IR LED。6.3 CircuitPython 运行时的故障问题5程序运行一段时间后卡死或无响应。内存泄漏检查虽然CircuitPython有垃圾回收但在循环中频繁创建大型对象如列表可能引发问题。确保在主循环中每个指令的pulse_array列表是新建的并且在PulseOut的with块结束后其作用域结束可以被回收。文件读取异常确保codes.txt文件完整地存放在CPX的根目录下。可以在代码开始时加入简单的文件检查import os; print(os.listdir(/))。电源问题使用电池供电时在发射信号的瞬间由于IR LED全功率工作可能导致电压瞬间跌落使单片机复位。建议在电池端并联一个较大容量的电容如100µF进行缓冲。问题6eval()报错SyntaxError或MemoryError。检查codes.txt格式eval()要求字符串是严格的Python字面量。确保文件每行都是一个完整的字典没有多余的逗号、括号不匹配或格式错误。可以使用Python的ast.literal_eval()进行更安全的测试虽然CircuitPython可能不支持该模块但可以在电脑上测试。分块加载如果单个指令的字典字符串太长导致内存错误可以考虑在压缩阶段进一步拆分。例如不把整个指令的索引列表i放在一行而是分成多行存储在读取时再拼接。这个项目从硬件信号捕获到软件解析压缩再到最终的嵌入式实现贯穿了嵌入式开发中数据采集、处理、优化和部署的全流程。它教会你的不仅仅是如何控制一个红外LED更是一种解决复杂问题、在有限资源内寻求最优方案的工程思维。当你拿着自己制作的CPX成功让一台陌生的电视屏幕熄灭时那种成就感远非购买一个成品可比。