iPhoneで制御できるラジコン作った

こんにちは。またまた副部長の小野です。新歓も終わって落ち着いたので記事書きます。製作日記とかいうタグ付けですがこの記事で完結するので悪しからず。後輩の勉強用にでもなればいいかな程度の心持ちで書きます。いろんなところに記事書いてるので見たことある人いるかも。

目標

仕様

  • ラジコンの制御にはraspberry pi3 modelB+とArduino UNOを用い、それらのデバイス間でUSBによるシリアル通信を行う。
  • BLEのPeripheralにラズパイ、CentralにiPhone、ラジコンカーのモータードライバとサーボモーターのPWM制御にArduinoを用いる。
  • iPhoneのUIコンポーネントであるSliderでラジコンの前進、後退、速度を制御する。
  • ラジコンはサーボモーターによるステアリングを実装し、Wi○のマリ○カートのようにiPhoneを傾けるとそれに連動してラジコンの進行方向が変わるようにする。
  • ラズパイ側はNode.js、iPhone側はswift3、ArduinoはCっぽい専用言語で実装する。
  • 前輪のサーボはSG90、後輪モーター制御用のモータードライバにはL298を使用する。

ハードウェアとモジュールのバージョン

iPhone側:iOS 10.3, Xcode8.3.2, Swift3.1
ラズパイ側:Raspberry Pi3 modelB+, Raspbian Jessie Lite, node.js 7.6.0, serialport 4.0.7
Arduino側:Arduino UNO

ソースコード

raspberry pi側

以下のnodeモジュールを使用しました。ラズパイ側はBLEのPeripheralになります。

bleno : https://github.com/sandeepmistry/bleno

serialport : https://github.com/EmergingTechnologyAdvisors/node-serialport

main.js

var bleno = require('bleno');
var Characteristic = require('./Characteristic');
var serviceUuid = 'abcd';

/*stateChangeイベントの登録。接続状態が変化するとコールバック関数が呼び出される*/
bleno.on('stateChange',function(state){
    console.log('on ->stateChange:'+state);
    if(state === 'poweredOn'){
        //指定された名前とUUIDでアドバタイズを開始
        bleno.startAdvertising('led',[serviceUuid]);
    }else {
        bleno.stopAdvertising();
    }
});
/*advertisingStartイベントの登録。アドバタイズが始まるとコールバック関数が呼び出される*/
bleno.on('advertisingStart',function(error){
    if(!error){
        bleno.setServices([
                new bleno.PrimaryService({
                    uuid : serviceUuid,
                    characteristics : [new Characteristic()]
                })
            ]
        );
        console.log('on ->advertisingStart');
    }
});
Characteristic.js
var util = require('util');
var bleno = require('bleno');
var characteristicUuid = '12ab';
var SerialPort = require('serialport');
var port = new SerialPort('/dev/ttyACM0');
var flag = false;
/*Arduinoからの応答を受け取るdataイベントを登録*/
port.on('data',function(data){
    /*Arduinoを初期化する時、そのシリアルポートが準備されているか確認*/
    if(data.readInt8(0) == -1){
        flag = true;
        console.log("ready");
    }
    /*不正な値を検出して停止*/
    else if(data.readInt8(0) == -2){
        flag = false;
        console.log("abnormal stop");
    }
});
//Characteristicコンストラクタをオーバーライド
var Characteristic = function(){
    Characteristic.super_.call(this,{
        uuid : characteristicUuid,
        properties : ['write']
    });
};

util.inherits(Characteristic,bleno.Characteristic);

/*iPhoneからの書き込み命令があった時に呼び出される*/
Characteristic.prototype.onWriteRequest = function(data,offset,withoutResponse,callback){
    if(flag){
        port.write(data);
        console.log("sliderData:"+data.readInt8(0));
        console.log("angleData:"+data.readInt8(1));
        callback(this.RESULT_SUCCESS);
    }
};

module.exports=Characteristic;

Arduino側

BLEMotor.ino
#include 
#define FORWARD_PIN 3
#define BACKWARD_PIN 11
#define SERVO_PIN 6
Servo myservo;
byte dataAry[2];
int sliderData;
int angleData;
void setup() {
  //ピンを初期化
  analogWrite(FORWARD_PIN,0);
  analogWrite(BACKWARD_PIN,0);
  //DCモータ、サーボモータをそれぞれ中立の値に初期化。
  sliderData = 4;
  angleData = 40;
  Serial.begin(9600);
  myservo.attach(SERVO_PIN);
  //シリアルポートの準備ができるまで待機
  while(!Serial){}
  //準備完了時に-1を送信
  Serial.write(-1);
}
void loop() {}
//シリアルポートにデータがある時呼び出される。
void serialEvent(){
  while(Serial.available()){
    //dataAryの先頭2バイトにシリアルからのデータが読み込まれる。
    Serial.readBytes(dataAry,2);
    sliderData = dataAry[0];
    angleData = dataAry[1];
    //データが正しく受信できているかを確認
    if(sliderData >= 0 && sliderData <= 8 && angleData >= 0 && angleData <= 80){
      myservo.write(angleData);
      //進行、バック、停止の判断
      if(sliderData > 4){
        analogWrite(BACKWARD_PIN,0);
        analogWrite(FORWARD_PIN,(sliderData-4)*64-1);
      }else if(sliderData < 4){
        analogWrite(FORWARD_PIN,0);  
        analogWrite(BACKWARD_PIN,(4-sliderData)*64-1);
      }else{
        analogWrite(FORWARD_PIN,0);
        analogWrite(BACKWARD_PIN,0);
      }
    }
    //データが不正の時
    else{
      Serial.write(-2);
      Serial.flush();
    }
  }
}

iPhone側

データの送信頻度を少なくするために工夫してます。

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)
        }
    }

}
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
            //回転角を-40から40までの最も近い偶数に丸め,それに40足した値をPeripheralに送信する
            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) {
        let q = Int(sender.value)
        //tempSliderが0〜8までの値になるよう四捨五入する
        tempSlider = (sender.value-Float(q)) > 0.5 ? q+1 : q
        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")
        }
    }
    
    
}

StoryBoardも使っているのでgitHubの方も参考までに