4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

watnow Advent Calendar 2024

Day 25

CoreBlueToothですれ違い通信を試みる(検証は追記🙇🏻‍♂️)

Last updated at Posted at 2024-12-24

タイトルにもあるように検証はアドベントカレンダーまでに終了きれなかったので,追記させてもらいます🙇🏻‍♂️
年内にやります,絶対.

はじめに

こんにちは!メリークリスマス!
アドベントカレンダー最終日を担当させていただきます,しゅうとです!
今年もあっという間に年末がやってまいりましたね.今年は運営活動や就活,ゼミなどで大学生活の中でも一番体感速度が早く,2024年の年越しに誰にもバレないように一人でひっそりとジャンプをしたのが昨日のことのように感じますね❄️

そんなことは置いておいて,今回は今年の春からずっと気になっていた
SwiftのCoreBlueToothすれ違い通信
についてある検証がしたかったのですが,バタバタしていて完全に忘れてしまっていたので,今回のアドベントカレンダーを機会にそれを実際に検証しながらCoreBlueToothの周辺情報もまとめていきたいと思います!
CoreBlueToothの概要については既に超絶わかりやすい記事がたくさんあるので,今回は省略させていただいている部分が多くありますが,ご勘弁ください🙇🏻‍♂️
さらに,素人の興味本位の検証でもあるので,条件や環境がガバガバじゃないかという意見もあるかと思いますが,それもご勘弁ください🙇🏻‍♂️

検証内容と動機

まずはじめになぜ今回どんな検証をしたいのかについてです.

今回の検証内容

  • CoreBlueToothを用いたデバイス同士のリアルタイム情報交換の実現性検証
  • すれ違い時の相対速度距離 がどれくらいデバイス同士の通信に影響してくるのかの比較検証
  • データの大きさがどれくらいデバイス同士の通信に影響してくるのかの比較検証(追記)

主にこの3点の検証を実際に実装しながら行いたい思います!

かなり余談(動機)
動機の部分に関してなのですが,なぜ今回このような検証を行おうと思ったかと言うと,バイク同士がすれ違う時に手を振りあうYAEHという文化があるのですが,その文化をスマホアプリに落とし込んで我らがド世代の3DSのすれ違い通信のバイク版を作ったら面白そうだなと思い,今年の春に所属団体のプロジェクト作ろうとした際にCoreBlueToothでできたらベストだよねという話になったのですが,流石に公道の最大速度の60kmですれ違った時に相対速度120kmで通信するのは難しいよねという話になり,結局CoreLocationを用いてゴリ押しで実装したという思い出があり,それを思い返した時にじゃあ実際どれくらいの速度,データ量だったらいけるんだろうという疑問が思い浮かび,検証しようと思ったのが,動機になります!(丁度団体の後輩が同じような実装をしようとしていて参考になればいいなという気持ちも半分)

検証方法

検証する方法に関して今回は以下のような条件,状況で検証していきたいと思います.

条件

  • 使用機種: iPhone13
  • 実験場所: 立命館大学内(人が少なく静か目な場所)

状況

  • すれ違いスピード
    • 時速0km(静止状態)
    • (推定)時速1.8km(一般の人がかなりゆっくり歩いた場合)
    • (推定)時速3.6km(一般の人がゆっくり歩いた場合)
    • (推定)時速8km(一般の人が早歩きをした場合)
    • (推定)時速13km(一般の人が走りをした場合)
    • 時速30km(原付の法定速度)
    • 時速60km(普通自動車,一般道の法定速度)

方法としては
上記の条件で2人が歩く,走る,バイクを走らせるなどをして,専用のアプリケーション(仕様は後述)でアドバタイズ,スキャニングを行いデバイス間で接続,データが交換できるかを検証する.
以上の条件,環境で検証していきたいと思います!

ではまず実装部分から参ります!

アプリケーションの実装

まずはざっくり今回のデータ送受信までの流れを書くと...

  1. アプリケーション起動時デバイス1, 2がアドバタイズを発信し,同時にスキャニングを開始!!
  2. デバイス1, 2がそれぞれのアドバタイズを検知し接続!!
  3. 各デバイスのセントラルが接続したペリフェラルの指定したサービス,指定したデータを取得しに行く!!
  4. データを交換!!

という流れになっています!
画面としては以下のようになっており,今の接続状況やログを見れるようにシンプルなListで表示しています.
下のボタンを押すと,今繋がっているデバイスがListに追加されるようになっています.

今回は最小限の機能のみを実装していくので,構成として
PeripheralManager
CentralManager
CBTthVerificationView
CBTthVerificationViewModel(なんちゃってVM)

で作成したいと思います!
GitHubリポジトリはこちら

まずはPeripheralManagerの実装です!

PeripheralManager

先にコードの全体像を載せておきます!

PeripheralMagnagerクラス
import Combine
import CoreBluetooth
import Foundation

class PeripheralManager: NSObject, CBPeripheralManagerDelegate {
    var peripheralManager: CBPeripheralManager!
    var transferCharacteristic: CBMutableCharacteristic?
    var peripheralPublisher = PassthroughSubject<String, Never>()

    override init() {
        super.init()
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }

    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        if peripheral.state == .poweredOn {
            print("Peripheral is powered on")
            setupService()
            startAdvertising()
        } else {
            print("Peripheral state: \(peripheral.state.rawValue)")
        }
    }

    func setupService() {
        transferCharacteristic = CBMutableCharacteristic(
            type: CBUUID(string: "180C"),
            properties: [.read],
            value: nil,
            permissions: [.readable]
        )

        let service = CBMutableService(type: CBUUID(string: "180B"), primary: true)
        service.characteristics = [transferCharacteristic!]

        peripheralManager?.add(service)
    }

    func startAdvertising() {
        let advertisementData: [String: Any] = [
            CBAdvertisementDataLocalNameKey: "Device",
            CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: "180A")]
        ]
        peripheralManager?.startAdvertising(advertisementData)
    }

    func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
        if let error = error {
            print("Failed to start advertising: \(error.localizedDescription)")
        } else {
            print("Advertising started successfully.")
            peripheralPublisher.send("Peripheral started advertising")
        }
    }

    func peripheralManager(
        _ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest
    ) {
        if request.characteristic.uuid == transferCharacteristic?.uuid {
            request.value = "UidIs1234".data(using: .utf8)
            peripheralManager?.respond(to: request, withResult: .success)
        }
    }
}
早速サクッと紹介します!

今回の実装で注目したいのが,こちらのクラスです.

CBMutableCharacteristic

CBMutableServiceです.

それぞれについて説明すると

CBMutableCharacteristicとは

GATTサーバー(通信をする際にデータを提供する役割を持つデバイスやソフトウェア)
が定義する編集可能なCharacteristicです.
超簡単にいうとここに送信したいデータ(UUID,編集権限,データの初期値,操作可能性)などを設定し格納し,これをGATTクライアントは読み取りの要求を行いデータを受け取るという流れになっています.

そもそもCharacteristicって何?
CharacteristicとはBLEデバイスと通信する際の構成要素の一つであり.特定のデータとそれに関連するデータを含みます.
そして,一つのCharacteristicは一つ以上のService(後述)に関連付けられる.

Characteristic構成要素
  • UUID:
    Characteristicを識別する際に使用(セントラルクラスでUUIDを指定して読み取るデータなどを指定する.)
  • プロパティ:
    • read: データを読み取れる.
    • write: データを書き込める.
    • notify: データの変更を通知できる.
    • indicate: データ変更通知を確認応答付きで行える.
  • 値 (Value)
    Characteristicが保持するデータ本体

今回はsetupService関数内で

 transferCharacteristic = CBMutableCharacteristic(
            type: CBUUID(string: "180C"),
            properties: [.read],
            value: nil,
            permissions: [.readable]
        )

とあるようにUUID180D, 読み取り専用,初期値はnilで定義してあります.

CBMutableServiceとは

CBMutableService は ペリフェラルで公開するカスタムサービスを構成するためのクラスです.
先述した通り,一つのCharacteristicは一つ以上のServiceに関連付けられるので,これも超簡単にいうと先ほど定義したtransferCharacteristicServiceに込めてデバイス同士の接続が完了した後にぶん投げるというイメージ.
今回は以下のように180Bという識別子をつけ,transferCharacteristicを登録しています.

let service = CBMutableService(type: CBUUID(string: "180B"), primary: true)
service.characteristics = [transferCharacteristic!]

ServiceのUUIDが2つ出てくるねんけどぉ
Peripheralクラスをみるとお気づきの通り2つのServiceにCBUUIDを付与しています.
この二つのServiceUUIDの違いを簡単に説明すると

CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: "180A")]

上記のUUIDは「俺ここにいるよ!!」とみんなに発信する,アドバタイズパケットの識別子となっており,デバイスの接続前にセントラルは自分が知っている識別子とこの識別子が合致するものに対して接続を開始します.

CBMutableService(type: CBUUID(string: "180B"), primary: true)

そして今回紹介したこちらのUUIDはデバイスの接続後に特定のデータが入ったServiceを見つけるためのUUIDであり,接続が完了するとセントラルは自分が知っている識別子と同じ識別子を持つサービスを見つけ,欲しいデータを取得します.

つまり
CBAdvertisementDataService はセントラルが接続したいアドバタイズを見つけるためのUUID
CBMutableServiceは接続後にセントラルがほしいデータが入ったサービスを見つけるためのUUID
のようなイメージを持っていただければと思います

そしてこれらの設定が完了したら,実際にstartAdvertising(advertisementData)関数でアドバタイズを発信します.

    func startAdvertising() {
        let advertisementData: [String: Any] = [
            CBAdvertisementDataLocalNameKey: "Device",// デバイスの名前(セントラルがアドバタイズを見つけたときこのKeyが取得できる)
            CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: "180A")]
        ]
        peripheralManager?.startAdvertising(advertisementData) // アバタイズを発信する際のデバイス名とUUIDを込めて発信を始める.
    }

PeripheralManagerクラスの最後の
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request:CBATTRequest)についてです.
このデリゲートメソッドはセントラルが無事ペリフェラルと接続でき,サービスのUUIDを用いてペリフェラルの特定のサービスを特定し,さらにその中の特定のCharacteristicを特定し送信要求をしてきたときに呼び出されるメソッドで,内容としては

        if request.characteristic.uuid == transferCharacteristic?.uuid {
            request.value = "UidIs1234".data(using: .utf8)
            peripheralManager?.respond(to: request, withResult: .success)
        }

送られてきた要求(request)の特定のCharacteristic(request.characteristic)に含まれるUUIDがペリフェラルが設定したものと一致していれば,requestのvalueに特定のデータを格納し,返すというようになっています!←ここにUserIDとか入れたらデータを交換できるのでは?という考えで,今回はValueに個々のUserIDを格納することを想定して検証していきます!(セキュリティ的な面はあまり考慮できていないかもです??)
PeripheralManagerクラスの解説は以上になります!


CentralManager

こちらも全体像を先に載せます!

CentralManagerクラス

class CentralManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    var centralManager: CBCentralManager?
    var centralPublisher = PassthroughSubject<String, Never>()
    var discoveredPeripheral: CBPeripheral? // Peripheralを保持するプロパティ

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn {
            print("Central is powered on")
            centralPublisher.send("Central is powered on")
            startScanning()
        } else {
            print("Central state: \(central.state.rawValue)")
        }
    }

    func startScanning() {
        centralManager?.scanForPeripherals(withServices: [CBUUID(string: "180A")], options: nil)
    }

    func centralManager(
        _ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any], rssi RSSI: NSNumber
    ) {
        if RSSI.intValue > -100 {
            let deviceName = peripheral.name ?? "Unknown Device"
            print("Discovered peripheral: \(deviceName)")
            centralManager?.stopScan()
            discoveredPeripheral = peripheral
            discoveredPeripheral?.delegate = self // delegateを設定
            centralManager?.connect(peripheral)
            centralPublisher.send("connection complete peripheral \(deviceName)")
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("Connected to peripheral: \(peripheral.name ?? "Unknown")")
        peripheral.discoverServices([CBUUID(string: "180B")])
    }


    func peripheral(
        _ peripheral: CBPeripheral, didDiscoverServices error: Error?
    ) {
        if let services = peripheral.services {
            for service in services {
                centralPublisher.send("Discovered Service: \(service.uuid)")
                if service.uuid == CBUUID(string: "180B") {
                    peripheral.discoverCharacteristics([CBUUID(string: "180C")], for: service)
                }
            }
        }
    }

    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService, error: Error?
    ) {
        if let characteristics = service.characteristics {
            for characteristic in characteristics {
                if characteristic.uuid == CBUUID(string: "180C") {
                    print("Discovered Characteristic: \(characteristic.uuid)")
                    centralPublisher.send("Discovered Characteristics")
                    peripheral.readValue(for: characteristic)
                }
            }
        }
    }

    func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic, error: Error?
    ) {
        centralPublisher.send("readValue")
        if let value = characteristic.value {
            let receivedString = String(data: value, encoding: .utf8) ?? "Invalid data"
            print("Received from Peripheral: \(receivedString)")
            centralPublisher.send(receivedString)
        }
    }
}

セントラルのクラスでは基本的に

  1. ペリフェラルで実装したアドバタイズをスキャニング
  2. 特定のUUIDを持ったサービスを見つけ接続(スキャニングをストップ)
  3. 接続先のペリフェラルに含まれるサービスの中から指定のUUIDを持つものを探す
  4. 特定したサービスの中から指定したUUIDのCharacteristicsを探し,見つけた場合は取得要求を送信
  5. ペリフェラルからデータを受け取り
    という流れになっております!

こちらもざっくり解説していきます!

まずはこちらのデリゲートメソッドについてです.

    func centralManager(
        _ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any], rssi RSSI: NSNumber
    ) {
        if RSSI.intValue > -100 {

こちらはスキャニングを開始した後に実際に指定したUUIDのアドバタイズパケットを見つけた時に発火する関数になります.そして少し注目したいのが,
rssi RSSI: NSNumberこちらの引数にはRSSIと呼ばれるセントラルとペリフェラルのデバイス間の電波強度を表す数値が入ってきます.つまり今回はアドバタイズを見つけた時にこのメソッドが発火されるので,アドバタイズを見つけた時のデバイス間の電波強度が入ってくるということになります.
さらに今回はRSSIの値が-100以上(通信強度としては低いが,今回の検証ではちょうど良かったので,調整しました)であることを条件としているので,ある程度電波強度が強い時のみに接続をするというロジックになっています.(つまり,ある程度近い距離ですれ違った場合のみに接続し,データを送る)

さらにそれぞれのデリゲートメソッドについて見ていきましょう.

まずこちらです.

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral)

この関数は無事ペリフェラルと接続できた時に呼び出される関数になります,
ここでは実際に指定したUUID("180B")をペリフェラルに対して「このUUIDのサービス探してくれ!」とお願いします.

        peripheral.discoverServices([CBUUID(string: "180B")])

こちらは先ほど記述したデリゲートメソッドでペリフェラルがサービスを発見したときにに発火する関数です.
didDiscoverServicesには先ほどの関数でペリフェラルに対して探索のお願いをしてその結果の内容が入っています.
この関数内では,指定したUUIDを元にCharacteristicsを探させる関数を発火させます.
この関数ないでサービスのUUIDを改めてif letで定義し直しているのはもしかしたらそれ以外の値も入っているかも...というリスクを回避するためっぽいですね.

func peripheral(
        _ peripheral: CBPeripheral, didDiscoverServices error: Error?
    )

ここでもペリフェラルに対して「"180C"をUUIDとするCharacteristicはないか探してくれ!」とお願いします.

peripheral.discoverCharacteristics([CBUUID(string: "180C")], for: service)

ここまで見るともうお気づきだと思いますが,こちらはCharacteristicsを見つけた時に発火する関数です.
ここでは先ほどと同じく,実際に帰ってきたデータが正しいかの妥当性を確認します.

func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService, error: Error?
    )

この関数内で実際に欲しいデータがあった場合にペリフェラルに対して読み取りを要求します.

if request.characteristic.uuid == transferCharacteristic?.uuid {
request.value = "UidIs1234".data(using: .utf8)
peripheralManager?.respond(to: request, withResult: .success)
}
送られてきた要求(request)の特定のCharacteristic(request.characteristic)に含まれるUUIDがペリフェラルが設定したものと一致していれば,requestのvalueに特定のデータを格納し,返すというようになっています!

この処理に移るというわけですね!
peripheral.readValue(for: characteristic)

そしてデータが帰ってきたらこの関数が発火し,データを表示すしてくれるという流れです!!

func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic, error: Error?
    )

ここまでが,実装の流れになります!!
その他のクラスの実装は省略させていただきますが,
各Managerクラスの各ポイントでログをCombineで購読し配列に追加し,その配列をViewとバインディングする形で画面に表示しています!


さて,ささっと解説する予定だったのに超絶ボリューミーになってしまいましたが,こっから実際に検証に写っていきたいと思います!!

検証(途中)🚧

では早速検証していきましょう!

今回の条件としてアドバタイズを見つけ,デバイス間のRSSIが-100dBm以上になった場合,接続を開始するという前提で行なっていきます!
今回はすれ違う前のスタート時の2人の距離として,2人が同時にアドバタイズ,スキャニングを開始したタイミングで,接続されないことを確認できる距離(だいたい50m程度)で行いました.

検証結果に移ります!!!

時速0km(静止状態&デバイスを隣に置く)の場合

ちゃんと実装として合っているかを確認するための実現性検証みたいなものですね.
この場合は通信を開始したとほぼ同時に接続されました.

(推定)時速1.8km(一般の人がかなりゆっくり歩いた場合)

この場合は

最後に

かなり長い記事になってしまいましたが,最後まで読んでいただきありがとうございました!
初めてCoreBlueToothをがっつり触ったので,よりデバイスと仲良くなれた気がしてとても楽しかったです!実装としてもかなりガバガバなものではあると思いますが,前から気になっていた疑問点を検証することができて良かったです!
間違っている箇所やもっと良くできる部分などあればぜひ教えていただきたいです!
2024年も残り6日となりましたが,
お体にお気をつけて,良いお年🍊&メリークリスマス🧑‍🎄

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?