iPhone(iOS) で iBeacon と EN API 発信機を作る

接触確認アプリでは Android/iOS の相互で BLE 通信を行うために、iOS での発信機のチェックもしておきます。
iOS の場合は、16 bit Service UUID 形式と Manufacturer Data 形式では発信できません。が、”発信できないこと” を確認するために、あえて両方の実装もしてあります。実際に iOS/Android の受信機で受信ができないことを確認してください。

iOS の場合、BLE の発信タイミングを制御することはできません。受信機の様子を見ると、かなりの頻度で受信するので、Android の Low Latency と同等の発信タイミングで発信しているようです。これは、別途 M5Stack で受信機を作って計測していきたいと思います。

SwiftUI で作る

これも SwiftUI で作ります。最終的には Flutter とか React Native でもよい気がするのですが、BLE まわりの制御が、Android と iOS でかなり異なるのでネイティブのままで作ってあります。

struct ContentView: View {
    var body: some View {
        TabView {
            BeaconTabView()
                .tabItem {
                    Label("Beacon", systemImage: "antenna.radiowaves.left.and.right")
                }

            FolkBearsTabView()
                .tabItem {
                    Label("FolkBears", systemImage: "bear")
                }

            ENTabView()
                .tabItem {
                    Label("EN API", systemImage: "wave.3.right")
                }

            MfDTabView()
                .tabItem {
                    Label("MfD", systemImage: "shippingbox")
                }
        }
    }
}

struct BeaconTabView: View {
    @StateObject private var transmitter = BeaconTransmitter()
    @State private var majorHex: String = ""
    @State private var minorHex: String = ""

    var body: some View {
        NavigationView {
            Form {
                Section {
                    HStack {
                        Text("Bluetooth")
                        Spacer()
                        Text(transmitter.bluetoothState)
                            .foregroundStyle(.secondary)
                    }
                    HStack {
                        Text("送信状態")
                        Spacer()
                        Text(transmitter.transmissionStatus)
                            .foregroundStyle(transmitter.isTransmitting ? .green : .secondary)
                    }
                } header: {
                    Text("ステータス")
                }

                Section {
                    TextField("Major (4 hex)", text: $majorHex)
                        .textInputAutocapitalization(.none)
                        .autocorrectionDisabled(true)
                        .font(.system(.body, design: .monospaced))
                        .onSubmit(applyMajorMinor)

                    TextField("Minor (4 hex)", text: $minorHex)
                        .textInputAutocapitalization(.none)
                        .autocorrectionDisabled(true)
                        .font(.system(.body, design: .monospaced))
                        .onSubmit(applyMajorMinor)
                } header: {
                    Text("Major / Minor (hex)")
                }

                Section {
                    Toggle("raw iBeacon manufacturer を使う", isOn: $transmitter.useRawIBeaconAdvertising)
                } header: {
                    Text("オプション")
                }

                Section {
                    HStack {
                        Button {
                            transmitter.startTransmitting()
                        } label: {
                            Label("発信開始", systemImage: "play.fill")
                        }
                        .disabled(transmitter.isTransmitting || transmitter.bluetoothState != "Powered On")

                        Button {
                            transmitter.stopTransmitting()
                        } label: {
                            Label("停止", systemImage: "stop.fill")
                        }
                        .disabled(!transmitter.isTransmitting)
                    }
                } header: {
                    Text("操作")
                }
            }
            .navigationTitle("Beacon")
            .onAppear(perform: syncFieldsFromModel)
        }
    }

    private func syncFieldsFromModel() {
        let newMajor = UInt16.random(in: 0...UInt16.max)
        let newMinor = UInt16.random(in: 0...UInt16.max)
        transmitter.major = newMajor
        transmitter.minor = newMinor
        majorHex = String(format: "%04X", newMajor)
        minorHex = String(format: "%04X", newMinor)
    }

    private func applyMajorMinor() {
        let cleanedMajor = majorHex.trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "0x", with: "")
            .uppercased()
        if let value = UInt16(cleanedMajor, radix: 16) {
            transmitter.major = value
            majorHex = String(format: "%04X", value)
        }

        let cleanedMinor = minorHex.trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "0x", with: "")
            .uppercased()
        if let value = UInt16(cleanedMinor, radix: 16) {
            transmitter.minor = value
            minorHex = String(format: "%04X", value)
        }
    }
}

iBeacon 形式で発信する

iBeacon を発信するときは CBPeripheralManager, CLBeaconRegion, CBPeripheralManagerDelegate を使います。iBeacon 自体は、Manufacturer Data と同じなので、実は startAdvertisingRawIBeacon 関数のように、Manufacturer Data を手作りして startAdvertising することも可能なのですが、実はできません。iOS では、自由なデータを送信することができなくて、アドバタイズは iBeacon 形式のみに限られています。

class BeaconTransmitter: NSObject, ObservableObject {
    private var peripheralManager: CBPeripheralManager?
    private var beaconRegion: CLBeaconRegion?
    // 実験用: raw iBeacon manufacturer data を使って広告するかどうか
    @Published var useRawIBeaconAdvertising = false
    // 保持しておくアドバタイズデータ(デバッグ用)
    private var lastAdvertisementData: [String: Any]?

    @Published var isTransmitting = false
    @Published var transmissionStatus = "停止中"
    @Published var bluetoothState = "Unknown"
    @Published var major: UInt16 = 0
    @Published var minor: UInt16 = 0

    // デフォルトのiBeacon設定
    private let defaultUUID = UUID(uuidString: "90FA7ABE-FAB6-485E-B700-1A17804CAA13")!
    private let defaultIdentifier = "FolkBearsBeacon"

    override init() {
        super.init()
        setupPeripheralManager()
    }

    private func setupPeripheralManager() {
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }

    func startTransmitting() {
        guard let peripheralManager = peripheralManager,
              peripheralManager.state == .poweredOn,
              !isTransmitting else {
            print("Bluetooth が利用できないか、既に発信中です")
            return
        }

        let major = CLBeaconMajorValue(major)
        let minor = CLBeaconMinorValue(minor)

        // ビーコンリージョンを作成
        beaconRegion = CLBeaconRegion(
            uuid: defaultUUID,
            major: major,
            minor: minor,
            identifier: defaultIdentifier
        )

        guard let region = beaconRegion else { return }

        // アドバタイズメントデータを生成
        if useRawIBeaconAdvertising {
            // raw manufacturer data を作成して startAdvertising する
            startAdvertisingRawIBeacon(uuid: defaultUUID, major: UInt16(major), minor: UInt16(minor), txPower: -59)
        } else {
            // measuredPowerを明示的に設定(-59dBmが一般的)
            let peripheralData = region.peripheralData(withMeasuredPower: -59 as NSNumber)
            // 保持しておく(デバッグ)
            if let adv = peripheralData as? [String: Any] {
                lastAdvertisementData = adv
            }
            // アドバタイズ開始
            peripheralManager.startAdvertising(peripheralData as? [String: Any])
        }

        isTransmitting = true
        transmissionStatus = "発信中..."
        let majorHex = String(format: "%04X", major)
        let minorHex = String(format: "%04X", minor)
        print("📡 iBeacon 発信開始")
        print("   UUID: \(defaultUUID)")
        print("   Major: \(majorHex)")
        print("   Minor: \(minorHex)")
        print("   Measured Power: -59dBm")

        // デバッグ用:アドバタイズメントデータを表示
        if useRawIBeaconAdvertising {
            if let adv = lastAdvertisementData {
                print("   Advertisement Data (raw manufacturer used): \(adv)")
            } else {
                print("   Advertisement Data: (raw manufacturer advertising active)")
            }
        } else if let advData = lastAdvertisementData {
            print("   Advertisement Data: \(advData)")
        }
    }

    // MARK: - Raw iBeacon (manufacturer data) 広告(実験用)
    /// iBeacon の manufacturer data を手作りして広告を行う(実験用)
    private func startAdvertisingRawIBeacon(uuid: UUID, major: UInt16, minor: UInt16, txPower: Int8 = -59) {
        // iBeacon フォーマット: Apple company id (0x004C little-endian), 0x02, 0x15, UUID(16), major(2), minor(2), tx(1)
        var data = Data()
        // Apple company ID (0x004C) little-endian
        data.append(0x4C)
        data.append(0x00)
        // iBeacon type and length
        data.append(0x02)
        data.append(0x15)

        // UUID bytes (big-endian order as raw bytes of UUID)
        withUnsafeBytes(of: uuid.uuid) { (bytes: UnsafeRawBufferPointer) in
            data.append(contentsOf: bytes)
        }

        // major (big endian)
        data.append(UInt8((major >> 8) & 0xFF))
        data.append(UInt8(major & 0xFF))
        // minor (big endian)
        data.append(UInt8((minor >> 8) & 0xFF))
        data.append(UInt8(minor & 0xFF))
        // tx power
        data.append(UInt8(bitPattern: txPower))

        let adv: [String: Any] = [CBAdvertisementDataManufacturerDataKey: data]
        // デバッグ用に保持と表示
        lastAdvertisementData = adv
        print("📡 iBeacon (raw) 発信データ生成: manufacturerData length=\(data.count)")

        peripheralManager?.startAdvertising(adv)
    }

    func stopTransmitting() {
        guard let peripheralManager = peripheralManager,
              isTransmitting else { return }

        peripheralManager.stopAdvertising()

        isTransmitting = false
        transmissionStatus = "停止中"
        print("iBeacon 発信停止")
    }

    func updateBeaconParameters(major: CLBeaconMajorValue? = nil, minor: CLBeaconMinorValue? = nil) {
        if let major = major { self.major = major }
        if let minor = minor { self.minor = minor }

        let newMajor = CLBeaconMajorValue(self.major)
        let newMinor = CLBeaconMinorValue(self.minor)

        if isTransmitting {
            stopTransmitting()
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.startTransmitting()
            }
        }
        print("ビーコンパラメータ更新 - Major: \(newMajor), Minor: \(newMinor)")
    }
}
extension BeaconTransmitter: CBPeripheralManagerDelegate {
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        DispatchQueue.main.async {
            switch peripheral.state {
            case .poweredOn:
                self.bluetoothState = "Powered On"
                print("Bluetooth が有効になりました")
            case .poweredOff:
                self.bluetoothState = "Powered Off"
                self.stopTransmitting()
                print("Bluetooth が無効です")
            case .resetting:
                self.bluetoothState = "Resetting"
                print("Bluetooth リセット中")
            case .unauthorized:
                self.bluetoothState = "Unauthorized"
                print("Bluetooth 使用権限がありません")
            case .unsupported:
                self.bluetoothState = "Unsupported"
                print("Bluetooth がサポートされていません")
            case .unknown:
                self.bluetoothState = "Unknown"
                print("Bluetooth 状態不明")
            @unknown default:
                self.bluetoothState = "Unknown"
                break
            }
        }
    }

    func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
        DispatchQueue.main.async {
            if let error = error {
                print("❌ アドバタイズ開始エラー: \(error.localizedDescription)")
                self.transmissionStatus = "エラー: \(error.localizedDescription)"
                self.isTransmitting = false
            } else {
                print("✅ アドバタイズ開始成功")
                print("   状態: Advertising")
                print("   確認: Android側でスキャンを開始してください")
                self.transmissionStatus = "発信中"
            }
        }
    }

    func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
        print("🔄 PeripheralManagerの準備完了")
    }
}

デバイス名の発見アドバタイズ

GattAdvertise で、Device Name をアドバタイズするパターンも作っています。これは GATT 接続の前に発信されるアドバタイズです。
デバイス名だけを送信(実はデバイス名自体はいらないのですが)するだけなので、CBPeripheralManager と CBPeripheralManagerDelegate だけで十分です。アドバタイズデータ advertisementData では、CBAdvertisementDataServiceUUIDsKey と CBAdvertisementDataLocalNameKey のみが有効になります。他のキーを設定しても無視されます。

class GattAdvertise: NSObject, ObservableObject {
    private var peripheralManager: CBPeripheralManager?
    private var customService: CBMutableService?
    
    @Published var isAdvertising = false
    @Published var advertisingStatus = "停止中"
    @Published var bluetoothState = "Unknown"
    @Published var connectedCentrals: [CBCentral] = []
    
    // カスタムサービスとキャラクタリスティックのUUID
    private let serviceUUID = CBUUID(string: "90FA7ABE-FAB6-485E-B700-1A17804CAA13")
    private let characteristicUUID = CBUUID(string: "90FA7ABE-FAB6-485E-B700-1A17804CAA14")
    private let deviceName = "FolkBears-GATT"
    
    private var customCharacteristic: CBMutableCharacteristic?
    private var characteristicValue = "Hello GATT World!"
    
    override init() {
        super.init()
        setupPeripheralManager()
    }
    
    private func setupPeripheralManager() {
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }
    
    func startAdvertising() {
        guard let peripheralManager = peripheralManager,
              peripheralManager.state == .poweredOn,
              !isAdvertising else {
            print("Bluetooth が利用できないか、既にアドバタイズ中です")
            return
        }
        
        let advertisementData: [String: Any] = [
            CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
            CBAdvertisementDataLocalNameKey: deviceName
        ]
        
        peripheralManager.startAdvertising(advertisementData)
        
        isAdvertising = true
        advertisingStatus = "アドバタイズ中..."
        print("GATT アドバタイズ開始 - サービス: \(serviceUUID)")
    }
    
    func stopAdvertising() {
        guard let peripheralManager = peripheralManager,
              isAdvertising else { return }
        
        peripheralManager.stopAdvertising()
        peripheralManager.removeAllServices()
        
        isAdvertising = false
        advertisingStatus = "停止中"
        connectedCentrals.removeAll()
        print("GATT アドバタイズ停止")
    }
    
    func getConnectionSummary() -> String {
        return """
        アドバタイズ状態: \(advertisingStatus)
        接続中デバイス数: \(connectedCentrals.count)個
        サービスUUID: \(serviceUUID)
        デバイス名: \(deviceName)
        """
    }
}

// MARK: - CBPeripheralManagerDelegate
extension GattAdvertise: CBPeripheralManagerDelegate {
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        DispatchQueue.main.async {
            switch peripheral.state {
            case .poweredOn:
                self.bluetoothState = "Powered On"
                print("Bluetooth が有効になりました")
            case .poweredOff:
                self.bluetoothState = "Powered Off"
                self.stopAdvertising()
                print("Bluetooth が無効です")
            case .resetting:
                self.bluetoothState = "Resetting"
                print("Bluetooth リセット中")
            case .unauthorized:
                self.bluetoothState = "Unauthorized"
                print("Bluetooth 使用権限がありません")
            case .unsupported:
                self.bluetoothState = "Unsupported"
                print("Bluetooth がサポートされていません")
            case .unknown:
                self.bluetoothState = "Unknown"
                print("Bluetooth 状態不明")
            @unknown default:
                self.bluetoothState = "Unknown"
                break
            }
        }
    }
    
    func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
        DispatchQueue.main.async {
            if let error = error {
                print("アドバタイズ開始エラー: \(error.localizedDescription)")
                self.advertisingStatus = "エラー"
                self.isAdvertising = false
            } else {
                print("アドバタイズ開始成功")
                self.advertisingStatus = "アドバタイズ中"
            }
        }
    }
}

16 bit Service UUID 形式の発信

EN API 形式の発信は、16 bit Service UUID を指定して発信するパターンですが、iOS では送信できません。送信はできないのですが、確認のためにコードは作ってあります。Android の受信機で受信できないことを確認してください。
たまに、Android/iOS の受信機に到達することがあり(このときのアドバタイズデータはランダム値になっています)、微妙な感じがするのですが、使えないのは確かです。

class ENSimTransmitter: NSObject, ObservableObject {
	private var peripheralManager: CBPeripheralManager?
	private let serviceUUID = CBUUID(string: "FD6F") // Exposure Notification 16-bit UUID
    private let altServiceUUID = CBUUID(string: "FF00") // Alternative UUID for testing

	@Published var isTransmitting = false
	@Published var transmissionStatus = "停止中"
	@Published var bluetoothState = "Unknown"
	@Published var localName = "ENSim"
    @Published var useAltService: Bool = false
	@Published var rpi: Data = ENSimTransmitter.generateRandomRpi()

	override init() {
		super.init()
		setupPeripheralManager()
	}

	private func setupPeripheralManager() {
		peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
	}

	func startTransmitting() {
		guard let manager = peripheralManager else {
			print("PeripheralManager が初期化されていません")
			return
		}

		guard manager.state == .poweredOn else {
			print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
			return
		}

		guard !isTransmitting else {
			print("既にアドバタイズ中です")
			return
		}

        let selectedService = useAltService ? altServiceUUID : serviceUUID
        let serviceData: [CBUUID: Data] = [selectedService: rpi]

		let advertisementData: [String: Any] = [
			CBAdvertisementDataServiceUUIDsKey: [selectedService],
			CBAdvertisementDataLocalNameKey: localName,
			// CBAdvertisementDataServiceDataKey: serviceData
		]

		manager.startAdvertising(advertisementData)

		isTransmitting = true
		transmissionStatus = "発信中..."

		print("📡 EN シミュレーション発信開始")
		print("   Service UUID (16-bit): \(useAltService ? altServiceUUID.uuidString : serviceUUID.uuidString)")
		print("   Local Name: \(localName)")
		print("   RPI (hex): \(rpi.map { String(format: "%02X", $0) }.joined())")
	}

	func stopTransmitting() {
		guard let manager = peripheralManager, isTransmitting else { return }

		manager.stopAdvertising()
		isTransmitting = false
		transmissionStatus = "停止中"

		print("EN シミュレーション発信停止")
	}

	private static func generateRandomRpi() -> Data {
		
		// let bytes = (0..<16).map { _ in UInt8.random(in: 0...255) }

		// ランダムな uuid を生成して RPI として使用(デバッグ用)
		// 送信は成功するが、Service Data の内容はランダム値になってしまうので、
		// 実質利用ができない。
		let uuid = UUID()
		let uuidBytes = withUnsafeBytes(of: uuid.uuid) { Array($0) }
		let bytes = Array(uuidBytes.prefix(16))
		return Data(bytes)
	}
}

Manufacturer Data 形式の発信

自由な形式でデータをブロードキャストする場合は、Manufacturer Data 形式で発信するのが一番いいのですが、これも iOS では使えません。これも、使えないことを確認するためにコードを作ってあります。
先に書いた通り、startAdvertisingRawIBeacon 関数を作ってもデータは送信できません。

/// Advertises custom manufacturer data (often consumed as scan response data on the scanner side).
/// フォーマット: [0]=0x02 (type), [1]=0x10 (length=16), [2..17]=TempId(16byte)

class ManufacturerDataTransmitter: NSObject, ObservableObject {
	private var peripheralManager: CBPeripheralManager?

	@Published var isTransmitting = false
	@Published var transmissionStatus = "停止中"
	@Published var bluetoothState = "Unknown"
	@Published var localName: String = "MFG"

	/// 16-bit company identifier (Little Endian in the payload). Default: 0xFFFF for testing.
	@Published var companyId: UInt16 = 0xFFFF
    let beacon_type = 0x02
    let beacon_length = 0x10

	/// Arbitrary manufacturer payload. Default 16 zero bytes for easy overriding.
	@Published var tempIdBytes: Data = Data(repeating: 0x00, count: 16)

	/// Last advertisement dictionary for debugging.
	private(set) var lastAdvertisementData: [String: Any]? = nil

	override init() {
		super.init()
		setupPeripheralManager()
	}

	private func setupPeripheralManager() {
		peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
	}

	/// Start advertising manufacturer data. Uses CBAdvertisementDataManufacturerDataKey which may appear in scan response on the scanner side depending on size and platform rules.
	func startTransmitting() {
		guard let manager = peripheralManager else {
			print("PeripheralManager が初期化されていません")
			return
		}

		guard manager.state == .poweredOn else {
			print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
			return
		}

		guard !isTransmitting else {
			print("既にアドバタイズ中です")
			return
		}

		// Build manufacturer data: company ID (little endian) + payload.
		var mfgData = Data()
		mfgData.append(UInt8(companyId & 0xFF))
		mfgData.append(UInt8((companyId >> 8) & 0xFF))
        mfgData.append(UInt8(beacon_type))
		mfgData.append(UInt8(beacon_length))
        mfgData.append(tempIdBytes)
		let advertisementData: [String: Any] = [
			CBAdvertisementDataManufacturerDataKey: mfgData,
			CBAdvertisementDataLocalNameKey: localName
		]

		lastAdvertisementData = advertisementData
		manager.startAdvertising(advertisementData)

		isTransmitting = true
		transmissionStatus = "発信中..."

		print("📡 Manufacturer 発信開始")
		print(String(format: "   Company ID: 0x%04X (LE)", companyId))
		print("   tempIdBytes (hex): \(tempIdBytes.map { String(format: "%02X", $0) }.joined())")
		print("   Local Name: \(localName)")
	}

	func stopTransmitting() {
		guard let manager = peripheralManager, isTransmitting else { return }

		manager.stopAdvertising()
		isTransmitting = false
		transmissionStatus = "停止中"

		print("Manufacturer 発信停止")
	}
}

実行

左から

  • Android で受信
  • iPhone で受信
  • iPhone で発信

という状態です。iBeacon の UUID は同じなので、major と minor で判断をします。

iBeacon 受発信の様子

受信回数は5分以内に受信したビーコンの回数を数えているので、5分程度放置しておくと1分あたりの受信頻度が計算できます。実際は、平均等を考えないといけないので、もう少し統計データとして保存できるようにしないといけないのですが。

iOS の受信機では1秒に1回程度、Androidの受信機では1秒に5回程度受信しています。たぶん、iOS 受信機のほうで間引いていると思うのですが、このあたりは後に検証します。

  • Android : 5分で 1210 回 = 1秒あたり 4.03 回
  • iOS : 5分で 300 回 = 1秒あたり 1 回

5分後

参考コード

folkbears-transmitter-ios https://github.com/FolkBearsGroup/ble-tools/tree/master/folkbears-transmitter-ios

カテゴリー: 開発, FolkBears パーマリンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

*