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・サーバー編】
※本記事内容の無断転載を禁じます。
ご連絡は以下アドレスまでお願いします★
Wav2Lipのオープンソース版を改造して外部から呼べるAPI化する
Wav2Lipのオープンソース版で静止画の口元のみを動かして喋らせる
【iOS】アプリアイコン・ロゴ画像の作成・設定方法
オープンソースリップシンクエンジンSadTalkerをAPI化してアプリから呼ぶ【2】
オープンソースリップシンクエンジンSadTalkerをAPI化してアプリから呼ぶ【1】
【Xcode】iPhone is not available because it is unpairedの対処法
【Let's Encrypt】Failed authorization procedure 503の対処法
【Debian】古いバージョンでapt updateしたら404 not foundでエラーになる場合
ファイアウォール内部のWindows11 PCにmacOS Sequoiaからリモートデスクトップする
【Windows10】リモートデスクトップ間のコピー&ペーストができなくなった場合の対処法
Windows11+WSL2でUbuntuを使う【2】ブリッジ接続+固定IPの設定
【C/C++】小数点以下の切り捨て・切り上げ・四捨五入
Windows11のコマンドプロンプトでテキストをコピーする
【Apache】サーバーに同時接続可能なクライアント数を調整する
GitLabにHTTPS経由でリポジトリをクローン&読み書きを行う
緯度経度の度単位10進数表現とミリ秒表現の相互変換
apt upgradeしたあとnvidia-smiがダメになった場合
Googleスプレッドシートを編集したら自動で更新日時を入れる