僕の部屋の照明は入居したときから付いていたもので、壁には配線のないワイヤレススイッチが貼ってある。誰が付けたのか、どこで買ったのか、どこのメーカーなのか、まったくわからない。

エアコンや空気清浄機を順番に Home Assistant につないでいったあと、自然とこう思った。照明もいけるんじゃない?ちょうどエアコン用に買った Broadlink RM4 Pro が手元にあって、スペック表には 433MHz の RF 学習対応と書いてある。ちょっと学習させれば終わりだと思っていた。

結局、丸二日かけて学習を試み、壁のスイッチは数百回押した。押しすぎてスイッチのほうが先に壊れるんじゃないかと心配になったくらいだ。RM4 Pro は最初から最後まで一度も学習できず、最終的には NT$220 の ESP32-C3 と CC1101 でようやく片づいた。途中ではかなり陰湿な罠も踏んだ。統計的には完璧に見えるコードが録れたのに、書き込んでみたら照明はまったく反応しなかった。

この記事ではその顛末てんまつを最初から最後までたどる。このスイッチの正体、RM4 Pro が学習できない理由、CC1101 の配線と設定、そして波形を録るときに一番自分を騙しやすいポイント。もし家のスイッチが同じタイプなら、後半の設定はそのまま流用できる。コードを自分で録ったものに差し替えればいい。

まずこのスイッチを理解する#

外してみると、スイッチの裏には何もない。電線もなければ電池もない。これはで、押した瞬間に発電して、その電力でちょうど一回分の無線信号を送る。押すと「カチッ、カチッ」と二回鳴る。押したときに一回、戻るときに一回。

実際に電源を切っているのは、シーリングライトの横に後付けされた小さな箱で、と呼ばれるものだ(中国語では「通断器」)。付いているのは簡体字で「進線/出線」と書かれた端子、鍵の形をしたボタン、青い LED がひとつだけ。こちらもブランドや型番はどこにも書いていない。

スマート電球に替えるという道は最初から閉ざされていた。照明器具は舞光(DanceLight)の 24W シーリングライトで、光源ははんだ付けされた 24 個の SMD LED。替えられる電球がそもそもない。だから HA から制御するには方法がひとつしか残っていない。自分があのスイッチのふりをして、まったく同じ信号を受信リレーに送ることだ。

問題は、周波数もフォーマットも何ひとつわかっていないことだった。

RM4 Pro:エアコンは順調、照明は全滅#

エアコンのほうは本当に順調だった。赤外線コードを一組学習させて SmartIR のコードデータベースと照合したら Hisense のコードにヒットし、171 bit のうち 93% が一致。エアコンはあっさり HA に入った。だから照明にもかなり期待していた。

そこから一晩徹夜して出た結論は、RM4 Pro はこのスイッチを受信できない、というものだった。途中で一番ひどかった間違いをいくつか記録しておく。どれもその瞬間は前進しているように見えたからだ。

868 MHz の「学習できました」#

RM4 Pro の RF 学習は、最初のステップでリモコンを長押ししてスキャンさせる必要がある。でも自己発電スイッチは一回押しても数ミリ秒しか送信しないので、長押しなんてそもそもできない。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 と報告してきた。そのリモコンは赤外線だ。

スキャンの生レスポンスをダンプして、ようやく意味がわかった:

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 のフォーマットではエスケープ文字なので、そこからデータ全体がずれていく。
  • RF パケットには実は 4 バイト余分に必要だった。python-broadlink の #778 で他の人が貼っていた動くパケットを見つけたら、先頭に RF 433MHz を表す b1c0、長さのあとにさらに 00 9f 06 00 が付いていた。リトルエンディアンで換算するとちょうど 433920、つまり 433.92MHz。僕が一晩中送っていたパケットには、これがまるごと抜けていた。
  • 最後にセルフテストを足したときにようやく気づいたのが、別のコマンドを書き直したときに、正解に一番近かった送信コマンドを丸ごと消していたこと。正しいパケットでは一度もテストされていなかった。

全部直して 17 項目のテストが通ってから、もう一度試した。照明はやはり動かなかった。

なぜ受信できないのか#

最終的に事情を説明してくれたのは二つの issue だった。python-broadlink の #739 で、ある人がはっきり書いている。Broadlink が理解できる「RF」は 433 と 315 MHz 上の特定のプロトコルだけで、それ以外は Flipper Zero のようなものが必要かもしれない、と。つまり赤外線学習は本当にパルスを録っているが、RF 学習のほうはファームウェア内のプロトコル復調器で、知らないフォーマットは受け取れない。

rtl_433 の #1790「Help decoding Kinetic light switch」は、冒頭からまさに僕の症状だった。質問者は Sonoff RF bridge と Broadlink RM を試して両方とも受信できず、RTL-SDR に替えてやっと信号が見えたという。

純正アプリも試した。最初はたまに一回学習できたが、そのコードを押しても照明は動かず、しばらく試すうちにまったく学習できなくなった。

自作ツールも、純正アプリも、他人の経験も全部ゼロ。ここでやっと、RM4 Pro の道は行き止まりだと素直に認めた。正直、一番時間を無駄にしたのは技術そのものではなく、偽の数字を握ったまま推論を進め続けて、立ち止まってその数字が本物か確かめなかった何周かのほうだ。

433MHz はただの周波数帯#

最初に一番納得できなかったのはここだ。433MHz ってよくあるやつじゃないの?シャッター、車のキー、ワイヤレスチャイム、全部そうだ。433 対応をうたう機器がなぜ学習できない?

あとになって、「433MHz」が説明しているのは一番外側の層だけだと理解した:

このスイッチRM4 Pro
搬送波周波数433.92 MHz✅
変調✅
符号化フォーマットとタイミング最短パルス約 37µs❌

Wi-Fi と Bluetooth が「どちらも 2.4GHz」なのと同じで、搬送波はんそうはの周波数と変調へんちょう方式が合っていても、それは同じチャンネルにいるというだけだ。相手がどんなフォーマットで、どれくらいの速さで話しているかは別の話になる。RM4 Pro の受信側はファームウェアに内蔵されたプロトコルしか認識せず、このスイッチはそこに含まれていない。送信側は理論上ぎりぎり届くかもしれないが、フォーマットを検証したパケットで最後まで試しても成功しなかったし、実際に電波が出ているのか確かめる測定器も手元になかった。

真相を知るには、生の波形を直接見られるものが必要だった。

ハードウェア選び#

検討した選択肢(価格は 2026 年 10 月時点):

案価格選ばなかった理由
Shelly 1PM Mini を受信リレーの上流に ×2台湾では珍しく、公式で1個約 €15天井裏に上がって商用電源の配線を二回やる必要があるが、電源配線はやったことがない
SwitchBot ボット ×22個で約 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受信のみで送信できない
Shopee の USB CC1101 スティックNT$291チップが実は機能削減版の CC110L

TIP

RTL-SDR を買うなら、Shopee で「V4」とうたいながら千元ちょっとで売っていて R860 と書いてあるものは偽物の可能性が高い。正規品の V4 のチューナーは R828D だ。

最終的に一番安いものを選んだ。むき出しの モジュールに ESP32-C3 SuperMini をつなぎ、 でファームウェアを書く。唯一の欠点は見た目が悪いこと。むき出しの基板二枚にジャンパーワイヤー八本だ。

text
CC1101 433MHz モジュール(SMA アンテナ付き)  $110
ESP32-C3 SuperMini                          $90
メス-メス ジャンパーワイヤー                $20
──────────────────────────────────────────────────
                                            $220、送料無料

C3 を選んだのは Wi-Fi があるからで、この用途なら必要なピンは 6 本だけ。S3 では大げさすぎる。見た目の問題は同僚が解決してくれた。彼は 3D プリンターを持っていて、部品が届いたらはんだ付けしてくれたうえに、ケースも印刷してくれた。

ケースは五バージョン印刷した。ネットには ESP32-S3 SuperMini と CC1101 のケースも、一般的な ESP32 開発ボード用のケースもあるのに、よりによって C3 SuperMini と CC1101 の組み合わせだけがない。自分たちで採寸して修正し、収まるまで一版ずつ印刷するしかなかった。

配線と設定#

ESPHome 公式ドキュメントの 2 ピン接続に従い、SPI は基板のシルクにもともと書いてあるピンを使い、データ用の 2 本はブート時の役割がない GPIO を選んだ:

CC1101ESP32-C3 SuperMini用途
VCC3V3⚠️ 5V に接続しないこと
GNDGND
SCKGPIO4SPI
MISOGPIO5SPI
MOSIGPIO6SPI
CSNGPIO7SPI
GDO0GPIO10送信
GDO2GPIO3受信

C3 SuperMini では GPIO2/8/9(起動用の strapping ピン)と GPIO20/21(UART)を避けること。正しく配線できていれば、起動ログに CC1101 found! Chip ID: 0x0004 が出る。

設定ファイルの核心はこの部分だ(簡略版。完全版は下にある):

yaml
logger:
  hardware_uart: USB_SERIAL_JTAG   # C3 はデフォルトで UART0 に出力する。これがないと USB でログが読めない

wifi:
  power_save_mode: NONE            # 省電力を切らないと LAN 内の 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

このタイプのスイッチには「トグル」という動作しかないので、オンとオフで同じコードを送る。

実際に使っているバージョンには、送信のキューイング、逆方向の検知、いくつかの診断センサーが追加されている。完全な設定ファイルを以下に置いておく。2 つのコードはプレースホルダーに置き換えてあるので、自分のコードを録ったら transmit_raw と on_raw の 2 か所の比較値に入れれば使える:

rf-bridge.yaml の完全版
yaml
# ─────────────────────────────────────────────────────────────────────────
# rf-bridge —— ESP32-C3 SuperMini + CC1101 の 433MHz 送受信ゲートウェイ
#
# 目的:リビング/寝室の「自己発電ワイヤレススイッチ」を HA に入れられない問題を解決する。
#   • RM4 Pro はこの種のスイッチを受信できない(RF は特定のプロトコルしか認識せず、時間分解能も足りない)
#   • CC1101 なら任意の OOK 生波形を傍受できる → スイッチのコードを録る → 同じ基板から受信リレーに送信
#
# 使い方は二段階:
#   段階一(録り込み):dump: raw にして壁のスイッチを押し、ログに出る生のタイミングを見る。
#                      このときは本機をスイッチのそばに持っていき(モバイルバッテリー給電)確実に受信させる。
#   段階二(照明制御):録ったコードを下の script の transmit_raw に入れ、二部屋とも届く場所に常駐させる。
#
# ピンは ESP32-C3 SuperMini のシルク表記に対応:
#   SPI は基板にもともと書いてある SCK(IO4) / MISO(IO5) / MOSI(IO6) / SS(IO7)
#   GDO の 2 本は「起動やシステムの役割がまったくない」空きピン:IO3 と IO10
#   (残りの空きピン IO0 / IO1 は予備)
# IO2 / IO8 / IO9 の 3 本の strapping と、IO20 / IO21 の 2 本の 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 設定で OK
  framework:
    type: esp-idf

logger:
  # 普段は INFO。新しいリモコンを録るときは DEBUG に戻し、下の remote_receiver の
  # dump: raw をオンにすると生の µs タイミングが見える。
  level: INFO
  # ⚠️ ESP32-C3 の logger はデフォルトで UART0(物理ピン GPIO20/21)に出力するが、
  #    その 2 本には何もつながっていない → USB でログを読むと真っ白(初回書き込みで踏んだ)。
  #    チップネイティブの 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. LAN 内の ping 遅延が 67-147ms に跳ね上がって激しく揺れる —— 即応性が必要な RF ブリッジには許容できない
  #   2. USB Serial/JTAG も道連れで機能しなくなり、シリアルが真っ白になって、一時はチップがフリーズしたと思った
  power_save_mode: NONE
  # ⚠️ サーバー/PC の 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:

# 内蔵の Web UI —— 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 モード(今使っているもの)では取れないし、センサーエンティティにもできない。

# ─── 受信(2 ピン接続、GDO2)───
remote_receiver:
  id: rx
  pin:
    number: GPIO3               # CC1101 の GDO2 に接続
  # 録り込み時は `dump: raw` をオンにすると生の µs タイミングが見える。
  # ⚠️ dump: all は使わないこと —— あれは「プロトコルデコーダー」を有効にするだけで生波形は含まず、
  #    しかもノイズを Pronto / Beo4 などの赤外線プロトコルに無理やり当てはめて、ログを埋め尽くすうえに役に立たない。
  # 普段はオフ:逆方向の検知は下の on_raw でやっているので dump がなくても動く。
  #            オンのままだと近所のリモコンやノイズまで全部ログに出る。
  # dump: raw
  # ⚠️ filter は「短いパルスを捨てる」だけではなく、短いパルスの両側の信号を「結合」してしまう:
  #      実際は 185 High / 60 Low / 185 High  →  filter 90µs のあとは 430µs の High 一本になる
  #    だから 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;
              }
            }
          }

# ─── 送信(2 ピン接続、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

# ─── 照明のスイッチエンティティ ───
# button ではなく switch を使う。そうすれば 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 を押す必要がある。押さないとずっとダウンロードモードに留まっていて、フリーズしたように見える。

波形の録り込み:一番自分を騙しやすいところ#

ハードウェアがつながったあと、本当に時間がかかったのは波形の録り込みだった。三回やって、二回目では終わったと思い込みかけた。

1回目:目視で「同じに見える」#

最初は dump: all を使っていたが、ログは Received Pronto だらけで一秒に十件。あれは ESPHome がノイズを無理やり赤外線プロトコルに当てはめてデコードしているもので、dump: all はプロトコルデコーダーを有効にするだけで生波形は含まない。本当のタイミングを見るには dump: raw にする必要がある。

押している間は毎秒 8 件、押していないときは 30 秒に 1 件。少なくともスイッチが送信していて CC1101 が受信しているのは確かだ。二件を選んで並べてみると、同じ位置の値はほぼ同じ。これだ、と思った。

それから統計を取ってみた:

text
長さ 41 のグループ(25 件) 位置ごとの変動係数:中央値 45%  最大 374%
キャプチャ長:15 から 67、標準偏差 9.4

同じリモコンコードなら、毎回のキャプチャの同じ位置はほぼ同じ値になるはずで、は 10% 以下でないと話にならない。45% ということは、僕が選んだ二件はたまたま似ていただけだった。

2回目:きれいな偽コード#

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 High → 60µs Low → 185µs High
filter 90µs 適用後: 430µs High

フレームの中の 477 と 327 は、たぶんこうやってくっついてできた値だ。

二つ目はもっと根本的だった。スイッチをモジュールにくっつけて録り直したとき、すべてのパルス幅が約 25µs の整数倍になっていることに気づいた。なのに設定ファイルには symbol_rate: 5000、つまり 1 シンボル 200µs と書いてあった。CC1101 のデータフィルタ帯域はシンボルレートに連動していて、遅く設定しすぎると速い変化はそのまま削ぎ落とされてしまう。僕は設定ファイルに「録り込み段階では raw キャプチャに影響しない」なんてコメントまで書いていた。完全に逆だ。

コードが録れるようになった決め手の2行+2−2
cc1101:
frequency: 433.92MHz
  symbol_rate: 5000
  symbol_rate: 40000
remote_receiver:
  filter: 90us
  filter: 20us
idle: 25ms

filter を 20µs まで下げられたのは、スイッチをモジュールにくっつけて押すようにしたことで、信号がノイズよりずっと強くなったからだ。このときは片手でアンテナをつかみ、もう片方の手でスイッチを押しながら録っていた。

3回目:54 個の値#

修正後、各キャプチャの長さは 19-63 から一気に 93-169 になり、しかも二つの群に分かれた。ちょうど同じフレームが二回繰り返された場合と三回繰り返された場合に対応している。計算すると周期は 54 個の値で、位置ごとの変動係数の中央値は 9.4%。

構造もようやく筋が通った。同期ヘッダーがひとつ、続いて 26 bit。各 bit は短いパルスと長いパルスの組で、短いほうが約 37µs、長いほうがその三倍。典型的な 系だ。書き込んで HA のボタンを押すと、照明が消えた。その日の夜、七時四十分ごろだった。

WARNING

2回目と3回目に注目してほしい:偽コードの変動係数のほうが本物より低い。

フィルタの設定を間違えていると、毎回同じやり方で信号を壊すので、ものすごく一貫して間違える。統計ではまったく見分けがつかない。変動係数 10% 以下は必要条件としてしか使えない。偽コードの化けの皮をはがしたのは別の二つのことだった。構造が短すぎること(High/Low のパルスが 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 を送ればトグルになる。こうしてボードが再起動するたび(抜き差し、ファームウェア更新、Wi-Fi が長く切れて自動再起動)に照明が一回トグルされ、HA のほうはオフと表示していた。

restore_mode: DISABLED に変えて、状態はフラッシュメモリに保存し、起動時は HA に状態を報告するだけで送信はしないようにした。ついでに送信をキュー実行に変えて、毎回 700ms の間隔を空けるようにした。二回連続で押したときに信号がくっついて、受信リレーに一回と見なされるのを防ぐためだ。上の設定はすでに修正済みのバージョンになっている。

物理スイッチがほとんど聞こえない#

逆方向の検知は初日のテストではちゃんと動いていたのに、9 月 9 日から 26 日までで物理スイッチを一回しか検知していなかった。ゆっくり 10 回押してみても 0 回。そのときボードはリビングのサーバーに挿してあって、二部屋のスイッチからは 4-5 メートル、しかも壁を一枚挟んでいた。コードを録ったときはモジュールにくっつけていたから気づかなかったが、自己発電スイッチのあの出力では壁越しだと足りなかったのだ。

これは無理に解決せず、SwitchBot Hub 3 の照度センサーで補うことにした。夜だけ動かし、部屋の明るさがレベル 4 以上なのに HA がオフと表示していたらオンに、レベル 2 以下なのにオンと表示していたらオフに直す。HA の表示を更新するだけで、送信はしない。センサーはだいたい 7.5 分ごとに報告するので、状態は遅くとも 8 分以内に追いつく。

1日17回切れる Wi-Fi#

同じ時期に、接続が切れ始めた。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 必要)、Wi-Fi はスキャンできるのに接続できないという症状が出る。アンテナを手で触っていないとつながらないという人までいる。僕は一時これだと確信して、外部アンテナ付きのボードを買いかけた。笑えるのは、その記事が勧めている対処法がまさに送信出力を 8.5dB に下げることで、僕は下げたら逆にもっとつながらなくなったことだ。今振り返ると、それ自体が問題はボードだけじゃないと暗示していた。

本当に方向転換するきっかけになったのは別の細かい出来事だった。ある抜き差しのあと隣の USB ポートに挿したら何をやってもつながらず、元のポートに挿し直したらすぐに直った。

数センチの差でここまで違うのは、環境の問題っぽい。USB 3.0 のポートやケーブルはそれ自体が 2.4GHz の干渉を出すし、サーバーの裏は金属の筐体きょうたいにマザーボードのノイズまである。もともと Wi-Fi が弱めのボードにとっては、たぶん家で最悪の場所だった。笑い話だが、計画の段階で「サーバーの近くに置くならケースから離さないと」とわかっていたのに、結局三週間そこに住まわせていた。

最初は部屋の PC に挿し替えようかと思ったが、PC にも USB 3.0 があるし、シャットダウンやスリープ中は USB の給電が切れることもある。夜中、一番照明を消したいときに限って電源がない、なんてことになりかねない。最終的にはふつうのスマホ用充電器に挿した:

82回
サーバーの横
4.8日間、1日17回、オフライン率17%
0回
スマホ充電器に変更
2.0日間

ボードは替えずに済んだ。

今の姿#

これが同僚の印刷してくれた第五版のケースで、僕の部屋の壁の充電器に挿して、アンテナを立ててある。この位置から、僕の部屋と母の部屋の照明をどちらも制御できる。RM4 Pro はエアコン担当に戻った。赤外線ではずっと優秀だったのだ。

まだ検証していないのは状態の同期で、今の位置から二つの物理スイッチが聞こえるかどうか。聞こえるなら HA の照明状態は物理スイッチにリアルタイムで追従するし、聞こえないなら引き続き照度センサーで補正する。自分の予想ではたぶん聞こえない。自己発電スイッチの出力は本当に小さいし、距離もまだ少しある。

とはいえ状態が同期するかどうかに関係なく、ベッドに寝転んだまま照明を消すことは本当に実現できた。

參考連結
  • python-broadlink:RF 学習は特定のプロトコルしか認識しないという議論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