電気ひつじ牧場

技術メモ

ラズパイ3内蔵BluetoothでiPhone操作のラジコンカーを作るまで2(iPhoneアプリ編)

かなり前の記事から引き続いてiPhoneアプリを作っていきます
かなり前の記事↓
cha-shu00.hatenablog.com

iPhoneアプリ 〜概要〜

・追加で使用する主なフレームワークはCoreBluetoothとCoreMotion。それぞれBLEデータを送信する用、iPhoneの姿勢データを読み取る用に使う。
・はじめのビューのボタンをタップするとBLEによるPeripheralの探索が始まり、次のビューに遷移する。
・二つ目のビューではラジコンのDCモータの出力を制御するためのスライダーと、サーボモータの角度を制御するために姿勢センサの実装をする。
・画面遷移はSegueを使って行う。方法は
【Xcode8.3.1,swift3.1】異なるビュー間での値の受け渡し方法 その2(Segue使うよ) - 叉焼.logを参照。

iPhoneアプリ 〜実装〜

詳しくはgithubを見たまえ
GitHub - teru01/BLE-radio-controller: An iPhone app to send data to raspberry pi via Bluetooth Low Energy to handle a radio-controlled car.

これで終わりと言いたいところですがそんなことすると読者離れが深刻になりそうなので説明します。

ViewController.swift

import UIKit
import CoreBluetooth

class ViewController: UIViewController,CBCentralManagerDelegate,CBPeripheralDelegate{
    
    var myCentralManager: CBCentralManager!
    var myTargetPeripheral: CBPeripheral!
    var myTargetService: CBService!
    var myTargetCharacteristic: CBCharacteristic!
    let serviceUuids = [CBUUID(string: "abcd")]
    let characteristicUuids = [CBUUID(string: "12ab")]

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    //startボタンを押下した時の処理
    @IBAction func tapStartBtn(_ sender: Any) {
        self.myCentralManager = CBCentralManager(delegate: self, queue: nil, options: nil)
    }
    
    //CBCentralManagerDelegateプロトコルで指定されているメソッド
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        print("state:\(central.state)")
        switch central.state{
        case .poweredOff:
            print("Bluetooth-Off")
            //BluetoothがOffの時にアラートを出して知らせる
            let bleOffAlert=UIAlertController(title: "警告", message: "bluettothをONにしてください", preferredStyle: .alert)
            bleOffAlert.addAction(
                UIAlertAction(
                    title: "OK",
                    style: .default,
                    handler: nil
                )
            )
            self.present(bleOffAlert, animated: true, completion:nil )
        case .poweredOn:
            print("Bluetooth-On")
            //指定UUIDでPeripheralを検索する
            self.myCentralManager.scanForPeripherals(withServices: self.serviceUuids, options: nil)
        default:
            print("bluetoothが準備中又は無効")
        }
    }
    //peripheralが見つかると呼び出される。
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber)
    {
        self.myTargetPeripheral = peripheral
        //アラートを出してユーザーの接続許可を得る
        let bleOnAlert = UIAlertController(title: "Peripheralを発見",message: "接続します",preferredStyle:.alert)
        bleOnAlert.addAction(
            UIAlertAction(
                title: "OK",
                style: .default,
                //Peripheralへの接続命令
                handler: {(action)->Void in self.myCentralManager.connect(self.myTargetPeripheral, options: nil)}
            )
        )
        bleOnAlert.addAction(
            UIAlertAction(
                title: "cencel",
                style: UIAlertActionStyle.cancel,
                handler: {(action)->Void in
                    print("canceled")
                    self.myCentralManager.stopScan()}
            )
        )
        self.present(bleOnAlert, animated: true, completion: nil)
    }
    
    //Peripheralへの接続が成功した時呼ばれる
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("connected")
        peripheral.delegate = self
        //指定されたUUIDでサービスを検索
        peripheral.discoverServices(serviceUuids)
    }
    //サービスを検索した時に呼び出される
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        peripheral.delegate = self
        self.myTargetService = peripheral.services![0]
        //指定のUUIDでcharacteristicを検索する
        peripheral.discoverCharacteristics(characteristicUuids, for:self.myTargetService)
    }
    //characteristicを検索した時に呼び出される
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        if let e = error{
            print("error:\(e.localizedDescription)")
        }else{
            myTargetCharacteristic = service.characteristics![0]
            segueToSecondViewController()
        }
    }
    //segueを用いて次のビューへ遷移
    func segueToSecondViewController() {
        //次のビューへ渡すプロパティ
        let targetTuple = (myTargetPeripheral,myTargetCharacteristic)
        self.performSegue(withIdentifier: "mySegue", sender: targetTuple)
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "mySegue" {
            let secondViewController = segue.destination as! SecondViewController
            secondViewController.targetTuple = sender as! (CBPeripheral,CBCharacteristic)
        }
    }

}

    f:id:cha-shu00:20170503114255p:plain
はじめのビューはこんな感じです。シンプルすぎてもはやどこに画像の境界があるのかわかりませんが心眼を使って見てください。基本的にはフレームワークであるCoreBluetoothにしたがって記述していけばいいので特に問題はないです。
startボタンをタップするとこんな感じでアラートが出ます。
    
    f:id:cha-shu00:20170503114858p:plain

SecondViewController.swift

import UIKit
import CoreBluetooth
import CoreMotion

class SecondViewController: UIViewController,CBPeripheralDelegate{
    var targetTuple: (CBPeripheral,CBCharacteristic)!
    var myTargetPeripheral: CBPeripheral!
    var myTargetService: CBService!
    var myTargetCharacteristic: CBCharacteristic!
    var sliderValue: UInt8 = 4
    var angleValue: UInt8 = 70
    var centralData: Data!
    var tempPitch: Int!
    var prevPitch: Int = 40
    var tempSlider: Int = 4
    var prevSlider: Int = 4
    @IBOutlet weak var pitchLabel: UILabel!
    @IBOutlet weak var accelLabel: UILabel!
    @IBOutlet weak var mySlider: UISlider!{
        //スライダーを縦表示する
        didSet{
            mySlider.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi / -2))
        }
    }
    
    let cmManager = CMMotionManager()
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        myTargetPeripheral = targetTuple.0
        myTargetCharacteristic = targetTuple.1
        //モーションセンサが発行するキューの実行間隔(秒)
        cmManager.deviceMotionUpdateInterval = 0.3
        //キューで実行するクロージャを定義
        let handler: CMDeviceMotionHandler = {
            (motionData: CMDeviceMotion?,error: Error?) -> Void in self.motionAnimation(motionData,error)
        }
        //更新で実行するキューを登録してモーションセンサをスタート
        cmManager.startDeviceMotionUpdates(to: OperationQueue.main, withHandler: handler)

    }
    //クロージャの中で実行される
    func motionAnimation(_ motionData:CMDeviceMotion?,_ error:Error?){
        if let motion = motionData{
            //pitchはradで渡されるので度に変換
            var pitch = motion.attitude.pitch/Double.pi*180
            //pitchを-40から40に抑える
            pitch = (pitch < -40) ? -40 : pitch
            pitch = (pitch > 40) ? 40 : pitch
            var predif = 1000
            for i in 0..<40{
                let dif = abs((i*2)-Int(pitch+40))
                if predif-dif > 0{
                    predif = dif
                    tempPitch = i*2
                }
            }
            if (tempPitch != prevPitch){
                //データを送信
                pitchLabel.text = String(tempPitch-40)
                print(tempPitch)
                angleValue = UInt8(tempPitch)
                sendData(sliderValue,angleValue)
            }
            prevPitch = tempPitch
        }
    }
    //スライダーの変化を検知
    @IBAction func changeSlider(_ sender: UISlider) {
        tempSlider = Int(sender.value + 0.5)
        if tempSlider != prevSlider{
             accelLabel.text = String(tempSlider-4)
             print("slider:\(tempSlider)")
             sliderValue = UInt8(tempSlider)
             sendData(sliderValue,angleValue)
        }
        prevSlider = tempSlider
    }
    
    //データの送信用関数
    func sendData(_ senderSlider:UInt8,_ senderAngle:UInt8){
        if self.myTargetCharacteristic != nil{
            let uintAry = [senderSlider,senderAngle]
            centralData = Data(uintAry)
            myTargetPeripheral.writeValue(centralData, for: myTargetCharacteristic,type:CBCharacteristicWriteType.withResponse)
            print("complete")
        }
    }
}

こちらは少しややこしいですね。

pitchはiPhoneが横向きの時に画面の奥から手前方向を軸とした回転角を表します。iPhoneが縦向きの時となぜか軸の向きが変わってしまうようです。回転角を度数法に直したのち、for文の部分で0~80までの偶数でもっとも近い値に収まるようにしています。Sliderの部分でもそうですが、なるべくラズパイからArduinoにシリアルで送るデータが少なくなるように、送信するためのデータに変化があった時だけ送信命令を出すようにしています。

iPhone姿勢センサーの謎

f:id:cha-shu00:20170808082342j:plain
プロジェクト設定から General>Deployment Info>Device Orientationのところで、デバイスの向きを設定できます。上の画像はこの時に"Portrait"だけ、"Landscape Left"だけにチェックを入れた時に、roll,pitch,yowの右ねじの回転軸をあらわしています。これは私が実験して観測したもので、ネットで調べても日本語英語に関わらず似たような情報が発見できませんでした。公式AppleTwitterにクソな英語で問い合わせてみましたが「Developer Program(¥11800/年)に入ってから聞いてねっ❤️」と言われる始末(Appleさんごめんなさい)なのでなぜこのようなことが起きるのか、はたまた私の実験が悪かったのかは結局わからずでした。
一応、実験の方法と使ったソースコード載せておきます。方法としては上記のように、デバイス向き設定を変えてその度にiPhoneに書き込み、デバイスを3軸周りに回転させて対応するラベルの値の変化を見るといった具合です。使用環境等は前の記事(http://cha-shu00.hatenablog.com/entry/2017/04/25/195725)に書いてあります

import UIKit
import CoreMotion

class nextPageViewController: UIViewController {

    @IBOutlet weak var numLabel: UILabel!
    @IBOutlet weak var yawLabel: UILabel!
    @IBOutlet weak var pitchLabel: UILabel!  
    @IBOutlet weak var rollLabel: UILabel!

    //coreMotionマネージャを作る
    let cmManager=CMMotionManager()  
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //キューの実行間隔
        cmManager.deviceMotionUpdateInterval = 0.1
        //キューで実行するクロージャを定義
        let handler:CMDeviceMotionHandler = {(motionData:CMDeviceMotion?,error:Error?)->Void in self.motionAnimation(motionData,error)
        }
        //更新で実行するキューを登録してモーションセンサをスタート
        cmManager.startDeviceMotionUpdates(to: OperationQueue.main, withHandler: handler)
    }
    func motionAnimation(_ motionData:CMDeviceMotion?,_ error:Error?){
        if let motion = motionData{
            var yaw = motion.attitude.yaw
            yaw = round(yaw*100)/100
            yawLabel.text = String(yaw)
            var pitch = motion.attitude.pitch
            pitch = round(pitch*100)/100
            pitchLabel.text = String(pitch)
            var roll = motion.attitude.roll
            roll = round(roll*100)/100
            rollLabel.text = String(roll)
        }
    }
    

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

真相が分かる方コメントで教えてください・・・。