我房间的灯是搬进来就有的,墙上配的是一颗没有电线的无线开关。谁装的、哪里买的、什么牌子,我一概不知道。

家里的冷气、空气清净机陆续接进 Home Assistant 之后,我很自然地想:灯应该也行吧?手上刚好有一台为了冷气买的 Broadlink RM4 Pro,规格上写着支援 433MHz 射频学习,我以为录一下就好。

结果前前后后录了两天,墙上那颗开关被我按了几百次,按到我都怕它先坏掉。RM4 Pro 从头到尾没录到过一次,最后是靠一块 NT$220 的 ESP32-C3 加 CC1101 才搞定。中间还踩了一个很阴险的坑:我录到一组统计上看起来完美的码,烧进去之后灯完全不理我。

这篇把整条路走一遍:这种开关到底是什么、为什么 RM4 Pro 录不到、CC1101 怎么接怎么设定,以及录波时最容易骗到自己的地方。如果你家也是这种开关,后半段的设定可以直接抄,码换成你自己录的就好。

先搞懂这颗开关#

拆下来一看,开关背面什么都没有,没有火线,也没有电池。它是一颗,按下去的那一下会发电,刚好够它送出一组无线讯号。按的时候会「喀、喀」两声,按下一次、弹起一次。

真正切断电源的是吸顶灯旁边一颗后装的小盒子,叫。上面只有简体字的「进线/出线」端子、一颗钥匙形状的按钮和一颗蓝色 LED,一样没有任何品牌或型号。

换智慧灯泡这条路一开始就是死的,灯具是舞光的 24W 吸顶灯,光源是 24 颗焊死的 SMD LED,没有灯泡可以换。所以要让 HA 控制它,只剩一个办法:假装自己是那颗开关,发一模一样的讯号给通断器。

问题是我连它用什么频率、什么格式都不知道。

RM4 Pro:冷气很顺,灯完全不行#

冷气那边确实很顺。录一组红外线码,拿去比对 SmartIR 的码库,命中一组 Hisense 的码,171 个 bit 有 93% 相同,冷气很快就进了 HA。所以我对灯的期待很高。

然后花了一整个通宵,得到的结论是 RM4 Pro 收不到这颗开关。这中间错得最离谱的几次值得记一下,因为每一次当下看起来都像进展。

868 MHz 的「学到了」#

RM4 Pro 的射频学习第一步要长按遥控器让它扫频,但自发电开关按一下只发几毫秒,根本没办法长按。翻 python-broadlink 的原始码发现 find_rf_packet() 可以直接指定频率跳过扫频,我就写了支小工具,在 433.92、315、868 MHz 轮流监听。

第一次跑就显示在 868.35 MHz 学到 114 bytes。我高兴了大概十秒,然后发现封包类型是 0x00(合法的只有红外线 0x26、433 的 0xb2、315 的 0xd7),脉冲宽度有 15 种、从 131µs 跨到 49260µs。RM4 Pro 根本不支援 868MHz,你指定一个它不支援的频率,它不会报错,会直接把杂讯吐给你。

装置自己回报的频率也不能信#

改用 RM4 Pro 自己扫频,它很有自信地回报 433.460 MHz。我信了,把工具全改成用 433.46 重录重发,又跑了好几轮。

后来想找个对照组,拿家里 T100 音响的遥控器去扫,它也很有自信地回报了 305.000 MHz。那支遥控器是红外线的。

把扫频的原始回应 dump 出来才看懂:

text
check_frequency 原始回应:00 68 a7 04 00
                          ↑  └─────────┘
                          │   0x0004a768 = 305000 kHz
                          └─ is_found = 0(其实没找到)

第一个位元组是 0,代表根本没找到,305 MHz 只是扫频器闲置时停的位置。前面那个 433.46 很可能也是同一回事,我拿一个假数字推论了好几轮。

通断器长按十秒会删光配对#

既然录不到,我换个方向:让通断器进入学习模式,改成让它来学 RM4 Pro 发的新码。但没有型号就没有说明书,只能猜。

按一下钥匙形按钮,灯关了,原来那只是手动开关。改成长按,我想等 LED 开始闪,结果它闪了一下、两下、三下、四下,然后墙上的开关就失效了。后来找到一张同类通断器的说明书才知道发生什么事:

WARNING

这类没品牌的通断器,常见的操作是:长按 3 秒闪 1 次进学习、长按 10 秒闪 4 次删除全部配对。我当时还很小心地避开网路上说的「连按 8 下会清除」,结果是被长按删光的。

删光了也不用慌,长按 3 秒等它闪一下,放手,再按一下墙上的开关,配对就回来了。

拆开开关#

猜不到就拆。

板子上有四颗整流二极体、一颗升压电感、一颗储能用的钽质电容,底下那圈铜线圈就是发电机,左边蛇行的铜箔是 PCB 天线。最有用的是那颗晶振:

text
Y1 = 26.2982 MHz
26.2982 × 16.5 = 433.9203 MHz

频率是 433.92,从硬体上确认了,433.46 可以丢掉了。可惜旁边那颗主晶片没有印字,从晶振值推,大概是华普微 CMT21xx 那一族。这类晶片的是模组厂烧进 EEPROM 的,0.5 到 40 kbps 都有可能,datasheet 不会告诉你这颗设成多少。

我的工具本身也是坏的#

到后半夜,频率、脉冲宽度、封包类型、极性能扫的都扫了,全部失败。回头检查才发现,有好几轮根本是我自己的工具在扯后腿:

  • pulses_to_data 预设的时间单位是 32.84µs,但官方文件写的是 2⁻¹⁵ 秒,也就是 30.52µs,差了 7.7%。
  • 换算时用无条件舍去,25µs 会被舍成 0,而 0x00 在 Broadlink 的格式里是跳脱字元,整包资料从那里开始错位。
  • 射频封包其实要多 4 个位元组。我在 python-broadlink 的 #778 找到别人贴出来能用的封包,开头标着 b1c0 代表 RF 433MHz,后面除了长度还多了 00 9f 06 00,用小端序换算刚好是 433920,也就是 433.92MHz。我一整晚发的都少了这段。
  • 最后补自我测试时才抓到,我在改写另一个指令时,把最接近正确答案的那个发射指令整段删掉了,它从来没有用正确的封包测过。

全部修好、17 项测试都过了之后再试一次,灯还是没动。

为什么收不到#

最后把事情解释清楚的是两个 issue。python-broadlink 的 #739 里有人说得很直接:Broadlink 能理解的「射频」只是 433 和 315 MHz 上的某些特定协定,其他协定你可能要用 Flipper Zero 之类的东西。也就是说,它的红外线学习是真的在录脉冲,射频学习却是韧体里的协定解调器,不认得的格式就收不到。

rtl_433 的 #1790「Help decoding Kinetic light switch」开头就是我的症状:发问者试过 Sonoff RF bridge 和 Broadlink RM,两个都收不到,改用 RTL-SDR 才看到讯号。

原厂 App 我也试过,一开始偶尔录到一次,录到的码按了灯也不动,后来试很久干脆录不到。

我自己的工具、原厂 App、别人的经验都是零,到这里我才甘愿承认 RM4 Pro 这条路走不通。老实说最浪费时间的不是技术本身,是那几轮我拿着假数字一直往下推,没先停下来确认数字是不是真的。

433MHz 只是频段#

我一开始最想不通的是:433MHz 不是很常见吗?铁卷门、车钥匙、无线门铃都是,一台号称支援 433 的设备怎么会录不到?

后来才理解「433MHz」只说明了最外面那一层:

这颗开关RM4 Pro
载波频率433.92 MHz✅
调变✅
编码格式与时序最短的脉冲约 37µs❌

就像 WiFi 和蓝牙「都是 2.4GHz」一样,频率对了只代表两边在同一个频道上,对方讲什么格式、讲多快是另一回事。RM4 Pro 收的那端只认它韧体里内建的协定,这颗开关不在里面。发的那端理论上勉强摸得到,但我用格式验证过的封包试到最后都没成功,手上也没有仪器能确认它到底有没有发出去。

要知道真相,得有个能直接看到原始波形的东西。

选硬体#

我考虑过的选项(价格是 2026 年 10 月查的):

方案价格我为什么没选
Shelly 1PM Mini 串在通断器上游 ×2台湾少见,官网一颗约 €15要爬天花板接两次市电,我没碰过市电接线
SwitchBot 开关机器人 ×2两入约 NT$2,370要黏墙、要换电池
LILYGO T-Embed CC1101NT$3,400 到 4,200贵,萤幕、NFC、红外线对我都用不到
Flipper Zero台湾没有正式通路,官网 US$169能抓能发,但不能常驻进 HA
RTL-SDR Blog V4NT$3,000 到 3,700只能收,不能发
虾皮上的 USB CC1101 棒NT$291晶片其实是阉割版的 CC110L

TIP

想买 RTL-SDR 的话,虾皮上标「V4」却只卖一千出头、写着 R860 的,很可能是山寨品,正品 V4 用的调谐器是 R828D。

最后选了最便宜的:裸的 模组接一块 ESP32-C3 SuperMini,用 写韧体。它唯一的缺点是丑,两块裸板加八条杜邦线。

text
CC1101 433MHz 模组(附 SMA 天线)   $110
ESP32-C3 SuperMini                 $90
母对母杜邦线                        $20
──────────────────────────────────────
                                   $220,免运

选 C3 是因为它有 WiFi,而这个用途只要 6 支脚,S3 太大材小用。丑的问题是同事解决的,他有 3D 印表机,零件到了之后帮我焊好,壳也是他印的。

壳印了五个版本。网路上找得到 ESP32-S3 SuperMini 加 CC1101 的壳,也有一般 ESP32 开发板的,偏偏就是没有 C3 SuperMini 加 CC1101 这个组合,只能自己量、自己改,一版一版印到装得进去为止。

接线与设定#

照 ESPHome 官方文件的双针接法,SPI 走板子丝印本来就标好的脚,两支资料脚挑没有开机职责的 GPIO:

CC1101ESP32-C3 SuperMini用途
VCC3V3⚠️ 不能接 5V
GNDGND
SCKGPIO4SPI
MISOGPIO5SPI
MOSIGPIO6SPI
CSNGPIO7SPI
GDO0GPIO10发射
GDO2GPIO3接收

C3 SuperMini 要避开 GPIO2/8/9(开机用的 strapping 脚)和 GPIO20/21(UART)。接对的话开机 log 会出现 CC1101 found! Chip ID: 0x0004。

设定档的核心是这几段(简化过,完整版在下面):

yaml
logger:
  hardware_uart: USB_SERIAL_JTAG   # C3 预设走 UART0,不加的话 USB 读不到 log

wifi:
  power_save_mode: NONE            # 不关省电的话区网 ping 会飙到 147ms

spi:
  clk_pin: GPIO4
  miso_pin: GPIO5
  mosi_pin: GPIO6

cc1101:
  id: transceiver
  cs_pin: GPIO7
  frequency: 433.92MHz
  modulation_type: ASK/OOK
  filter_bandwidth: 200kHz
  symbol_rate: 40000               # 这个值是整篇最重要的一行,后面会讲

remote_receiver:
  pin: GPIO3
  dump: raw                        # 录波时打开,录完可以关掉
  filter: 20us
  idle: 25ms
  tolerance: 45%

remote_transmitter:
  pin: GPIO10
  carrier_duty_percent: 100%
  on_transmit:
    - cc1101.begin_tx: transceiver
  on_complete:
    - cc1101.begin_rx: transceiver

switch:
  - platform: template
    name: 房间灯
    optimistic: true
    restore_mode: DISABLED         # 这个也很重要,后面会讲
    turn_on_action: &toggle
      - remote_transmitter.transmit_raw:
          code: [<你录到的码>]
          repeat:
            times: 8
    turn_off_action: *toggle

这种开关只有「切换」一个动作,所以开和关送的是同一组码。

实际在用的版本多了排队发射、反向侦测和几个诊断感测器。完整的设定档放在这里,两组码换成了占位符,录到自己的码之后填进 transmit_raw 和 on_raw 那两个比对值就能用:

完整的 rf-bridge.yaml
yaml
# ─────────────────────────────────────────────────────────────────────────
# rf-bridge —— ESP32-C3 SuperMini + CC1101 的 433MHz 收发闸道
#
# 目的:解决客厅/房间的「自发电无线开关」无法进 HA 的问题。
#   • RM4 Pro 收不到这种开关(它的射频只认特定协定,且时间解析度不够细)
#   • CC1101 能侧录任意 OOK 原始波形 → 录下开关的码 → 用同一片发射给通断器
#
# 用法分两阶段:
#   阶段一(录波):dump: raw,按墙上开关,看 log 印出的原始时序。
#                  这时把本装置拿到开关旁边(行动电源供电)确保收得到。
#   阶段二(控灯):把录到的码填进下面 script 的 transmit_raw,找个收得到两间房的位置常驻。
#
# 脚位对照 ESP32-C3 SuperMini 的丝印:
#   SPI 走板子本来就标好的 SCK(IO4) / MISO(IO5) / MOSI(IO6) / SS(IO7)
#   两支 GDO 走板上「完全没有开机或系统职责」的自由脚:IO3 和 IO10
#   (另外两支自由脚 IO0 / IO1 留着备用)
# 避开 IO2 / IO8 / IO9 这三支 strapping,以及 IO20 / IO21 这两支 UART。
# 注:IO4~IO7 在资料上标为 JTAG(MTMS/MTDI/MTCK/MTDO),但 C3 内建 USB-JTAG,
#     不接外部 JTAG 的话拿来当 SPI 是正常用法,那也是板厂标示的用途。
# ─────────────────────────────────────────────────────────────────────────

substitutions:
  name: rf-bridge
  friendly_name: "RF Bridge (CC1101)"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2024.6.0
  on_boot:
    # 保险:明确让 CC1101 开机就进接收模式。
    # 官方范例只在「发射完成后」才 begin_rx,没交代开机初始状态,
    # 录波阶段收不到东西的话会很难分辨是没讯号还是没在收。
    priority: -100
    then:
      - cc1101.begin_rx: transceiver
      # 把记住的灯状态「显示」回 HA,只 publish 不执行动作,不会送任何 RF 讯号
      - lambda: |-
          id(room_light).publish_state(id(room_light_saved));
          id(mom_light).publish_state(id(mom_light_saved));

esp32:
  board: esp32-c3-devkitm-1     # C3 SuperMini 用这个 board 设定即可
  framework:
    type: esp-idf

logger:
  # 平常用 INFO。要再录新的遥控器时改回 DEBUG,并把下面 remote_receiver
  # 的 dump: raw 打开,才看得到原始 µs 时序。
  level: INFO
  # ⚠️ ESP32-C3 的 logger 预设输出到 UART0(实体脚 GPIO20/21),
  #    那两支没接任何东西 → 从 USB 读 log 会是一片空白(我第一次烧就踩到)。
  #    要明确指定走晶片原生的 USB Serial/JTAG 才看得到开机讯息。
  hardware_uart: USB_SERIAL_JTAG

api:
  # 给 HA 的「只改显示、不发射」指令:HA 用光线感测器发现状态对不上时呼叫,
  # publish_state 会触发 on_state,所以快闪里记的状态也会一起更新。
  actions:
    - action: set_room_light_state
      variables:
        state: bool
      then:
        - lambda: id(room_light).publish_state(state);
    - action: set_mom_light_state
      variables:
        state: bool
      then:
        - lambda: id(mom_light).publish_state(state);
  encryption:
    key: !secret rf_bridge_api_key

ota:
  - platform: esphome
    password: !secret rf_bridge_ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  # ⚠️ 必须关省电。ESP32-C3 的 WiFi light sleep 会造成两个问题(都实际踩过):
  #   1. 区网内 ping 延迟飙到 67~147ms 且剧烈抖动 —— 对要即时反应的 RF 桥接器不能接受
  #   2. USB Serial/JTAG 会跟着失效,序列埠变成一片空白,害我一度以为晶片当机
  power_save_mode: NONE
  # ⚠️ 不要长期插在伺服器 / 电脑的 USB 3.0 孔上:那里的 2.4GHz 杂讯让它一天断 17 次,
  #    改插手机充电头后 2 天 0 次。
  #    试过 output_power: 8.5dB → 断电重开后也完全连不上,比预设更糟,已拿掉。
  # 断线时记一笔,连回来后 HA 就看得到这次开机断了几次
  on_disconnect:
    - lambda: id(wifi_drops) += 1;
  # 收讯不稳时的后援热点(第一次设定用)
  ap:
    ssid: "rf-bridge-setup"
    password: !secret rf_bridge_ap_password

globals:
  - id: wifi_drops
    type: int
    restore_value: no
    initial_value: '0'
  # 两盏灯最后的状态,存在快闪里,重开机后拿来还原 HA 的显示
  - id: room_light_saved
    type: bool
    restore_value: yes
    initial_value: 'false'
  - id: mom_light_saved
    type: bool
    restore_value: yes
    initial_value: 'false'
  # 记录最后一次发射的时间,给 on_raw 做冷却判断用
  - id: last_tx_ms
    type: uint32_t
    initial_value: '0'

captive_portal:

# 内建网页介面 —— 用来直接测试按钮,不必先接进 HA。
# 之后要拿掉就删这段(会省下一点 flash)。
web_server:
  port: 80
  version: 3

# 首次用 USB 序列烧录后,之后可走 WiFi OTA
improv_serial:

# ─── SPI 汇流排:软体 SPI,用避开雷脚的一般 GPIO ───
spi:
  clk_pin: GPIO4
  miso_pin: GPIO5
  mosi_pin: GPIO6

# ─── CC1101 本体 ───
# 频率锁 433.92(我的开关晶振 26.2982×16.5 换算出来的值)。
cc1101:
  id: transceiver
  cs_pin: GPIO7
  frequency: 433.92MHz
  modulation_type: ASK/OOK
  # 350kHz 太宽,AGC 会把底噪也放大进来。收窄到 200kHz 压杂讯。
  # (实测 325kHz 时每按一次开关会收到 8 笔长度不一的碎片)
  filter_bandwidth: 200kHz
  # ⚠️ symbol_rate 会影响 CC1101 的资料滤波器频宽,不是「只在发射时有用」。
  #    原本设 5000(5 kbaud = 200µs/符号),但实测讯号的基本时间单位是 25µs
  #    (所有脉冲宽度都是 25 的整数倍:25/50/75/100/.../475/875),
  #    等于快了 8 倍,滤波器会把快速变化抹掉 → 每次解调出来都不一样。
  #    40000 baud = 25µs/符号,刚好对上讯号的最小特征。
  symbol_rate: 40000
  output_power: 10             # dBm,最大约 +11
  # 注:cc1101 没有 rssi / lqi 这两个设定项(我一开始写错,compile 直接挡下来)。
  #     RSSI 和 LQI 只在 packet_mode 的 on_packet 触发器里当变数用,
  #     async 模式(我们用的这个)拿不到,也挂不出感测器实体。

# ─── 接收(双针接法,GDO2)───
remote_receiver:
  id: rx
  pin:
    number: GPIO3               # 接 CC1101 GDO2
  # 录波时打开 `dump: raw` 才看得到原始 µs 时序。
  # ⚠️ 不要用 dump: all —— 那只启用「协定解码器」,不含原始波形,
  #    而且会把杂讯硬套成 Pronto / Beo4 等红外线协定,洗版又没用。
  # 平常关掉:反向侦测靠的是下面的 on_raw,不需要 dump 就能运作,
  #          开着会把邻居的遥控器和杂讯也全部印进 log。
  # dump: raw
  # ⚠️ filter 不只是「丢掉短脉冲」,它会把短脉冲两侧的讯号「合并」起来:
  #      真实 185高 / 60低 / 185高  →  filter 90µs 之后变成单一个 430µs 高
  #    所以 90µs 时录到的 477 / 327 很可能是这样造出来的假值。
  #    近距离录波时讯号远高于杂讯,filter 可以放低才录得到真实结构。
  filter: 20us
  # idle 拉长,确保一次按压的所有重复框都收在同一笔里,不会被切断
  idle: 25ms
  tolerance: 45%
  # 注:接收端不需要(也不接受)on_transmit / on_complete。
  #     模式切换全部由 remote_transmitter 那边负责,发射完会自己切回 rx。
  #
  # ── 反向侦测:有人按实体开关时,翻转 HA 里的状态 ──
  # 不用 ESPHome 内建的 raw 比对,因为那要求从框的开头对齐,
  # 而我们的撷取起始相位不固定(同一次按压会收到 2~4 次重复,起点不一定)。
  # 改成自己解成 26 bit 再比数值,不管从哪里开始都认得出来。
  on_raw:
    then:
      - lambda: |-
          // 发射后 1 秒内忽略,避免自己发的讯号把状态翻两次
          static uint32_t last_seen = 0;
          if (millis() - id(last_tx_ms) < 1000) return;

          uint32_t code = 0; int nbits = 0;
          for (size_t i = 0; i + 1 < x.size(); i += 2) {
            int32_t m = x[i], sp = -x[i+1];
            if (m < 20 || sp < 20) { code = 0; nbits = 0; continue; }
            if (sp > 400) {            // 同步间隔 → 一个框结束
              code = 0; nbits = 0; continue;
            }
            code = (code << 1) | (m > sp ? 1 : 0);
            if (++nbits > 26) { code &= 0x3FFFFFF; nbits = 26; }

            if (nbits == 26) {
              if (millis() - last_seen < 800) break;   // 同一次按压的重复框只算一次
              if (code == 0x0000000UL) {     // 换成房间灯的 26 bit 数值
                last_seen = millis();
                ESP_LOGI("rf", "侦测到实体开关:房间灯");
                id(room_light).publish_state(!id(room_light).state);
                break;
              } else if (code == 0x0000001UL) {   // 换成妈妈房间灯的 26 bit 数值
                last_seen = millis();
                ESP_LOGI("rf", "侦测到实体开关:妈妈房间灯");
                id(mom_light).publish_state(!id(mom_light).state);
                break;
              }
            }
          }

# ─── 发射(双针接法,GDO0)───
remote_transmitter:
  id: tx
  pin:
    number: GPIO10              # 接 CC1101 GDO0
  carrier_duty_percent: 100%
  on_transmit:
    then:
      - lambda: 'id(last_tx_ms) = millis();'
      - cc1101.begin_tx: transceiver
  on_complete:
    then:
      - cc1101.begin_rx: transceiver

# ─── 灯的开关实体 ───
# 用 switch 而不是 button,这样 HA 有「开/关」状态可以显示与自动化。
# RF 这种开关只有「切换」一个动作(没有分开的开/关指令),
# 所以 turn_on 和 turn_off 送的是同一组码,靠 optimistic 维持状态。
# 有人按实体开关时,上面 remote_receiver 的 on_raw 会把状态翻过来。
#
# 两颗开关的码(2026-09-09 录,各自贴着模组按十次取共识):
#   房间灯     26 bit = <你的码>
#   妈妈房间灯 26 bit = <你的码>
#   前 10 bit 相同(同型号厂商码),之后分歧 → 不会互相干扰
switch:
  - platform: template
    name: "房间灯"
    id: room_light
    icon: "mdi:ceiling-light"
    optimistic: true
    # ⚠️ 预设 restore_mode 是 ALWAYS_OFF,开机时会「真的执行一次 turn_off」,
    #    而 turn_off 送的是切换码 → 每次重开机灯都被切换一次。所以必须 DISABLED,
    #    状态改由上面 globals 记住、on_boot 只显示不发射。
    restore_mode: DISABLED
    on_state:
      - lambda: id(room_light_saved) = x;
    turn_on_action: &toggle_room
      - script.execute: tx_room_light
    turn_off_action: *toggle_room

  - platform: template
    name: "妈妈房间灯"
    id: mom_light
    icon: "mdi:ceiling-light"
    optimistic: true
    # ⚠️ 预设 restore_mode 是 ALWAYS_OFF,开机时会「真的执行一次 turn_off」,
    #    而 turn_off 送的是切换码 → 每次重开机灯都被切换一次。所以必须 DISABLED,
    #    状态改由上面 globals 记住、on_boot 只显示不发射。
    restore_mode: DISABLED
    on_state:
      - lambda: id(mom_light_saved) = x;
    turn_on_action: &toggle_mom
      - script.execute: tx_mom_light
    turn_off_action: *toggle_mom

# ─── 发射排队 ───
# HA 上连按时,两串讯号若黏在一起,灯只会当成按一次,但 HA 已经翻了两次 → 不同步。
# 所以每盏灯一个 queued script:一次只发一串,发完至少隔 700ms 才发下一串。
script:
  - id: tx_room_light
    mode: queued
    max_runs: 6
    then:
      - remote_transmitter.transmit_raw:
          code: [<换成你录到的码>]
          repeat:
            times: 8
            wait_time: 0s
      - delay: 700ms
  - id: tx_mom_light
    mode: queued
    max_runs: 6
    then:
      - remote_transmitter.transmit_raw:
          code: [<换成你录到的码>]
          repeat:
            times: 8
            wait_time: 0s
      - delay: 700ms

# ─── 诊断 ───
sensor:
  - platform: wifi_signal
    name: "${friendly_name} WiFi"
    update_interval: 60s
    entity_category: diagnostic
  - platform: uptime
    name: "${friendly_name} 开机时间"
    update_interval: 60s
    entity_category: diagnostic
  - platform: template
    name: "${friendly_name} WiFi 断线次数"
    lambda: return id(wifi_drops);
    accuracy_decimals: 0
    state_class: measurement
    update_interval: 60s
    entity_category: diagnostic

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "${friendly_name} IP"
    bssid:
      name: "${friendly_name} BSSID"
  - platform: debug
    reset_reason:
      name: "${friendly_name} 重开原因"

debug:
  update_interval: 5min

密码和金钥走 ESPHome 的 secrets.yaml,需要的栏位是 wifi_ssid、wifi_password、rf_bridge_api_key、rf_bridge_ota_password、rf_bridge_ap_password。

另外 C3 这块板子第一次烧录有个小坑:esptool 最后那句「Hard resetting via RTS」对它没用,烧完要自己按一下 RESET,不然它会一直停在下载模式,看起来像当机。

录波:最容易骗到自己的地方#

硬体接好,真正花时间的是录波。我录了三轮,第二轮差点让我以为做完了。

第一轮:目测「看起来一样」#

一开始用的是 dump: all,log 里全是 Received Pronto,一秒十笔。那是 ESPHome 把杂讯硬套成红外线协定在解码,dump: all 只开协定解码器,不包含原始波形,要改成 dump: raw 才看得到真正的时序。

按的时候每秒收到 8 笔,不按时 30 秒才 1 笔,至少确定开关有在发、CC1101 有在收。我挑了两笔摆在一起看,同一个位置的值几乎一样,心想就是它了。

然后跑了统计:

text
长度 41 那组(25 笔)逐位置变异系数:中位 45%  最大 374%
撷取长度:15 到 67,标准差 9.4

同一组遥控码,每次撷取的同一个位置应该几乎一样,要在 10% 以下才算数。45% 代表我那两笔只是刚好挑到像的。

第二轮:漂亮的假码#

我把 25、50、75µs 这些很短的脉冲当成杂讯,把 filter 从 10µs 拉到 90µs 滤掉。这次的数字漂亮多了:

text
160 个完整框,134 个长度相同
逐位置变异:中位 7.3%  最大 12.2%
框:186, -110, 477, -105, 184, -847, 182, -111, 327, -552, 186, -1305

7.3%,低于门槛。我开开心心做成 HA 按钮,连按了六次,灯一次都没动。

一开始怀疑距离,但这说不通:那颗开关我拆下来拿到很远的地方按都有用,它那点功率都够了,CC1101 没道理不够。所以问题一定在码本身。

回头看,错在两个参数。第一个是 filter,它不只是把太短的脉冲丢掉,还会把短脉冲两边的讯号黏在一起:

text
真实:          185µs 高 → 60µs 低 → 185µs 高
filter 90µs 后: 430µs 高

框里那个 477 和 327,很可能就是这样黏出来的。

第二个更根本。把开关贴着模组重录的时候,我发现所有脉冲宽度都是约 25µs 的整数倍,而设定档里写的是 symbol_rate: 5000,也就是每个符号 200µs。CC1101 的资料滤波器频宽是跟着符号速率走的,设得太慢,快速的变化会直接被滤掉。我当初还在设定档里写了一行注解说「录波阶段不影响 raw 撷取」,完全写反了。

真正让码录得到的两行+2−2
cc1101:
frequency: 433.92MHz
  symbol_rate: 5000
  symbol_rate: 40000
remote_receiver:
  filter: 90us
  filter: 20us
idle: 25ms

filter 敢降到 20µs,是因为改成把开关贴着模组按,讯号远强过杂讯。那时候我是一手抓着天线、一手按开关在录的。

第三轮:54 个值#

改完之后,每次撷取的长度从 19 到 63 一下子变成 93 到 169,而且分成两群,刚好对应同一个框重复两次和三次。算出来周期是 54 个值,逐位置变异中位 9.4%。

结构也终于说得通了:一个同步头,接着 26 个 bit,每个 bit 是一短一长两个脉冲,短的约 37µs、长的是三倍,典型的 一族。烧进去,按下 HA 的按钮,灯关了。那天晚上七点四十分左右。

WARNING

注意第二轮和第三轮:假码的变异系数比真码还低。

滤波器设错的时候,每次都会用同样的方式把讯号抹坏,所以错得非常一致,统计上完全看不出来。变异系数低于 10% 只能当必要条件。让假码露馅的是另外两件事:结构太短(只有 6 组高低脉冲,一般遥控码至少二十几个 bit),以及「开关拿到远处都能用」这个生活常识。

妈妈房间,和反过来听#

成功之后顺手把妈妈房间的灯也做了。同样的方法,录到 9 个框、变异中位 6.7%。两颗码前 10 个 bit 完全一样(应该是同一家厂商),后面有 11 个 bit 不同,不会互相干扰。中间有一次算出变异 0.0%,高兴不到一秒就发现只有一个样本,没东西可比当然是 0。

既然 CC1101 收得到开关,也可以反过来:有人按实体开关时,让 HA 的状态跟着翻。ESPHome 内建的比对要求从框的开头对齐,但每次撷取的起点不固定,我改成在 on_raw 里自己把波形解成 26 个 bit 再比数值,加上发射后 1 秒的冷却(不然会听到自己),以及 800ms 内同一次按压只算一次。第一天测试两间房分得清清楚楚。

用了三周才发现的问题#

每次重开,灯就自己切换一次#

9 月 26 日,HA 上的灯状态开始跟实际对不上。查下来是 ESPHome template switch 的 restore_mode 预设值 ALWAYS_OFF,它的意思是开机时真的执行一次 turn_off。偏偏这颗灯只有切换码,turn_off 送出去就是切换。于是板子每重开一次(拔插、更新韧体、WiFi 断太久自己重开),灯就被切换一次,HA 还显示成关。

改成 restore_mode: DISABLED,状态存在快闪记忆体里,开机只把状态回报给 HA、不发射。顺便把发射改成排队执行,每次之间隔 700ms,免得连按两下黏在一起被通断器当成一次。上面那份设定已经是改好的版本。

实体开关几乎听不到#

反向侦测第一天测得好好的,但从 9 月 9 日到 26 日只侦测到一次实体开关,我慢慢按了 10 下也是 0 次。那时板子插在客厅伺服器上,离两间房的开关 4 到 5 公尺还隔一道墙。当初录码是贴着模组录的,自发电开关那点功率隔墙就不够了。

这个我没有硬解,改用 SwitchBot Hub 3 的光线感测器补:只在晚上动作,房间亮度 4 级以上而 HA 显示关,就改成开;2 级以下而显示开,就改成关。它只更新 HA 的显示、不发射。感测器大约 7.5 分钟回报一次,所以状态最慢 8 分钟内会跟上。

每天断 17 次的 WiFi#

同一段时间它开始断线。9 月 25 日起一天断十几次,26 日傍晚干脆扫不到 IoT 那组 SSID,只有拔插 USB 才会好。接下来三天我把能想到的都试了一遍:

假说结果
路由器的 IoT 网路设定被改了✗ 手机看得到,2.4GHz、频道 6 都正常
路由器把它的 MAC 挡了✗ 过滤、封锁清单、AiProtection 都是干净的
路由器的频段导引不愿意重新接纳 2.4GHz 装置✗ 把 IoT 网路改成只开 2.4GHz,其他 8 台(包括同样只支援 2.4GHz 的 C210 摄影机)都连回来了,只有它不行
调低发射功率 output_power: 8.5dB✗ 更糟,断电重开都连不上
C3 SuperMini 的设计缺陷(晶振离天线太近)△ 板子确实偏弱,但不是主因

C3 SuperMini 有一批板子的设计缺陷还蛮有名的,done.land 有整理:2024 年有一批改版把晶振移到离陶瓷天线只剩 0.3mm(正常至少要 1mm),症状是扫得到 WiFi 却连不上,有人甚至要用手摸着天线才连得上。我一度很确定就是这个,差点去买一块外接天线的板子。好笑的是那篇建议的解法正好是把发射功率调到 8.5dB,而我调了之后反而更连不上,现在回头看,这其实已经在暗示问题不只是板子。

真正让我转向的是另一个细节:某次拔插之后它被插到隔壁的 USB 孔,怎样都连不上,插回原本那个孔马上就好。

差几公分就差这么多,比较像环境问题。USB 3.0 的孔和线本身就会发出 2.4GHz 的干扰,伺服器背后又是金属机壳加主机板杂讯,对一块 WiFi 本来就偏弱的板子来说,那大概是全家最糟的位置。说来好笑,规划的时候我就知道放伺服器旁要离机壳远一点,结果它还是在那里住了三周。

本来想改插房间的电脑,但电脑一样有 USB 3.0,关机睡眠时 USB 还可能断电,半夜最需要关灯的时候反而最容易没电。最后插在一个普通的手机充电头上:

82次
插在伺服器旁
4.8 天,每天 17 次,离线 17%
0次
改插手机充电头
2.0 天

板子不用换了。

现在的样子#

这就是同事印的第五版壳,插在我房间墙上的充电头,天线立起来。从这个位置,我房间和妈妈房间的灯都控制得到。RM4 Pro 退回去只管冷气,它在红外线上一直都很称职。

还没验证的是状态同步:现在这个位置听不听得到两颗实体开关。如果听得到,HA 的灯状态就能即时跟着实体开关走;听不到的话就继续靠光线感测器校正。我自己猜大概听不到,自发电开关的功率实在太小,离得还是有点远。

不过不管状态同不同步,躺在床上关灯这件事是真的做到了。

參考連結
  • python-broadlink:RF learning 只认特定协定的讨论issue #739
  • python-broadlink:用 b1c0 前缀发射 RF 的可用封包issue #778
  • rtl_433:Help decoding Kinetic light switchissue #1790
  • ESP-Home CC1101 Kinetic Switch Transceiverbillmaterial
  • ESP32-C3 SuperMini:晶振太靠近天线的设计缺陷done.land
  • ESPHome:CC1101 元件esphome.io