2024-07-13




iOS/SwiftUIとRaspberryPi/Bleno間でBluetooth LE通信【1・サーバー編】の続きとなるクライアント編です。全体構成と通信データフォーマットはサーバー編に記載していますので参照してください。
【iPhone/SwiftUI(セントラル)側のコーディング】
サーバー編で準備したRaspberryPiにiOSからBLEで接続し、文字列のエコーおよび指定したURLの画像を取ってきてもらいBLEで転送して表示します。
プロジェクト一式はこちら。
https://github.com/servernote/iPhoneSample/tree/master/RaspBLE
1、SwiftUIプロジェクト作成とBluetooth許可設定
XCode→New→Project→Single View App→Language/Swift Interfaces/Swift UIで作成
Info.plistにて、
Required device capabilitiesにbluetooth-leを追加、さらに
Information Property List をクリックして+
Privacy - Bluetooth Always Usage Description
を追加、説明Stringは
このアプリはBluetoothを使用します
などとする。
2、ContentView.swift
以下ソースです。
Swift | ContentView.swift | GitHub Source |
// // ContentView.swift // RaspBLE // // Created by webmaster on 2020/07/12. // Copyright © 2020 SERVERNOTE.NET. All rights reserved. // import SwiftUI struct ContentView: View { @ObservedObject private var bluetooth = Bluetooth() @State private var editText = "" var body: some View { ScrollView{ VStack(alignment: .leading, spacing: 5) { HStack() { Spacer() Button(action: { self.bluetooth.buttonPushed() }) { Text(self.bluetooth.buttonText) .padding() .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.blue, lineWidth: 1)) } Spacer() } Text(self.bluetooth.stateText) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) if self.bluetooth.CONNECTED { VStack(alignment: .leading, spacing: 5) { TextField("送信文字列", text: $editText) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.body) HStack() { Spacer() Button(action: { self.bluetooth.writeString(text:self.editText) }) { Text("送信する") .padding() .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.red, lineWidth: 1)) } Spacer() } Text(self.bluetooth.resultText) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) } } Image(uiImage: self.bluetooth.resultImage) .resizable() .scaledToFit() .frame(width: 300, height: 80, alignment: .top) } .onAppear{ //使用許可リクエスト等 } }.padding(.vertical) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
- 後述の自作クラスBluetoothの状態をリアルタイムに表示反映します。
- ボタンアクション、接続状態を表示し、接続が確立している場合に限りエディットボックスと送信ボタン、結果テキストが表示されます。
- 一番下にエディットボックスに画像URLを入力した場合RaspberryPiが画像を取ってくるので、それの表示用Imageを設けています。
3、Bluetooth.swift
New FileでBluetooth.swiftを作成し追加します。これが表示内容制御、コマンドアクションすべてを行うメインクラスになります。
Swift | Bluetooth.swift | GitHub Source |
// // Bluetooth.swift // RaspBLE // // Created by webmaster on 2020/07/12. // Copyright © 2020 SERVERNOTE.NET. All rights reserved. // import Foundation import Combine import CoreBluetooth import UIKit enum Bluestate : Int { case POWERED_ON, POWERED_OFF, SCANNING, SCAN_TIMEOUT, DISCOVER_PERIPHERAL, CONNECTING, CONNECT_TIMEOUT, CONNECT_ERROR, CONNECT_CLOSE, CONNECT_OK } final class Bluetooth: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate, ReplyStreamDelegate { @Published var state:Bluestate = .POWERED_OFF @Published var buttonText:String = "検索できません" @Published var stateText:String = "Bluetoothが使用できません" @Published var resultText:String = "" @Published var resultImage = UIImage(systemName: "photo")! @Published var CONNECTED:Bool = false let SERVICE_UUID_STR = "54f06857-695e-47e4-aea8-c78184ad6c75" let CHARACT_UUID_STR = "30a5f1bb-61dc-45f0-9c52-2218d080fa77" var SERVICE_UUID:CBUUID! var CHARACT_UUID:CBUUID! var CENTRAL:CBCentralManager? var SCAN_TIMER:Timer? var PERIPHERAL:CBPeripheral? var CONNECT_TIMER:Timer? var CHARACTERISTICS:CBCharacteristic? var MAX_WRITELEN:Int! var REPLY_STREAM:ReplyStream! //メンバ変数初期化 NSObject override init() { super.init() SERVICE_UUID = CBUUID( string:SERVICE_UUID_STR ) CHARACT_UUID = CBUUID( string:CHARACT_UUID_STR ) CENTRAL = CBCentralManager( delegate:self,queue:nil ) SCAN_TIMER = nil PERIPHERAL = nil CONNECT_TIMER = nil CHARACTERISTICS = nil MAX_WRITELEN = 0 REPLY_STREAM = ReplyStream() REPLY_STREAM.DELEGATE = self } //状態テキストの更新 func updateLabels(){ switch self.state { case .POWERED_OFF: self.buttonText = "検索できません" self.stateText = "Bluetoothが使用できません" break case .POWERED_ON: self.buttonText = "検索する" self.stateText = "機器を検索してください" break case .SCANNING: self.buttonText = "検索キャンセル" self.stateText = "RaspberryPiを検索しています..." break case .SCAN_TIMEOUT: self.buttonText = "検索する" self.stateText = "RaspberryPiが見つかりませんでした" break case .DISCOVER_PERIPHERAL: self.buttonText = "接続する" break case .CONNECTING: self.buttonText = "接続キャンセル" self.stateText += "\n" + "接続中..." break case .CONNECT_TIMEOUT: self.buttonText = "検索する" self.stateText += "タイムアウトしました" break case .CONNECT_ERROR, .CONNECT_CLOSE: self.buttonText = "検索する" break case .CONNECT_OK: self.buttonText = "切断する" break } } //ボタンアクション func buttonPushed(){ switch self.state { case .POWERED_OFF: break case .POWERED_ON, .SCAN_TIMEOUT, .CONNECT_TIMEOUT, .CONNECT_ERROR, .CONNECT_CLOSE: startScan() break case .SCANNING: stopScan() self.state = .POWERED_ON updateLabels() break case .DISCOVER_PERIPHERAL: connectPeripheral() break case .CONNECTING: disconnectPeripheral() self.state = .CONNECT_CLOSE self.stateText += "キャンセルしました" updateLabels() case .CONNECT_OK: disconnectPeripheral() self.state = .CONNECT_CLOSE self.stateText += "\n接続を切断しました" updateLabels() break } } // status update func centralManagerDidUpdateState( _ central:CBCentralManager ) { print("centralManagerDidUpdateState.state=\(central.state.rawValue)") //central.state is .poweredOff,.poweredOn,.resetting,.unauthorized,.unknown,.unsupported if central.state == .poweredOn { self.state = .POWERED_ON } else{ self.state = .POWERED_OFF stopScan() } updateLabels() } func startScan(){ stopScan() self.state = .SCANNING updateLabels() SCAN_TIMER = Timer.scheduledTimer(timeInterval: 10, target: self, selector: #selector(scanTimeout), userInfo: nil, repeats: false) CENTRAL?.scanForPeripherals( withServices:[SERVICE_UUID],options:nil ) //CENTRAL?.scanForPeripherals( withServices:nil,options:nil ) } func stopScan(){ disconnectPeripheral() SCAN_TIMER?.invalidate() SCAN_TIMER = nil CENTRAL?.stopScan() } @objc func scanTimeout() { stopScan() self.state = .SCAN_TIMEOUT updateLabels() } // discover peripheral func centralManager( _ central:CBCentralManager,didDiscover peripheral:CBPeripheral, advertisementData:[String:Any],rssi RSSI:NSNumber ) { print("didDiscover") stopScan() PERIPHERAL = peripheral //for (key, value) in advertisementData { // print (key) //} self.stateText = "以下のRaspberryPiが見つかりました\n" + peripheral.name! + "(" + peripheral.identifier.uuidString + ")" self.state = .DISCOVER_PERIPHERAL updateLabels() } func connectPeripheral() { if CONNECT_TIMER != nil || CONNECTED || PERIPHERAL == nil { return } self.state = .CONNECTING updateLabels() CONNECT_TIMER = Timer.scheduledTimer(timeInterval: 10, target: self, selector: #selector(connectTimeout), userInfo: nil, repeats: false) CENTRAL?.connect( PERIPHERAL!,options:nil ) } func disconnectPeripheral() { CONNECT_TIMER?.invalidate() CONNECT_TIMER = nil if PERIPHERAL != nil { CENTRAL?.cancelPeripheralConnection( PERIPHERAL! ) PERIPHERAL = nil } CHARACTERISTICS = nil CONNECTED = false } @objc func connectTimeout() { disconnectPeripheral() self.state = .CONNECT_TIMEOUT updateLabels() } // connect peripheral OK func centralManager( _ central:CBCentralManager,didConnect peripheral:CBPeripheral ) { print("didConnect") CONNECT_TIMER?.invalidate() CONNECT_TIMER = nil peripheral.delegate = self peripheral.discoverServices( [SERVICE_UUID] ) } // connect peripheral NG func centralManager( _ central:CBCentralManager,didFailToConnect peripheral:CBPeripheral,error:Error? ) { print("didFailToConnect") self.stateText += "エラー発生" if let e = error{ self.stateText += "\n" + e.localizedDescription } self.state = .CONNECT_ERROR disconnectPeripheral() updateLabels() } // disconnect peripheral RESULT func centralManager( _ central:CBCentralManager,didDisconnectPeripheral peripheral:CBPeripheral,error:Error? ) { print("didDisconnectPeripheral") if error != nil { self.stateText += "\n" + error.debugDescription } var gonotify:Bool = false if( PERIPHERAL != nil ){ gonotify = true } disconnectPeripheral() if gonotify { self.state = .CONNECT_CLOSE self.stateText += "\n" + "接続が切断されました" updateLabels() } } // discover services func peripheral( _ peripheral:CBPeripheral,didDiscoverServices error:Error? ) { print("didDiscoverServices") if error != nil { self.stateText += "エラー発生" self.stateText += "\n" + error.debugDescription self.state = .CONNECT_ERROR disconnectPeripheral() updateLabels() return } if peripheral.services == nil || peripheral.services?.first == nil { self.stateText += "エラー発生" self.stateText += "\n" + "ble error empty peripheral.services" self.state = .CONNECT_ERROR disconnectPeripheral() updateLabels() return } peripheral.discoverCharacteristics( [CHARACT_UUID],for:(peripheral.services?.first)! ) } // discover characteristics func peripheral( _ peripheral:CBPeripheral,didDiscoverCharacteristicsFor service:CBService,error:Error? ) { print("didDiscoverCharacteristicsFor") if error != nil { self.stateText += "エラー発生" self.stateText += "\n" + error.debugDescription self.state = .CONNECT_ERROR disconnectPeripheral() updateLabels() return } if service.characteristics == nil || service.characteristics?.first == nil { self.stateText += "エラー発生" self.stateText += "\n" + "ble error empty service.characteristics" self.state = .CONNECT_ERROR disconnectPeripheral() updateLabels() return } CHARACTERISTICS = service.characteristics?.first MAX_WRITELEN = peripheral.maximumWriteValueLength( for:CBCharacteristicWriteType.withResponse ) print("MAX_WRITELEN="+String(MAX_WRITELEN)) peripheral.setNotifyValue( true,for:(service.characteristics?.first)! ) self.stateText += "接続しました" self.stateText += "\n" + "エディットテキストで入力可能です" self.state = .CONNECT_OK self.resultText = "" CONNECTED = true updateLabels() } //文字列送信 httpから始まる場合画像URLと見なす func writeString(text:String){ if !CONNECTED || text.isEmpty { return } var code:String = "T" let tops:String = String(text.prefix(4)) if tops == "http" { code = "U" } let data = text.data( using:String.Encoding.utf8,allowLossyConversion:true ) let head:String = String( format:"%@%07d",code,data!.count ) let dstr:String = head + "" + text write(data:dstr.data( using:String.Encoding.utf8,allowLossyConversion:true )!) } // データ送信 MAX_WRITELENごとに分割して大きなデータも送信可能 func write(data:Data) { if !CONNECTED { return } var remain:Int = data.count var index:Int = 0 var wlen:Int = 0 while remain > 0 { wlen = MAX_WRITELEN if wlen > remain { wlen = remain } PERIPHERAL?.writeValue( data.subdata( in:index..<index+wlen ), for:CHARACTERISTICS!,type:CBCharacteristicWriteType.withResponse ) remain -= wlen index += wlen } } // write value result func peripheral( _ peripheral:CBPeripheral,didWriteValueFor characteristic:CBCharacteristic,error:Error? ){ print("didWriteValueFor") if let e = error { self.resultText += e.localizedDescription + "\n" } } // read or update value result func peripheral( _ peripheral:CBPeripheral,didUpdateValueFor characteristic:CBCharacteristic,error:Error? ){ print("didUpdateValueFor") if( characteristic.value!.count <= 0 ){ return } if error != nil { self.resultText += error.debugDescription + "\n" return } REPLY_STREAM.append(data:characteristic.value) if peripheral.state == .connected { peripheral.readValue( for:characteristic ) } } func replyDataCompleted(_ reply:ReplyData) { if !reply.TEXT.isEmpty { self.resultText += reply.TEXT + "\n" } if reply.DATA != nil { self.resultImage = UIImage(data: reply.DATA!)! } } }
- ContentView.swiftに表示させるためのデータ変数をすべてPublishedで持ちます。NSObjectを継承することでinit初期化関数が使えるので、そこで主要変数を初期化。
- CoreBluetoothのセントラルとしてRaspberryPiペリフェラルとの接続を確立させる流れは多くのサンプルと同じです。サーバー編でRaspberryPiが作成したサービスUUID、キャラクタリスティックUUIDをここで指定してRaspberryPiのcharacteristic.jsと通信します。
- RaspberryPiを検索開始、または見つかったあとに接続開始する際、タイマーを設けて、一定時間待ってダメだったらキャンセルするようにしています。
- 接続が確立したらContntViewからのエディット入力を受け取り、サーバー編で決めたフォーマットルールでRaspberryPiに送信します。パケットサイズは小さいので、大きな文字列もループで分割して送れるように処理しています。
- エディット入力先頭文字列がhttpで始まる場合、画像URLと見なし、RaspberryPiに取得希望を要求し、データを待ちます。
- RaspberryPiからの返却データは終わるまでdidUpdateValueForが繰り返し呼ばれるので、都度後述のReplyStream.swiftに渡して貯めこんで、1つの返却データ受信を完成させます。
4、ReplyStream.swift
New FileでReplyStream.swiftを作成し追加します。RaspberryPiからの返却データを、サーバー編で決めたフォーマットルールにのっとり解析して保存します。
Swift | ReplyStream.swift | GitHub Source |
// // ReplyStream.swift // RaspBLE // // Created by webmaster on 2020/07/12. // Copyright © 2020 SERVERNOTE.NET. All rights reserved. // import Foundation // Stream Data Format // CODE(1byte text) + BODYSIZE(7byte zero filled text) + BODYDATA // CODE is // N=Notify only(empty body), // T=Simple Text Data // U=URL Text Data // I=Image Binary Data struct ReplyData { var CODE:String var TEXT:String var DATA:Data? init(code:String) { CODE = code TEXT = "" DATA = nil } } protocol ReplyStreamDelegate : class { func replyDataCompleted(_ reply:ReplyData) } class ReplyStream:NSObject { weak var DELEGATE:ReplyStreamDelegate? var STREAM_DATA:Data! var STREAM_INDEX:Int! var CONTENT_CODE:String! var CONTENT_SIZE:Int! var CONTENT_BODY:Data! override init() { super.init() DELEGATE = nil STREAM_DATA = Data() STREAM_INDEX = 0 CONTENT_CODE = "" CONTENT_SIZE = (-1) CONTENT_BODY = Data() } func reset() { resetStream() resetContent() } func resetStream() { STREAM_DATA.removeAll(keepingCapacity:false) STREAM_INDEX = 0 } func resetContent() { CONTENT_CODE = "" CONTENT_SIZE = (-1) CONTENT_BODY.removeAll(keepingCapacity:false) } func append(data:Data?) { if data == nil || data!.count <= 0 { return } STREAM_DATA.append(data!) var remain:Int = STREAM_DATA.count - STREAM_INDEX print("try for stream data remain \(remain)") while remain > 0 { if CONTENT_CODE == "" { print("read content code 1 byte from index \(STREAM_INDEX!)") let codebin:Data = STREAM_DATA.subdata( in:STREAM_INDEX..<STREAM_INDEX+1 ) STREAM_INDEX = STREAM_INDEX + 1 remain = remain - 1 let codestr:String = String( data:codebin,encoding:.utf8 )! print("content code is \(codestr)") if codestr != "N" && codestr != "T" && codestr != "U" && codestr != "I" { print("content code is unknown,continue") continue } CONTENT_CODE = codestr continue } if CONTENT_SIZE < 0 { if remain < 7 { print("not enough packet for read content size") break } print("read content size 7 byte from index \(STREAM_INDEX!)") let sizebin:Data = STREAM_DATA.subdata( in:STREAM_INDEX..<STREAM_INDEX+7 ) STREAM_INDEX = STREAM_INDEX + 7 remain = remain - 7 let sizestr:String = String( data:sizebin,encoding:.utf8 )! let sizeint:Int = Int(sizestr)! print( "contents size is \(sizeint)" ) CONTENT_SIZE = sizeint if CONTENT_SIZE <= 0 { //Completed print("zero content complete call delegate") let reply:ReplyData = ReplyData(code:String(CONTENT_CODE)) resetContent() //読み込み完了 DELEGATE?.replyDataCompleted(reply) } continue } let remain_content:Int = CONTENT_SIZE - CONTENT_BODY.count var bytesforread:Int = remain if bytesforread >= remain_content { bytesforread = remain_content } print("read content body \(bytesforread) byte from index \(STREAM_INDEX!)") CONTENT_BODY.append(STREAM_DATA.subdata( in:STREAM_INDEX..<STREAM_INDEX+bytesforread )) remain = remain - bytesforread STREAM_INDEX = STREAM_INDEX + bytesforread if CONTENT_BODY.count >= CONTENT_SIZE { //Completed print("content body read complete call delegate") var reply:ReplyData = ReplyData(code:String(CONTENT_CODE)) if CONTENT_CODE == "T" || CONTENT_CODE == "U" { reply.TEXT = String( data:CONTENT_BODY,encoding:.utf8 )! print("reply.TEXT=\(reply.TEXT)") } else if CONTENT_CODE == "I" { reply.DATA = Data(CONTENT_BODY) print("reply.DATA.count=\(reply.DATA!.count)") } resetContent() //読み込み完了 DELEGATE?.replyDataCompleted(reply) } } //読んだところまでは捨ててOK print("before remove stream len=\(STREAM_DATA.count)") STREAM_DATA.removeSubrange(0..<STREAM_INDEX) print("after remove stream len=\(STREAM_DATA.count)") STREAM_INDEX = 0 } }
- Bluetooth.swiftから小間切れのデータを連続で受け取り、1つのデータを完成させていきます。
- 完成したテキストデータはReplyData構造体のTEXTに保存します。バイナリデータ=画像データは、DATAに保存します。
- データが完成ししたらDelegateでBluetooth.swiftに通知します。それはそのままContentView.swiftで表示されます。
5、プログラム実行
以下、実行画像です。普通の文字列を入れれば「あなたは~と言いました」と返り、httpから始まるURL文字列を入れれば画像URLと見なされRaspberryPiが取ってくるので、BLEで受け取り表示します。
以上です。サーバー編は以下になります。
iOS/SwiftUIとRaspberryPi/Bleno間でBluetooth LE通信【1・サーバー編】
※本記事内容の無断転載を禁じます。
ご連絡は以下アドレスまでお願いします★
オープンソースリップシンクエンジンSadTalkerをDebianで動かす
ファイアウォール内部のOpenAPI/FastAPIのdocsを外部からProxyPassで呼ぶ
Debian 12でsshからshutdown -h nowしても電源が切れない場合
【Windows&Mac】アプリのフルスクリーンを解除する方法
Debian 12でtsコマンドが見つからないcommand not found
Debian 12でsyslogやauth.logが出力されない場合
Debian 12で固定IPアドレスを使う設定をする
Debian 12 bookwormでNVIDIA RTX4060Ti-OC16GBを動かす
【Debian】apt updateでCD-ROMがどうのこうの言われエラーになる場合
Windows11+WSL2でUbuntuを使う【2】ブリッジ接続+固定IPの設定
【Windows10】リモートデスクトップ間のコピー&ペーストができなくなった場合の対処法
【Apache】サーバーに同時接続可能なクライアント数を調整する
【C/C++】小数点以下の切り捨て・切り上げ・四捨五入
【ひかり電話+VoIPアダプタ】LANしか通ってない環境でアナログ電話とFAXを使う
GitLabにHTTPS経由でリポジトリをクローン&読み書きを行う
Intel Macbook2020にBootCampで入れたWindows11 Pro 23H2のBluetoothを復活させる
VirtualBoxの仮想マシンをWindows起動時に自動起動し終了時に自動サスペンドする
Windows11でMacのキーボードを使うには