iOS

Swiftでドローンを飛ばしてみる

swift-drone

Swiftでドローンを飛ばしてみる

この記事はSwift愛好会vol49@JapanTaxiでの発表の詳細の記事です。 発表のスライドはこちらです。 上記のスライドにもあるドローンを使ったエンタメに興味をもち、また愛好会の発表のネタにもなりそうだったので、Telloのドローンを買って、遊んでみました。

仕組み

下の図のように、iPhoneなどの端末とドローンをUDP通信で接続します。その後指定のコマンドを送ることで、ドローンがそのコマンド通りに動いてくれます。

実装

@eito_2さんこちらの記事を参考にし、Network FrameworkのNWNetworkを使用して、実装しました。WWDC2019でそれに関する動画がありましたので、興味のある方はどうぞ。 まずはドローンのアドレス/ポート情報をまとめた構造体とコマンド一覧を定義したenumを準備します。コマンドは一部しか定義していないので、利用可能なコマンド一覧をみたい方は、SDK2.0 User Guideをご確認ください。

struct TelloConstants {
    static let ipAddress = "192.168.10.1"
    static let port: UInt16 = 8889
}

enum TelloCommands {
    case start
    case takeoff
    case land
    case emergency
    // Min: 20, Max: 500(cm)
    case left(x: Int)
    case right(x: Int)
    case forward(x: Int)
    case back(x: Int)
    case up(x: Int)
    case down(x: Int)

    // Min: 1, Max: 360(degree)
    case rotateRight(x: Int)
    case rotateLeft(x: Int)

    case flip(direction: TelloDirection)
}

extension TelloCommands {
    var asString: String {
        switch self {
        case .start: return "command"
        case .land: return "land"
        case .takeoff: return "takeoff"
        case .emergency: return "emergency"
        case .left(let x): return "left \(x)"
        case .right(let x): return "right \(x)"
        case .forward(let x): return "forward \(x)"
        case .back(let x): return "back \(x)"
        case .up(let x): return "up \(x)"
        case .down(let x): return "down \(x)"
        case .rotateRight(let x): return "cw \(x)"
        case .rotateLeft(let x): return "ccw \(x)"
        case .flip(let direction): return "flip \(direction.asString)"
        }
    }
}

enum TelloDirection {
    case left
    case right
    case forward
    case back

    var asString: String {
        switch self {
        case .left: return "l"
        case .right: return "r"
        case .forward: return "f"
        case .back: return "b"
        }
    }
}

次にドローンとの接続と実際にコマンドを送信する実装です。 init時に、stateUpdateHandlerにクロージャーをセットすることで、ドローンとの接続状況を確認できるようにしています。 またメソッドとしては、以下の3つを用意しました。

  1. コマンドのenumを受け取り、コマンド実行後のレスポンスを受信後、指定のクロージャにResult型で結果を通知する。
  2. コマンドを受け取り、その実行結果をSingleで返す(1のラッパーメソッド)。
  3. コマンドの配列とコマンド実行間隔の秒数を受け取り、そのコマンドを順に実行し、その実行結果をObservableで返す。
class TelloManager {
    let connection = NWConnection(host: .init(TelloConstants.ipAddress), port: .init(integerLiteral: TelloConstants.port), using: .udp)

    init() {
        connection.stateUpdateHandler = { [unowned self] state in
            switch state {
            case .setup:
                print("Setup")
            case .waiting(let error):
                print(error)
            case .preparing:
                print("preparing")
            case .ready:
                self.connection.start(queue: .global())
                print("ready")
            case .failed(let error):
                print(error)
            case .cancelled:
                print("cancelled")
            @unknown default:
                fatalError("Unknown error")
            }
        }
    }

  //1.コマンドのenumを受け取り、コマンド実行後のレスポンスを受信後、指定のクロージャにResult型で結果を通知する。
    func send(command: TelloCommands, completion: @escaping ((Result<String, Error>) -> Void)) {
        print("Commands: ", command.asString)
        let message = command.asString.data(using: .utf8)
        connection.send(content: message, contentContext: .defaultMessage, isComplete: true, completion: .contentProcessed({ error in
            if let error = error {
                completion(.failure(error))
            }
        }))
        connection.receive(minimumIncompleteLength: 0, maximumLength: Int(Int32.max)) { (data, context, isComplete, error) in
            if !isComplete, let error = error {
                completion(.failure(error))
            } else if let data = data, let message = String(data: data, encoding: .utf8) {
                completion(.success(message))
            } else {
                print("Unknown error")
            }
        }
    }

    //2.コマンドを受け取り、その実行結果をSingleで返す(1のラッパーメソッド)。
    func send(command: TelloCommands) -> Single<String> {
        return Single<String>.create { [unowned self] single -> Disposable in
            self.send(command: command) { result in
                switch result {
                case .success(let message):
                    single(.success(message))
                case .failure(let error):
                    single(.error(error))
                }
            }
            return Disposables.create()
        }
    }

    //3.コマンドの配列とコマンド実行間隔の秒数を受け取り、そのコマンドを順に実行し、その実行結果をObservableで返す。
    func sends(parameters: (commands: [TelloCommands], interval: Int)) -> Observable<String> {
        let commandsObservables = parameters.commands.map(send)
            .map({ $0.asObservable().delay(.seconds(parameters.interval), scheduler: MainScheduler.instance) })
        return Observable.concat(commandsObservables)
    }
}

ViewControllerの実装は以下の通りです。 このドローンの制約上、最初にcommandというコマンドを最初に送信しないといけないので、setupというボタンをタップしてから、その後の操作に入るようになっています。 その後takeoffボタンをタップしたら離陸、landボタンを押したら着陸、 pattern1を押したら、2秒間隔で「離陸→60cm上昇→30cm下降→左に100cm移動→右に100cm移動→前にフリップ→後ろにフリップ→着陸」が実行されます。 ちなみにフリップはこんな動きをします。

class ViewController: UIViewController {

    @IBOutlet weak var setupButton: UIButton!
    @IBOutlet weak var takeOffButton: UIButton!
    @IBOutlet weak var landButton: UIButton!
    @IBOutlet weak var rightButton: UIButton!
    @IBOutlet weak var leftButton: UIButton!
    @IBOutlet weak var pattern1Button: UIButton!

    private let resultRelay = PublishRelay<String>()
    private let disposeBag = DisposeBag()
    private let tello = TelloManager()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupButton.rx.tap.throttle(.seconds(1), scheduler: MainScheduler.instance)
            .map({ _ in .start })
            .flatMap(tello.send)
            .bind(to: resultRelay)
            .disposed(by: disposeBag)
        takeOffButton.rx.tap.throttle(.seconds(1), scheduler: MainScheduler.instance)
            .map({ _ in .takeoff })
            .flatMap(tello.send)
            .bind(to: resultRelay)
            .disposed(by: disposeBag)
        landButton.rx.tap.throttle(.seconds(1), scheduler: MainScheduler.instance)
            .map({ _ in .land })
            .flatMap(tello.send)
            .bind(to: resultRelay)
            .disposed(by: disposeBag)
        rightButton.rx.tap.throttle(.seconds(1), scheduler: MainScheduler.instance)
            .map({ _ in .right(x: 100) })
            .flatMap(tello.send)
            .bind(to: resultRelay)
            .disposed(by: disposeBag)
        leftButton.rx.tap.throttle(.seconds(1), scheduler: MainScheduler.instance)
            .map({ _ in .left(x: 100) })
            .flatMap(tello.send)
            .bind(to: resultRelay)
            .disposed(by: disposeBag)
        pattern1Button.rx.tap.throttle(.seconds(1), scheduler: MainScheduler.instance)
            .map({ _ -> [TelloCommands] in [.takeoff, .up(x: 60), .down(x: 30), .left(x: 100), .right(x: 100), .flip(direction: .forward), .flip(direction: .back), .land] })
            .map({ ($0, 2) })
            .flatMap(tello.sends)
            .bind(to: resultRelay)
            .disposed(by: disposeBag)
        resultRelay.asObservable()
            .subscribe(onNext: { message in
                print("Message: ", message)
            }, onError: { (error) in
                print("Error: ", error)
            })
            .disposed(by: disposeBag)
    }
}

まとめ

普段はモバイル内のみを動かすことが多いSwiftですが、ドローンを動かせたのは新鮮でした。 Try!Swift2019ではSwiftでラズパイを動かす発表もあったので、いずれそちらも触ってみたいです。 またこのドローンはカメラの情報も受け取れる仕組みになっているので、その情報を受け取って、写真や動画の撮影できるようにするのも、いずれ実装したいです(今回は時間がなくて、断念しました)。 ソースコードはこちらです。

参考

https://www.ryzerobotics.com/jp/tellohttps://qiita.com/eito_2/items/0d9e2c92b0be0ea16e77https://dl-cdn.ryzerobotics.com/downloads/Tello/Tello%20SDK%202.0%20User%20Guide.pdf

0

COMMENT

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA