← Blog 一覧

Suica をかざしてスマートロックを解錠する自作 NFC リーダーを ATOM Lite で作った

FeliCa Suica NFC M5Stack ATOM Lite ST25R3916 Home Assistant SwitchBot Lock MQTT スマートロック Apple Pay Express Transit

要旨

家の玄関に「手持ちの Suica や Apple Watch をかざすだけで開く NFC スマートロック」を、市販品を買わずに以下の構成で自作した:

[FeliCa media] (Suica / PASMO / モバイル Suica / Apple Watch)
   ↓ 13.56MHz NFC-F
[NFC Universal Unit (ST25R3916)]
   ↓ I2C @0x50 (Grove)
[ATOM Lite (ESP32-PICO-D4)]
   ↓ WiFi → MQTT publish
[Mosquitto on Home Assistant]
   ↓ (whitelist 判定)
[Home Assistant on Proxmox]
   ↓ BLE (SwitchBot Bluetooth integration)
[SwitchBot Lock] 🔓
  • 部品代: 約 5,000 円(SwitchBot Lock を除く)
  • 体感速度: タップから解錠まで約 1 秒
  • 対応メディア: 物理 Suica / PASMO / ICOCA、モバイル Suica (Apple Pay Express Transit)、Apple Watch
  • 安全性: 鍵の状態を ATOM Lite の LED で常時可視化、未登録カードでの試行はカメラスナップ + 通知

背景: 指紋パッドが雨の日に効かない

SwitchBot Lock は便利だが、解錠手段が アプリ / Apple Watch アプリ / 指紋パッド (別売) に限られる。我が家は旧型の SwitchBot 指紋パッドを既に導入済で、晴天時はそれなりに機能していた。

問題は雨の日。指が濡れていたり、パッド表面に水滴が付いていたりすると、認識率が体感半分以下になる。何度もタップし直す or 諦めてアプリを立ち上げる、というのが雨天時の毎度の光景になっていた。傘を片手に、もう片方で iPhone を出してアプリを開く、という動線がとにかく面倒。

一方で、ほぼ全員の財布や iPhone の中には Suica や PASMO が既に入っている。「持ってる IC をそのまま鍵にする」 が一番敷居が低く、しかも

  • 物理 Suica は防水(改札で雨ざらしになる前提の設計)
  • iPhone / Apple Watch は防水等級あり
  • 指先の状態に依存しない(濡れていてもタップで読める)

ので、指紋パッドの「雨に弱い」問題を構造的に回避できる。

似た用途で Akerun / SESAME 等の専用 NFC 解錠デバイスもあるが、月額課金や本体価格 1〜2 万円。すでに SwitchBot Lock がある家なら、NFC リーダーだけ自作して MQTT で繋げば 5,000 円で済む。

既存策と限界

限界
SwitchBot 指紋パッド (実体験)雨天時の認識率が体感半分以下、指が濡れているとさらに悪化
SwitchBot アプリ / Apple Watch アプリ雨の日に iPhone を取り出すのが面倒、アプリ起動 → 解錠ボタンまでの動線が長い
Akerun NFC月額課金、SwitchBot Lock と干渉
Apple HomeKit (Express Transit)iPhone 限定、物理 Suica や非 Apple 端末 NG
RFID リーダー (RC522)NFC-A 専用、FeliCa (Suica) は読めない

FeliCa (13.56MHz NFC-F) を直接読める NFC リーダーを ESP32 に繋いで、MQTT で Home Assistant に流し、SwitchBot Lock の BLE 解錠を発火させる のが、手持ち資産を活かしつつ最低コストで実現できる解。

ハードウェア構成

  • M5Stack ATOM Lite (ESP32-PICO-D4) — 24mm 角・約 5g・WS2812 RGB LED 内蔵・Grove ポート搭載・1,500 円前後
  • M5Stack NFC Universal Unit (ST25R3916) — FeliCa / NFC-F / NFC-A / NFC-B 全対応・約 3,500 円
  • 配線: Grove ケーブル 1 本のみ(I2C @0x50)

ハードウェアの組み立てに半田付けは一切ない。Grove ケーブルを挿すだけ。

設置: ドアのガラス部の内側に貼る

最大の工夫はこれ。ATOM Lite + NFC Unit を玄関ドアの内側、ガラス窓 (郵便スリットや採光窓) の内側に両面テープで貼り付けて、ガラス越しに屋外の Suica を読ませる

メリット:

  • 屋外露出ゼロ。雨・直射日光・盗難・破壊リスクが全て無くなる
  • 配線が屋内側で完結。USB-C 給電を壁内に通すような大工事が不要
  • 防水ケース不要。ATOM Lite の IP 等級問題が一気に解決
  • 13.56MHz NFC は 3〜5mm 厚の窓ガラスなら問題なく通る(改札の透過読取と同じ原理)。実機で物理 Suica / モバイル Suica / Apple Watch すべて反応する距離が出る

唯一のデメリットは「LED の状態が外から見えない」こと。これを解決するために、

LED はドアスコープ越しに見せる

ATOM Lite の WS2812 LED が、ドアスコープ (覗き穴) の魚眼レンズに向かう位置になるように本体を貼り付ける

ドアスコープは屋内 → 屋外を覗くための片方向レンズと思われがちだが、屋内側の強い光源 (= WS2812 LED) は屋外側に普通に漏れて、覗き込まなくても光って見えるレベルではっきり認識できる。実際、ドアの前に立った状態で目線を上げるだけで色が分かる。

  • 緑呼吸 → 施錠中(安全)
  • 橙呼吸 → 解錠中(誰か中にいる、または既に開いている)
  • 認証成功時の緑ストロボ → 「鍵が今、開きました」
  • 認証失敗時の赤ストロボ → 「未登録カードです」

屋外には何も貼らないのに、屋外で操作した結果が屋外で(覗き込まずに)確認できる」という気持ちの良い構成になった。

ハードルとブレークスルー

1. モバイル Suica が普通の polling に応答しない

最初に詰まったのがこれ。物理の Suica は普通に読めるのに、iPhone の Apple Pay Express Transit Suica が読めない

原因は FeliCa polling の system code 仕様:

  • M5Unit-NFC ライブラリの detect() は内部で wildcard polling (0xFFFF) を先に投げる
  • 物理 Suica/PASMO は wildcard に応答するので問題なし
  • iPhone の Express Transit Suica は wildcard には応答せず、system code 0x0003 (CJRC) を明示的に指定された SENSF_REQ にのみ応答する 仕様
  • これは Express Transit の電力節約・誤反応防止のためと推測される

解決策は低レベル polling() を直接呼び、交通系の system code を明示する:

// FeliCa system codes to poll.
//   0x0003 : Suica, PASMO, ICOCA, モバイル Suica, Apple Pay Suica 等 (CJRC)
//   0x80DE : IruCa など他交通系
constexpr uint16_t kPollSystemCodes[] = {0x0003, 0x80DE};

for (uint16_t code : kPollSystemCodes) {
  if (nfcF.polling(picc, code, RequestCode::None, TimeSlot::Slot1)) {
    // hit — IDm を取得
    break;
  }
}

これで Apple Pay Suica / Apple Watch / 物理 Suica すべてが同じコードパスで読める。

2. ST25R3916 のコールドブート NACK

ATOM Lite を再起動した直後、I2C 通信で ST25R3916 が NACK を返してくることがあった。チップの電源安定/内部初期化を待たずに init を叩いている疑い。

解決: 100kHz の settle pass → 400kHz の段階的初期化:

Wire.begin(sda, scl, 100 * 1000U);  // 100kHz でアドレススキャン (settle pass)
for (uint8_t a = 1; a < 0x7F; a++) {
  Wire.beginTransmission(a);
  Wire.endTransmission();
}
Wire.end();
Wire.begin(sda, scl, 400 * 1000U);  // 400kHz で本番運用

100kHz で全アドレスを舐めるだけで、ST25R3916 が安定状態に入る時間を稼げる。これでコールドブート時の NACK は消えた。

3. HA 再起動で「鍵が勝手に開く」事故を防ぐ

最初の実装では IDm を MQTT で retain=true で publish していた。これだと HA を再起動したり MQTT broker を再起動したりすると、retained メッセージで最後の IDm が再配信されて鍵が勝手に開く。盗難リスクとして致命的。

解決: IDm publish は明示的に retain=false。一方で、LWT (Last Will and Testament) と MQTT Discovery 設定 は retain=true のまま (こちらは状態同期に必要):

// IDm: 一回性のイベント — retain=false
mqtt.publish(TOPIC_IDM, (const uint8_t*)buf, n, /*retained=*/false);

// LWT: online/offline 状態 — retain=true (HA が再接続時に状態を取れるように)
mqtt.connect(DEVICE_ID, MQTT_USER, MQTT_PASSWORD,
             TOPIC_STATUS, /*qos=*/1, /*retain=*/true, "{\"online\":false}");

状態は retain、イベントは retain しない」という MQTT の基本則を、解錠系では特に厳格に守る必要がある。

4. 安全 UI: 鍵の状態を玄関で常時可視化

セキュリティ的に重要なのが「今、鍵が開いてるのか閉まってるのか」が玄関の前で分かること。Home Assistant の lock entity が状態変化したら、ATOM Lite に MQTT で push して LED に反映する:

鍵状態LED 色
施錠中 (locked)緑 (呼吸)
解錠中 (unlocked)橙 (呼吸)
状態不明 (unknown)シアン (呼吸)

呼吸エフェクトは sinf(t * 0.003f) で 0〜30 の輝度を滑らかに変化させて、寝静まった廊下でもまぶしくない明るさに調整した。

HA 側からは:

- alias: "FeliCa: ロック状態を MQTT 公開"
  triggers:
    - trigger: state
      entity_id: lock.entrance_lock
    - trigger: homeassistant
      event: start
  actions:
    - service: mqtt.publish
      data:
        topic: home/entrance/lock/state
        retain: true
        payload: "{{ states('lock.entrance_lock') }}"

homeassistant.event: start も trigger に入れてあるので、HA 再起動後も即座に最新状態が流れる。

5. デバウンス (連続発火防止)

カードを近づけたまま離さなくても、IDm は 100ms 間隔で延々と読まれ続ける。これを Home Assistant に流し続けると、自動化が暴走する。

if (idm == lastIdm && now - lastIdmAt < IDM_DEBOUNCE_MS) return;  // 3000ms

同じ IDm は 3 秒間無視。違うカードに切り替えた瞬間は即時反応する。

MQTT Discovery で HA に自動登録

ATOM Lite 起動時に MQTT Discovery 仕様の homeassistant/<component>/<device_id>/<object_id>/config トピックに retained で publish するだけで、Home Assistant 側が device + entity を自動登録してくれる:

  • sensor.felica_entrance_reader_last_idm (最後の IDm)
  • binary_sensor.felica_entrance_reader_online (LWT 連動の疎通センサー)
  • sensor.felica_entrance_reader_last_result (HA の script が publish した認証結果)

これらは同じ device.identifiers を持つので HA 上で 1 デバイスにまとまる。HA の configuration.yaml 側で手書き設定する必要が一切ない。

認証フローの設計: 解錠を最優先 → 副次処理は並列

HA 側のスクリプト felica_check_and_unlock は以下の順序:

# 1. 解錠を最優先 (BLE 経由、最速)
- if: "{{ authorized }}"
  then:
    - service: lock.unlock
      target:
        entity_id: lock.entrance_lock
    - service: mqtt.publish  # 結果を ATOM Lite に return (LED 緑化)
      ...

# 2. スナップショット + Frigate イベント (並列、解錠完了を待たない)
- parallel:
    - service: camera.snapshot   # ゲートカメラ
    - service: camera.snapshot   # 玄関ホールカメラ
    - service: frigate.create_event  # 録画にラベル付け
    ...

# 3. 拒否時のみ persistent_notification
- if: "{{ not authorized }}"
  then:
    - service: persistent_notification.create
      ...

「解錠 → 通知系は並列」 が肝。スナップショット (camera.snapshot) は数百 ms かかるので、これを直列にすると体感解錠速度が遅くなる。

カメラスナップショットのスロットローテーション

イベント毎に画像を保存し続けると、ディスクが埋まる/古いファイルを消すための shell_command を別途設定する必要があり、面倒。

そこで 50 スロットの循環バッファinput_number で実装した:

slot: "{{ '%02d' % (states('input_number.felica_event_slot') | int) }}"
snap_gate: "/config/www/felica/felica_{{ slot }}_gate.jpg"
# ... 撮影 ...
- service: input_number.set_value
  data:
    value: "{{ (states('input_number.felica_event_slot') | int + 1) % 50 }}"
  • 各イベントでスロット番号が +1、50 に達したら 0 に戻る
  • 古いスロットのファイルが自動的に上書きされる
  • shell_command 不要、HA 単体で完結
  • 通知の Markdown 内で ![gate](/local/felica/felica_{{ slot }}_gate.jpg) として画像を直接見られる

ATOM Lite 内蔵 Web UI / OTA

デバッグ・運用のために ATOM Lite 自体に Web UI を載せた。http://felica-entrance.local/ (mDNS) でアクセス:

  • リアルタイムステータス: LED 状態 / Uptime / WiFi RSSI / IP / MQTT 接続 / NFC poll 回数 / 最後の IDm / Free heap / SW version
  • ファームウェア更新: .bin をブラウザからアップロードするだけで OTA
  • 再起動ボタン

これがあると「玄関に貼ったまま 1 度も外さずに」ファーム更新できる。屋外設置の体験が劇的に良くなる。

LED 状態フィードバック

状態LED 色
起動中薄白
WiFi 接続中青点滅
MQTT 接続中橙点滅
待機 (IDLE)鍵状態を反映(緑/橙/シアン 呼吸)
読み取り中白ストロボ
認証成功緑ストロボ → 緑固定 (3 秒)
認証失敗赤ストロボ (3 秒)
エラー黄色点滅

夜中に玄関に立っても「あ、開いてる」「あ、認証通った」が一目で分かる。

カード登録手順

  1. 新しいカード/モバイル/Apple Watch を ATOM Lite にかざす
  2. HA Logbook または sensor.felica_entrance_reader_last_idm で IDm を確認 (16 桁の hex、例: 0123456789ABCDEF)
  3. HA の script の whitelist: 辞書に追加:
whitelist:
  "0123456789ABCDEF": "本人のモバイル Suica"
  "FEDCBA9876543210": "本人の Apple Watch"
  "AABBCCDDEEFF0011": "家族の物理 Suica"
  1. 設定 → 自動化 & スクリプト → YAML リロード で即反映

結果

指標
部品代約 5,000 円 (ATOM Lite + NFC Unit + Grove ケーブル)
体感解錠速度タップから解錠音まで約 1 秒
対応メディア物理 Suica、モバイル Suica、Apple Watch (家族 3 人ぶん登録済み)
反応距離0〜3cm 程度
運用実績数ヶ月、誤発火・誤拒否ゼロ

教訓

  • モバイル決済 IC は wildcard polling に応答しない。Suica なら system code 0x0003 を明示せよ。detect() 系の高レベル API は罠
  • ST25R3916 の I2C 初期化は段階的に。100kHz settle pass → 400kHz が安定
  • MQTT の retain は「状態」だけにかける。「イベント」(IDm 等) は必ず retain=false、でないと broker 再起動で鍵が勝手に開く
  • 解錠を最優先 → 副次処理 (スナップ・通知) は parallel。直列にすると体感速度が落ちる
  • 鍵状態を玄関で可視化する LED は重要。「開いてるかな?」と不安にさせない設計が、結局のところセキュリティ
  • スロットローテーションでディスク管理を HA 単体に閉じる。shell_command 等のサイドカーは入れないほうが運用が楽
  • Web UI + OTA をデバイス内蔵すると屋外運用が楽。これは IoT 自作全般に効く Tips

よくある質問

Suica 以外も使えるの?

PASMO、ICOCA、Kitaca、TOICA、manaca、SUGOCA、nimoca、はやかけん、IruCa など FeliCa 規格の交通系全般が対応。0x0003 (CJRC) と 0x80DE (IruCa) の 2 系統で polling しているため。電子マネー単体の WAON や nanaco も FeliCa なので IDm は取れるが、本記事の polling 設定では拾わない (system code が異なる)。

iPhone の Express Transit Suica は本当に動く?

動く。Face ID やパスコード解除を要求されない (Express Transit が有効な交通系カードだけの特権)。iPhone のロック中でもタップで解錠できるので、改札と同じ感覚で使える。Apple Watch も同様。

Visa Touch / Mastercard コンタクトレスは?

非対応。これらは NFC-A/B (ISO 14443) ベースで、FeliCa (NFC-F) とは規格が違う。本構成では IDm を取得できない。

IDm は他人と被らない?

FeliCa 仕様上、製造時にユニークに割り振られる 8 バイト ID。物理 Suica は一生変わらない。モバイル Suica は機種変更で変わる (Wallet の再発行扱い) ので、その都度再登録が必要。

IDm を読まれたら他人が複製できる?

IDm は読み取り専用で、複製ハードを物理的に作るのは可能だが市販品では困難。とはいえ「IDm 認証だけでは強固ではない」のは知られているので、

  • 紛失時にすぐ whitelist から削除できる構成
  • 拒否時はカメラスナップ + Frigate 録画 + 通知
  • そもそも玄関の外に IDm を読みに来る人は他のルートで侵入する

の三段で十分とした。家庭用途で運用するレベル。

部品はどこで買える?

  • ATOM Lite: スイッチサイエンス / 秋月電子 / M5Stack 公式ストア
  • NFC Universal Unit: M5Stack 公式ストア (国内代理店 経由可)
  • Grove ケーブル: ATOM Lite に同梱、無ければ秋月などで 300 円

Home Assistant が落ちたら解錠できない?

その通り。物理キーは絶対に持って出る前提。HA を高可用化したい場合は Proxmox HA / バックアップ手段 (フォールバックの指紋パッド併設) を検討。我が家は 「物理キー + FeliCa 解錠 + SwitchBot アプリ」の 3 系統 にしている。

ATOM Lite を屋外に貼っていいの?

IP 等級なし。本記事の構成では 屋外には一切貼らず、ドア内側のガラス越しに NFC を通す ことで屋外露出問題そのものを回避している (詳細は本文「設置: ドアのガラス部の内側に貼る」節)。

もしどうしても屋外設置したい場合は軒下が大前提で、トランスペアレント アクリルケース or 防水ケースに収めること。ただ「ガラス越し透過読み取り + ドアスコープ越し LED」のほうが工事不要・防水不要で運用が圧倒的に楽なので、可能ならこちらを推奨。

反応距離は?

カード 0〜3cm 程度。Apple Watch は腕の角度次第で 1〜2cm。改札と同じくらい意識して「タッチ」する必要がある。これは ST25R3916 + 内蔵アンテナの仕様で、外部アンテナを足せば改善するが、家庭用としては十分。

認証から解錠までの時間を縮めたい

今は約 1 秒。内訳は

  • ATOM Lite が IDm を読む: 約 200ms
  • MQTT publish + HA script 起動: 約 100ms
  • BLE で SwitchBot Lock に解錠コマンド: 約 500〜700ms (これがボトルネック)

BLE 部分は SwitchBot Lock 側の仕様なので短縮困難。HA を SwitchBot Lock の物理近傍に置く / BLE プロキシ (ESP32-S3) を玄関側にも追加することで多少改善できる。

同じ仕組みを Switchbot 以外のロックに繋ぎたい

HA に lock entity として認識されていれば何でも繋がる。Sesame、Akerun、Yale、Aqara Door Lock 等、HA インテグレーションがあるロックに変えるだけで OK。スクリプト内の lock.entrance_lock を入れ替えるだけ。

制作プロセスについて

ハードウェア構成検討、M5Unit-NFC ライブラリの挙動調査 (特に Apple Pay Express Transit が wildcard polling に応答しない件の切り分け)、ST25R3916 のコールドブート NACK 対策、MQTT Discovery の YAML 自動生成、HA script のスロットローテーション設計などには Anthropic の Claude Code を活用した。実機への書き込み・玄関への物理設置・家族への運用説明は人間担当。

この記事をシェア