家族や恋人と同じ食卓を囲んでいるのに、気づけば全員それぞれのスマホをのぞいている。その時間を、もう少しちゃんと一緒に過ごせないか——playpark で開発している Ima. は、一緒にいるあいだだけ、みんなでスマホを手放すための iOS アプリです。スマホ依存を我慢で断つ禁欲ツールというより、目の前の相手との時間を守るための、ささやかな仕掛けに近いものを目指しています。
ただ「一緒にいる時間はスマホを置こう」と自分に課すだけだと、あとで自分でこっそり外せてしまって、その約束はたいてい続きません。Ima. が少し変わっているのは、解除の鍵を自分ではなく「隣にいる相手」のほうに預けているところです。どう使うのかは、アプリを開いた瞬間の画面にだいたい出ています。

「Ima. に入る」のボタンは、最初グレーで押せません。押せるようになる条件が、すぐ下に書いてあります——Bluetooth をオンにして、相手のスマホを近くに置くこと。そして声をかけ合って、全員で同時にタップすること。つまり自分ひとりではロックを始められない。隣に相手が居て、その端末が物理的に近くにあって、初めて鍵がかかります。
これが、さっきの「自分で外せる鍵」への答えでした。約束を本人の意志任せにせず、解除の条件をソフトの根性論ではなく物理に置く。相手の端末が近くにある時だけロックが続き、離れたら自動で解ける。隣にいる人が居てくれることが、そのまま鍵になっているわけです。
アプリが守ると約束しているルールは、最初の同意画面にまとまっています。

この中の2行が、実装の制約をほぼ決めています。「3m以上離れると自動で解除」と「データはサーバーに送らない」。サーバーも GPS も使わずに、3メートルという距離を、手元の2台のスマホだけで測りきらないといけない。
Ima. は App Store にリリース済みです。
この記事は、合意の演出や UX の話ではなく、その下で動いている近接判定レイヤの実装記録です。さっきの「3メートル離れたら解ける」を、BLE(Bluetooth Low Energy)の電波の強さからどう判定しているか、そこでどんな失敗を踏んだかを書きます。
「近さ」を電波の強さで測る
2台の iPhone が「近くにいる」ことを、サーバーもGPSも使わずに判定したい。屋内の食卓で、しかもバッテリーを食わずに。この条件だと CoreBluetooth の RSSI(受信信号強度)が現実的な選択肢になります。
仕組みはシンプルです。両端末が Ima. 専用の Service UUID で広告(advertising)とスキャンを同時に行い、見つけ合ったら接続する。接続後は片方が相手の RSSI を一定間隔で読み続けます。電波が強ければ近い、弱ければ遠い。それだけです。
サンプリングの定数はこのあたりに集約しています。
enum BLEConstants {
// RSSI を 1 秒ごとに読む(1 Hz)
static let rssiPollIntervalSeconds: TimeInterval = 1.0
// 直近 30 秒を移動平均の窓にする
static let proximityWindowSeconds: TimeInterval = 30.0
}
1 Hz でRSSIを読み、直近30秒ぶんを移動平均にかける。なぜ瞬間値をそのまま使わないかというと、BLEのRSSIは恐ろしく暴れるからです。手をかざす、体の向きが変わる、電子レンジが回る。それだけで瞬間値は10dB以上ふらつきます。生の値で「近い/遠い」を判定したら、テーブルに置いたままでも判定が高速で点滅します。30秒窓の平均は、この暴れをならすための保険です。
閾値は2段、でも閾値だけでは足りない
平均が出たら、それを Proximity(近接の度合い)に分類します。境界はRSSIの実測から決めました。
enum Proximity {
case immediate // RSSI >= -50(ほぼ密着)
case near // -50 > RSSI >= -75(同じテーブル圏内)
case far // -75 > RSSI(離れた)
case disconnected
case unknown
}

immediateThreshold を -50、nearThreshold を -75 に置いています。同じテーブルに座っていれば -75 は十分に超える、というのが実機で確かめた肌感です。
問題は境界そのものではなく、境界をまたぐ瞬間です。平均が -75 付近をうろうろすると、near と far が交互に出る。これがそのまま解除判定につながると、座って話しているだけなのにセッションが勝手に終わる。古典的なチャタリングです。
ここで最初は素直にヒステリシスを入れました。「near から far に落ちるには -80 まで下げないと許さない」というやつです。ところが実機検証で、テーブルの端と端、3メートルほど離れた程度だと平均は -70〜-72dBm にしかならず、-80 の壁に届かない。離れているのにセッションが切れないバグになりました。要件(離れたら解ける)と閾値の設定がズレていたわけです。
ヒステリシスをやめて、時間で殴る
ここが今回いちばん設計判断が割れた部分でした。-80 を緩めて -75 に戻せば3メートルは検知できる。でも -75 は near の境界そのものなので、数値上のヒステリシス幅がゼロになり、またチャタリングが復活する。閾値だけで「離れにくく、かつ確実に離れる」を両立させるのは無理がありました。
そこで振動防止の役割を、閾値から時間に移しました。コードのコメントにこの判断の言い換えが残っています。
// near→far ヒステリシス: 前回 .near かつ avg >= -75 なら留まる
// 数値上のヒステリシス幅は 0 だが、振動防止は applyFarDebounce の
// 5 秒デバウンスで代替している(=「閾値を跨いだ瞬間に切り替える」のではなく
// 「閾値を 5 秒継続で下回ったら切り替える」)
static let farDebounceDuration: TimeInterval = 5.0
平均が near の閾値を下回っても、即 far にはしない。下回り始めた時刻を覚えておき、その状態が5秒続いて初めて far を確定させます。途中で閾値内に戻ったらカウンタはリセット。デバウンスのロジックはこんな形です。
private func applyFarDebounce(avg: Double, now: Date) -> Proximity {
if let since = pendingFarSince {
// 5 秒以上続いたら .far 確定
if now.timeIntervalSince(since) >= Self.farDebounceDuration { return .far }
return .near // まだ 5 秒未満なら .near に留まる
}
// 今回はじめて閾値を下回った
pendingFarSince = now
return .near
}
「閾値を跨いだ瞬間に切る」のではなく「閾値を5秒継続で下回ったら切る」。この言い換えひとつで、瞬間的な揺れには鈍く、本当に部屋を出ていく動きには確実に反応する、という相反する要求が同居できました。一瞬席を立ってトイレに行く程度では切れず、明確に離れていけば数秒で解ける。電波の物理を時間でならす、という割り切りです。
「測れていない」と「離れた」は別物
近接判定でいちばん地雷だったのが、unknown と disconnected の取り違えでした。
最初の実装では、RSSIサンプルが無いときの近接を disconnected(切断)として返していました。一見正しそうですが、これが誤検知の温床になります。アプリ起動直後でまだ電波を一度も読めていない瞬間や、readRSSI が一時的に失敗してサンプルが枯れた瞬間にも disconnected が飛ぶ。すると「離れたから切断された」と解釈され、まだ隣にいるのにセッションが終わってしまう。
そこで「接続が切れた」と「まだ測れていない」を別の状態に分けました。
// CurrentValueSubject の初期値を .unknown に
private let proximitySubject = CurrentValueSubject<Proximity, Never>(.unknown)
disconnected は「接続していた相手が切れた」、unknown は「まだスキャン前/相手未発見/評価未完了」を意味する。意味論を分けたうえで、セッション継続中に unknown が来てもセッションを解除しないようにしました。
case .unknown:
// 「測れていない」だけで、相手が離れたとは断定できない。
// ここで即 release すると意図せずセッションが終わる。
// 本当に接続が切れたときは .disconnected が別経路で飛ぶのでそちらで解除。
break
判断のよりどころは「測定の不在を、離脱の証拠にしない」。サンプルが一瞬枯れただけで罰を与えない、という設計です。逆に本当にBLE接続そのものが落ちたときは、切断イベント側で確実に disconnected を流し、そちらで解除する。さらに切断後はスキャンを再開して、ペアリング済みの相手との再接続を自動で試みます。
func centralManager(_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
// ...サンプルを破棄し disconnected を流す...
// 切断後にスキャンを再開してペアリング済み peer の再接続を試みる
beginScanning()
}
switch は default: で逃げずに全ケースを列挙しています。Proximity に新しい状態を足したとき、扱い忘れがコンパイルエラーで全部炙り出される。状態を増やすほど取りこぼしが怖くなる場所なので、コンパイラに見張らせています。
近接を「解除ゲート」に変える
ここまでで near / immediate / far / unknown / disconnected が安定して出るようになりました。最後に、これをセッションの開始条件と継続条件に接続します。
開始側のゲートはこうです。
func isProximityGateSatisfied() -> Bool {
// BLE が接続済みで、かつ近接が near / immediate のときだけ true
guard case .connected = currentConnection else { return false }
switch currentProximity {
case .near, .immediate:
return true
case .far, .disconnected, .unknown:
return false
}
}
このゲートが無かった頃、相手が近くに居なくてもボタンを押せばセッションが始まってしまうバグがありました。原因は、近接ゲートが開始の事前条件になっておらず、片方の端末だけでロックが起動できてしまっていたこと。「隣にいる人が鍵」という設計の根幹が、実は鍵穴に繋がっていなかったわけです。近接を開始の前提条件に格上げして、ようやく「相手がそこに居ないと始まらない」が物理的に保証されました。
継続側は逆で、近接が far か disconnected に落ちたらセッションを解除します。
case .far, .disconnected:
state = .released(reason: .separation)
解除理由を separation(離脱)として明示的に記録する。タイムアウトでも緊急解除でもなく「物理的に離れたから解けた」を、ログとして残せるようにしています。
まとめ
スマホロックの解除を、本人の意志力ではなく「隣にいる人が物理的にそこに居ること」に縛る。その物理を、BLEの電波強度というアナログな信号から取り出すのが今回の肝でした。
整理すると、効いたのは次の3つの割り切りです。
- 暴れる瞬間値は使わない — 1 Hz サンプリングを30秒窓の移動平均でならす
- チャタリングは閾値ではなく時間で抑える — ヒステリシス幅をゼロにして、5秒デバウンスに振動防止を肩代わりさせる
- 測定の不在を離脱の証拠にしない —
unknownとdisconnectedを分け、測れていないだけでは罰を与えない
電波は嘘をつかないけれど、よく揺れる。その揺れをどう飼いならすかが、「隣にいる」という当たり前を機械に判定させるときの全てでした。Ima. のコンセプトに興味を持ってもらえたら、LP と App Store をのぞいてみてください。



