我房間的燈是搬進來就有的,牆上配的是一顆沒有電線的無線開關。誰裝的、哪裡買的、什麼牌子,我一概不知道。

家裡的冷氣、空氣清淨機陸續接進 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