アプリケーション開発ポータルサイト
ServerNote.NET
Amazon.co.jpでPC関連商品タイムセール開催中!
カテゴリー【SwiftiPhone/iPadXcodeRaspberryPI
iOS/SwiftUIとRaspberryPi/Bleno間でBluetooth LE通信【2・クライアント編】
POSTED BY
2023-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 capabilitiesbluetooth-leを追加、さらに
Information Property List をクリックして+
Privacy - Bluetooth Always Usage Description
を追加、説明Stringは
このアプリはBluetoothを使用します
などとする。

2、ContentView.swift

以下ソースです。

SwiftContentView.swiftGitHub 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を作成し追加します。これが表示内容制御、コマンドアクションすべてを行うメインクラスになります。

SwiftBluetooth.swiftGitHub 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からの返却データを、サーバー編で決めたフォーマットルールにのっとり解析して保存します。

SwiftReplyStream.swiftGitHub 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・サーバー編】

※本記事は当サイト管理人の個人的な備忘録です。本記事の参照又は付随ソースコード利用後にいかなる損害が発生しても当サイト及び管理人は一切責任を負いません。
※本記事内容の無断転載を禁じます。
【WEBMASTER/管理人】
自営業プログラマーです。お仕事ください!
ご連絡は以下アドレスまでお願いします★

☆ServerNote.NETショッピング↓
ShoppingNote / Amazon.co.jp
☆お仲間ブログ↓
一人社長の不動産業務日誌
【キーワード検索】