接触確認アプリでは 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
