スマホアプリで操作するクローラ式ラジコン

# はじめに
こんにちは、IT業界へ就職を目指している黒鉄と言います。
まずは、記事を開いていただきありがとうございます。
この記事の内容はポートフォリオを作成するという目的で作成されました。
作成内容は題名の通り、スマホアプリでクローラ式のラジコンを操作するというものです。
コードはGPTに生成させたものを元に、組み合わせと調整を行いながら実装しています。
初めての試みばかりで、至らない点が多々あると思いますが、最後まで見ていただければ幸いです。


# 目次
・[製作物の概要](#製作物の概要)
・[製作までの道のり](#製作までの道のり)
　-  [1.GPIOピンとLED](#1gpioピンとled)
　-  [2.モーターの制御](#2モーターの制御)
　-  [3.ウェブソケット通信](#3ウェブソケット通信)
　-  [4.アンドロイドスタジオでスマホアプリと通信](#4アンドロイドスタジオでスマホアプリと通信)
　-  [5.ラジコンの操作画面から制御指令を送る](#5ラジコンの操作画面から制御指令を送る)
　-  [6.ラズベリーパイゼロ側のサーバープログラム](#6ラズベリーパイゼロ側のサーバープログラム)
　-  [7.カメラ機能の搭載](#7カメラ機能の搭載)
　-  [8.完成の前に](#8完成の前に)
　-  [9.完成した後の振り返り](#9完成した後の振り返り)
 
## 製作物の概要 (#目次)
ラズベリーパイゼロWを利用して、クローラの制御を行います。
電源の供給はモーター用に乾電池4本（当初は2本）とラズベリーパイゼロW用にモバイルバッテリーを利用しています。
モータードライバーをDRV8832を2枚利用しています。
車体はタミヤのユニバーサルプレート、ギアボックスはタミヤのダブルギアボックス、クローラはタミヤのトラック＆ホイールセットを利用しています。
カメラはKEYESTUDIO 5MP カメラ モジュールを利用しています。
その他のボードやパーツは3Dプリンタを利用して作成しました。

![DSC_0086.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539975/64cf5be1-236a-d035-d8ae-560b3ca7153a.jpeg)
![DSC_0087.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539975/a8dcee3e-aa1e-3b9d-02b5-e367c238d77f.jpeg)

[目次へ戻る](#目次)

## 製作までの道のり
注意：これらはプロジェクトが完成後、別撮りしたものなので、初回の実験や学習とは違うものになります。

## 1.GPIOピンとLED
まずは初めに、ラズベリーパイ4でLEDを光らせます

<details><summary>ソースコード</summary>

```Python
import RPi.GPIO as GPIO
import time

GPIO.setmode(GPIO.BCM)
GPIO.setup(21, GPIO.OUT)

for i in range(1,6):
    GPIO.output(21,GPIO.HIGH)
    time.sleep(0.5)
    GPIO.output(21,GPIO.LOW)
    time.sleep(0.5)

GPIO.cleanup()
```

</details>

<iframe width="560" height="315" src="https://www.youtube.com/embed/Vqax-mIJwcw?si=nv4miRpSiephshC8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
0.5秒ごとに、点灯と消灯を5回繰り返します。

このプログラムで、GPIOの使い方を調べました。
GPIO.LOWとすると電力が供給されなくなります、モーターの正転逆転で利用します。
今回は利用しませんがGPIO.setup(21,GPIO.IN)状態を取得するような使い方もあります、センサー類から情報を取得する際はA/Dコンバーターが必要で、取得したデータの整形もセンサーによって変化するので、ここのハードルは高そうです。
[目次へ戻る](#目次)
## 2.モーターの制御

上手くはないですが、簡易的な配線図を以下の図にしました。
![DRV8832配線図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539975/4a052db3-60f2-9f67-fecc-4574df69fa0e.png)
DRV8832で検索をかけると、取扱説明書をPDFでアップしている通販サイトがありますので、必要な方はそちらをどうぞ。
<details><summary>DRV8832の配線について</summary>

OUT1とOUT2はモーターへ接続。

ISENSEは電流制御用のピンになります、抵抗を挟んでGNDに接続する事によって電流量を制限できるようです、今回は制限をおこなわないので、直接GNDに接続します。

VCCにはバッテリーからの＋極を接続。

GNDにはバッテリーのー極とラズパイのGNDを接続しています、ラズパイのGNDとは接続しなくても、制御はできましたが、ここは本来どうするべきなのか分からないので、間違っている可能性もあります。

FAULTnは異常が発生した時に通知するピンで、今回利用しないので無接続です。

VSETは、本来VREFから抵抗を挟んでVSETへ、VSETから抵抗を挟んでGNDに接続するもののようです、今回ここにPWM制御のピンを刺し、そこに流れる電圧でモーターの回転数を制御します。

VREFは、VSETに直接つなぐと5.14Vになるようになっているとのこと。

IN1とIN2はGPIOピンからのHIGHとLOWの組み合わせで正転逆転を行います。

モーターの配線にはコンデンサを繋げておかないと、PWMによる制御が行えなくなるので、ギアボックスのモーターにはコンデンサをはんだ付けする必要があります。

以上になります。
余談ですがVCCにラズパイから5V線を繋いで、電池が破裂するという事がありました、ラズパイが壊れなかったのが幸いでしたが、接続には気を付けましょう。
</details>

<details><summary>ソースコード</summary>

```Python
import RPi.GPIO as GPIO
import time

GPIO.setmode(GPIO.BCM)
GPIO.setup(20, GPIO.OUT)  # IN1
GPIO.setup(21, GPIO.OUT)  # IN2
GPIO.setup(13, GPIO.OUT) # PWM出力

pwm = GPIO.PWM(13,50)  #13番ピンを50Hzでセット
pwm.start(25)          #25%でPWM開始
try:
    GPIO.output(20, GPIO.HIGH)
    GPIO.output(21, GPIO.LOW)
    pwm.ChangeDutyCycle(35) #PWMを35%に変更
    time.sleep(5)
    pwm.ChangeDutyCycle(50) #PWMを50%に変更
    time.sleep(5)
    pwm.ChangeDutyCycle(75) #PWMを75%に変更
    time.sleep(5)
    pwm.ChangeDutyCycle(100) #PWMを100%に変更
    time.sleep(5)
except:
    print("error")

GPIO.cleanup()
```
</details>
5秒毎に35％50％75％100％と出力が上がっていきます、動画では見にくいので出力されている電圧を測りました、音は小さめですが、駆動音からも出力が上がっているのが確認できるかと思います。

.
<iframe width="560" height="315" src="https://www.youtube.com/embed/qDZkJVrxh1I?si=CRGJKI1FODtArOBp" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

[目次へ戻る](#目次)

## 3.ウェブソケット通信
ウェブソケット通信のテストを行います、初回はAndroidStudioがクライアントでしたが、今回はPyCharmをクライアントにしてテストを行います。
<details><summary>サーバー側のプログラム</summary>

```Python

import asyncio
import websockets

async def echo(websocket, path):
    async for message in websocket:
        # 受け取ったメッセージを表示
        print(f"Received message: {message}")
        # クライアントにエコーバック
        await websocket.send(message)  

async def main():
    # ホストとポートを指定
    server = await websockets.serve(echo, "192.168.0.17", 8080)  
    print("WebSocket server started on port 8080")
    await server.wait_closed()

# メインループを実行
asyncio.run(main())
```
</details>

<details><summary>クライアント側のプログラム</summary>

```Python

import asyncio
import websockets

async def connect(IP,Port):
    async with websockets.connect("ws://"+IP+":"+Port) as websocket:
        while True:#
            #コンソールに文字列を表示し、ユーザーの入力文字列のみをmessageに代入する
            message = input("Enter a message to send (exit to quit): ")
            #文字列を送信する
            await websocket.send(message)
            #受け取ったメッセージをresponseに代入
            response = await websocket.recv()
            #exitだった時ループを終了します
            if response == "exit":
                break
            #受け取ったメッセージを表示する
            print(f"Received response: {response}")

async def main():
    await connect("192.168.0.17","8080")
if __name__ == "__main__":
    asyncio.run(main())

# メインループを実行
asyncio.run(main())
```
</details>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539975/4dc093c5-0f03-5b0a-fcf9-a12fc0076fc6.png)
上記がクライアント側のコンソール画面です、「送信」を送ると「送信」が返ってきます、「exit」の場合は受信後プログラムを終了しているので返ってきた「exit」はコンソールに表示されません。
[目次へ戻る](#目次)

## 4.アンドロイドスタジオでスマホアプリと通信
これ以降は完成したプログラムを元に、必要な部分以外を削除して、何があったかなどを書いていきます。
なので、特にスマホ側のプログラムはそのままコピペでは動かない可能性が高いです。

ここでは、スマホからのウェブソケット通信を確立する方法を試しました。


<details><summary>メインアクティビティのコード</summary>

```kotlin

//シングルトン宣言として、ウェブソケットマネージャーを作成
object WebSocketManager{
    //シングルトンとして共有するOkHttpClientのインスタンス
    var webSocketClient : OkHttpClient? = OkHttpClient()
    // 受信したデータを保持する変数、終了コマンドを送信後にサーバーがちゃんと受け付けれたかを格納する変数
    private var receiveData: String? = null
    //ウェブソケットを作成、このウェブソケットを他のメソッドでも使う
    var webSocket : WebSocket? = null
    //アドレス、ポート番号。パス、リスナーを引数にウェブソケット接続を開始するメソッド
    fun connectWebSocket(address: String ,port: Int ,path: String, listener: WebSocketListener){
        // パスが空かどうかでURLを設定する
        val url = if (path.isNullOrEmpty()) {
            "ws://$address:$port"
        } else {
            "ws://$address:$port/$path"
        }
        val request = Request.Builder().url(url).build()
        //接続情報の入っているrequestと引数で受け取ったlistenerを引数に、ウェブソケット通信を確立する
        webSocket = webSocketClient?.newWebSocket(request,listener)
    }
 }

class MainActivity : AppCompatActivity() {


    //ウェブソケットマネージャーをonCreateメソッドの外で初期化
    private  val webSocketManager = WebSocketManager
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //初期画面にアドレス、ポート番号、パスを入力して、ボタンを押した時の処理用インスタンス
        val buttonStart = findViewById<Button>(R.id.button)
        //buttonStartにボタンのインスタンスを生成したので、それが押された時の処理を記述する
        buttonStart.setOnClickListener{
            //各テキストフィールドから入力値を取得
            val addressEditText = findViewById<EditText>(R.id.addressEditText)
            val portEditText = findViewById<EditText>(R.id.portEditText)
            val pathEditText = findViewById<EditText>(R.id.pathEditText)
            //取得した入力値をパース
            val address = addressEditText.text.toString()
            val portString = portEditText.text.toString()
            val path = pathEditText.text.toString()
            //ポート番号に番号以外が入っていた場合、80を入力する
            val port = try{
                portString.toInt()
            }catch (e: NumberFormatException){
                80
            }
            //startWebSocketConnectionメソッドを呼び出す
            startWebSocketConnection(address , port , path)
        }
    }

    //ウェブソケット通信に必要な情報が揃ったら、このメソッドに引数として渡し、ウェブソケットマネージャーで接続を開始する
    private fun startWebSocketConnection(address : String , port: Int , path: String){
        try {
            WebSocketManager.connectWebSocket(address, port, path, object : WebSocketListener() {
                override fun onOpen(webSocket: WebSocket, response: Response) {
                    runOnUiThread {
                        //接続後コントロールパネルへ画面遷移する
                        val intent = Intent(this@MainActivity, ControllPanelActivity::class.java)
                        startActivity(intent)
                    }
                }

                override fun onMessage(webSocket: WebSocket, text: String) {
                    //テキストデータを受信した時の処理を記述
                }

                override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                    //ここはウェブソケット通信が確立できなかった時の処理を記述
                }
            })
        }catch(e : java.net.ConnectException){
            //接続に関して例外をスローした時の処理
        }catch (e : Exception){
            //コネクト以外の例外が発生した時の処理
        }
    }
}



```
</details>


<details><summary>ラズパイ側のサーバープログラム</summary>

```Python    

async def echo(websocket, path,pwm1,pwm2):
    async for message in websocket:      
        command = message.split(" ")
        print(message)
        if command[0] == "exit":
            await websocket.send("Roger")
            await websocket.close()
            if websocket.open:
                print("websocket open")
            else:
                print("websokcet closed") 

async def main():
    
    echo_with_pwm = functools.partial(echo)
 
    
    server = await websockets.serve(echo_with_pwm,"192.168.0.100", 8080)  # ホストとポートを指定
    print("WebSocket server started")
    try:
        await server.wait_closed()
    finally:
        GPIO.cleanup()
    
if __name__ == "__main__":
    # メインループを実行
    asyncio.run(main())
```
</details>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539975/edb29684-7c56-088f-578a-dd20b7910f47.png)
これが最初に出てくるスマホ画面です、左からIPアドレス・ポート番号・パスになっています。
パスは同じ接続先で分岐させるために利用するようですが、今回はそういうことはしないので、不要な場合は空白のままです、今回のプロジェクトでは項目自体を作る必要はなかったですね。

流れとしては、スマホ画面の入力値を受け取って、各変数に代入後、関数に引数として渡して、ウェブソケットマネージャの接続関数でウェブソケット通信を確立するという形です、object WebSocketManagerとすることで、画面遷移した先でも利用できるようになります。

<details><summary>スマホから接続する時の注意</summary>
この段階ではアンドロイドスタジオのエミュレータから接続しているので、問題はないですが、スマホから接続する際はAndroidManifest.xmlに「android:usesCleartextTraffic="true"」を追加しないとHTTP通信を拒否されてしまうので、注意が必要となります。
HTTPS通信を行うには、証明書を用意、それを読み込んで、設定した後、それをもとにウェブソケット通信を行うという面倒な手順なので、今回のようなインターネット上を介さない操作や映像ならば、特に必要性はないでしょう。
</details>

[目次へ戻る](#目次)

## 5.ラジコンの操作画面から制御指令を送る
以下操作画面になります、完成後の画面のためカメラOFFボタンが付いていますが、この時点ではまだカメラ機能については実装していませんでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539975/6097a1e6-2248-d117-649b-932cc19c4587.png)
左右のシークバーで左と右のモーターを制御します、シークバーにはバナー作成ソフトで作成した画像を表示させてあります。
<details><summary>コントローラーのコード</summary>

```Kotlin

class ControllPanelActivity : AppCompatActivity(){
    //シークバーのハンドラー宣言
    private val handlerL = Handler(Looper.getMainLooper())
    private val handlerR = Handler(Looper.getMainLooper())
    //シークバー定期送信用のインスタンス
    private lateinit var sendSeekBarValueRunnableL:Runnable
    private lateinit var sendSeekBarValueRunnableR:Runnable
    //シングルトンで定義したウェブソケット
    private val webSocketManager = WebSocketManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //onClickで呼び出される画面を指定
        setContentView(R.layout.controllpanel)
        //左右のシークバーをインスタンス化
        val seekBarL = findViewById<SeekBar>(R.id.seekBarLeft)
        val seekBarR = findViewById<SeekBar>(R.id.seekBarRight)
        //シークバーの最大値を設定
        seekBarL.max = 260
        seekBarR.max = 260
        //シークバーの初期位置を真ん中にする
        seekBarL.progress = 130
        seekBarR.progress = 130
        //現在のシークバーから値を取得し、それを入れて置く変数
        var progressL = 0;
        var progressR = 0;
        //PWM用の数値を保存する変数
        var pwmL = 0;
        var pwmR = 0;
        //送信する文字列
        var sendStrL = "";
        var sendStrR = "";


        //左シークバーの定期送信メソッド
        sendSeekBarValueRunnableL = object : Runnable{
            override fun run(){
                //シークバーの数値をそのまま代入
                progressL = seekBarL.progress
                //シークバーが130以上の場合、正転とし、１３０を引いた数値を送信する
                if(progressL >= 130){
                    pwmL = progressL -130
                    //シークバーから取得した値から130を引いたものと、正転用の数値と左右判定用の 1 0 Lを結合する
                    sendStrL = "$pwmL 1 0 L"
                    //センドコマンドでsenStrLを送信する
                    WebSocketManager.sendCommand(sendStrL)
                }else{
                    //後進時には130から値が減っていくので、130を引いたものに-を取り除いて格納している
                    pwmL = Math.abs((progressL -130))
                    //シークバーから取得した値から30を引いたものと、逆転用の数値と左右判定用の 0 1 Lを結合する
                    sendStrL = "$pwmL 0 1 L"
                    //センドコマンドでsenStrLを送信する
                    WebSocketManager.sendCommand(sendStrL)
                }

                //送信間隔を１００ミリ秒にする
                handlerL.postDelayed(this,100)
            }
        }
        //右シークバーの定期送信メソッド
        sendSeekBarValueRunnableR = object : Runnable{
            override fun run(){
                //シークバーの数値をそのまま代入
                progressR = seekBarR.progress
                //シークバーが130以上の場合、正転とし、１３０を引いた数値を送信する
                if(progressR >= 130){
                    pwmR = progressR -130
                    //シークバーから取得した値から130を引いたものと、正転用の数値と左右判定用の 1 0 Rを結合する
                    sendStrR = "$pwmR 1 0 R"
                    //センドコマンドでsenStrLを送信する
                    WebSocketManager.sendCommand(sendStrR)
                }else{
                    //後進時には130から値が減っていくので、130を引いたものに-を取り除いて格納している
                    pwmR = Math.abs((progressR -130))
                    //シークバーから取得した値から30を引いたものと、逆転用の数値と左右判定用の 0 1 Rを結合する
                    sendStrR = "$pwmR 0 1 R"
                    //センドコマンドでsenStrLを送信する
                    WebSocketManager.sendCommand(sendStrR)
                }
                //送信間隔を１００ミリ秒にする
                handlerR.postDelayed(this,100)
            }
        }

        //左シークバーの設定
        seekBarL.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
            override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
                //上下に空白を取るために30の間をとっている
                if (fromUser) {
                    if (progress < 30) {
                        seekBar?.progress = 30
                    }
                    if (progress > 230) {
                        seekBar?.progress = 230
                    }
                }
            }
            //タッチ中は現在の値を送信し続ける
            override fun onStartTrackingTouch(seekBar: SeekBar?) {
                handlerL.post(sendSeekBarValueRunnableL)
            }
            //指を離した時は送信処理を止め、シークバーを真ん中に戻す
            override fun onStopTrackingTouch(seekBar: SeekBar?) {
                handlerL.removeCallbacks((sendSeekBarValueRunnableL))
                seekBarL.progress = 130
                WebSocketManager.sendCommand("0 0 0 L")
            }
        })

        //右シークバーの設定
        seekBarR.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
            override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
                if (fromUser) {
                    if (progress < 30) {
                        seekBar?.progress = 30
                    }
                    if (progress > 230) {
                        seekBar?.progress = 230
                    }
                }
            }

            //タッチ中は現在の値を送信し続ける
            override fun onStartTrackingTouch(seekBar: SeekBar?) {
                handlerR.post(sendSeekBarValueRunnableR)
            }
            //指を離した時は送信処理を止め、シークバーを真ん中に戻す
            override fun onStopTrackingTouch(seekBar: SeekBar?) {
                handlerR.removeCallbacks((sendSeekBarValueRunnableR))
                seekBarR.progress = 130
                WebSocketManager.sendCommand("0 0 0 R")
            }
        })
    }

}

```
</details>

シークバーの値は最大値260で30～260の範囲で動かせるようになっています、端まで動かすと画像がめり込んでしまい直せなかったのでこのようにしています。
制御指令の間隔は０.１秒毎に送信しています、送られる制御指令は デューティ比・IN1信号・IN2信号・左右 になっています、デューティ比は出力、IN1とIN2はモーターの正転逆転、左右はモーターの左右となっています。
4項目ありますが実際はIN1のみで正転逆転の制御ができるので、3項目で良かったと気づいたのはラズベリーパイ側のプログラムを作ってからでした、なので無駄ではありますが、このまま利用しています。

シークバーはタッチしている間は制御指令を送信し続け、指を離すと130の位置（中央）に戻りデューティ比0を送信してから送信を終了します。

[目次へ戻る](#目次)


## 6.ラズベリーパイゼロ側のサーバープログラム

<details><summary>サーバー側コード</summary>

```Python

def cleanup_gpio():
    # GPIOピンをクリーンアップ
    GPIO.cleanup()

async def echo(websocket, path,pwm1,pwm2):
    async for message in websocket:      
        command = message.split(" ")
        print(message)
        if command[0] == "exit":
            await websocket.send("Roger")
            await websocket.close()
        elif command[0] == "Live":
            await websocket.send("Lived")                               
        elif command[0] != "exit":
            if command[3] == "L":
                pwm1.ChangeDutyCycle(int(command[0]))
                if(command[1] == "1"):
                    GPIO.output(5,GPIO.HIGH)
                    GPIO.output(6,GPIO.LOW)
                else:
                    GPIO.output(5,GPIO.LOW)
                    GPIO.output(6,GPIO.HIGH)
            elif command[3] == "R":
                pwm2.ChangeDutyCycle(int(command[0]))
                if(command[1] == "1"):
                    GPIO.output(20,GPIO.HIGH)
                    GPIO.output(21,GPIO.LOW)
                else:
                    GPIO.output(20,GPIO.LOW)
                    GPIO.output(21,GPIO.HIGH)    

def setup_gpio():
    #GPIO set
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(13, GPIO.OUT)#PWM
    GPIO.setup(19, GPIO.OUT)#PWM
    # 利用するGPIOの初期設定、HIGH/LOW用
    GPIO.setup(5, GPIO.OUT)  
    GPIO.setup(6, GPIO.OUT)  
    GPIO.setup(20, GPIO.OUT)  
    GPIO.setup(21, GPIO.OUT)  
    # PWMの初期設定、１０ヘルツにする
    pwm1 = GPIO.PWM(13,10)
    pwm2 = GPIO.PWM(19,10)
    # PWMのスタート、コントローラーから来た数値を利用するので初期値は０にする
    pwm1.start(0)
    pwm2.start(0)
    
    return pwm1,pwm2

async def main():
    pwm1 , pwm2 = setup_gpio()
    echo_with_pwm = functools.partial(echo, pwm1=pwm1, pwm2=pwm2)
 
    server = await websockets.serve(echo_with_pwm,"192.168.0.100", 8080)  # ホストとポートを指定
    print("WebSocket server started")
    try:
        await server.wait_closed()
    finally:
        GPIO.cleanup()
        # プログラム終了時にcleanup_gpio()関数を実行
        atexit.register(cleanup_gpio)
       # os.system("sudo shutdown now")
    
if __name__ == "__main__":
    # メインループを実行
    asyncio.run(main())

```
</details>

デューティ比 IN1 IN2 左右 の4つのパラメータが0.1秒間隔で送信され、それを受信する様子を画像にしたのが以下の画像です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539975/83726695-49d7-0095-73ca-fa8737a5c17f.png)
コード自体は、スペース区切りで送られてくる指令を、スペースでスプリットし配列に代入してあります、先頭行が文字列の場合「exit」「Lived」「それ以外」で分岐され、exitは終了、Livedはサーバーが動いているかの応答、それ以外はデューティ比なのでデューティサイクルへ分岐します。
サーバーの稼働状況を得るためのLivedは、もっとふさわしいコマンド名があると思いますが、作成者の英語能力的に限界でした、単語的に合ってない可能性もありますね。

制御指令はcommand[3]の左右を判定してから、command[0]のデューティ比をPWMへ代入後、command[1]で正転逆転を判定しています。

exitは本来であれば、サーバーを終了しGPIOピンを解放後、プログラムとラズベリーパイゼロ自体を終了する予定でしたが、ウェブソケットクローズ後終了に進まないという問題が発生したため、想定と違う動作を行っています。
とりあえずは動いているのと、実際に出来上がると、毎回ラズベリーパイゼロ自体を終了させる必要はないということになりましたので、OSを終了する行はコメントアウトしたままの状態にしてあります。
[目次へ戻る](#目次)


## 7.カメラ機能の搭載

<details><summary>シングルトン追記コード</summary>

```Kotlin
    var videoDataReceivedListener: ((ByteArray) -> Unit)? = null

    fun setOnVideoDataReceivedListener(listener: (ByteArray) -> Unit) {
        videoDataReceivedListener = listener
    }

    fun decodeVideoData(text: ByteArray): ByteArray {
        return android.util.Base64.decode(text, android.util.Base64.DEFAULT)
    }

```

</details>
ビデオ用リスナーとBase64でエンコードされたバイナリデータをデコードして返す関数。


<details><summary>onOpen追記コード</summary>

```Kotlin
                override fun onMessage(webSocket: WebSocket, data: ByteString) {
                    val videoData = webSocketManager.decodeVideoData(data.toByteArray())
                    webSocketManager.videoDataReceivedListener?.invoke(videoData)
                }
```
</details>
onMessageをByteStringの引数にすると、バイナリデータは自動的にこちらで処理されるようになり、エンコードされたバイナリデータをデコードし、ビデオリスナーにビデオデータを渡しています。

<details><summary>コントローラーアクティビティ追記コード</summary>

```Kotlin
        WebSocketManager.setOnVideoDataReceivedListener { videoData ->
            runOnUiThread {
                val decodedBitmap = BitmapFactory.decodeByteArray(videoData, 0, videoData.size)
                videoImageView.setImageBitmap(decodedBitmap)
            }
        }

        cameraSwitch.setOnClickListener {
            //現在がオフなら、ONのコマンドを送信し、表示をONにする
            if(cameraSwitch.text.equals("カメラOFF")){
                webSocketManager.sendCommand("ON")
                cameraSwitch.text = "カメラON"

            //現在がオンなら、OFFのコマンドを送信し、表示をOFFにする
            }else if(cameraSwitch.text.equals("カメラON")){
                webSocketManager.sendCommand("OFF")
                cameraSwitch.text = "カメラOFF"
            }
        }
```
</details>
シングルトンで保持されているバイナリデータを取得し、ビットマップ型にデコードしてから、ビデオイメージビューに表示する。
カメラのON/OFFを変更と送信する関数、OFFが送信されると映像データの送信が止まり、ビデオイメージビューには最後に映った画像が表示されるだけとなる。

<details><summary>ラズベリーパイゼロ側のサーバー追記</summary>

```Python
class VideoFlag:
    def __init__(self):
        self.send_video = False
vf=VideoFlag()

def setup_camera():
    camera = picamera.PiCamera()
    camera.resolution =(480,360)
    camera.vflip = True
    camera.hflip = True
    return camera

async def start_camera_stream(camera, websocket , send_video):
    
    while vf.send_video:
        frame_stream = io.BytesIO()
        camera.capture(frame_stream, format='jpeg',use_video_port=True)
        frame_stream.seek(0)
        send_frame = frame_stream.read()
        await websocket.send(base64.b64encode(send_frame))
        frame_stream.seek(0)
        frame_stream.truncate()
        await asyncio.sleep(0.03)

async def echo(websocket, path,pwm1,pwm2, camera):
        elif command[0] == "ON":
            vf.send_video= True
            asyncio.create_task(start_camera_stream(camera,websocket,vf.send_video))
        elif command[0] == "OFF":
            vf.send_video = False


```
</details>
VideoFlagが映像データの送信を判定するためのフラグになる。
setup_cameraではカメラの初期設定を行っているが、flipで上下左右を反転させている、理由としては構造上カメラが逆さに取り付けられてしまったため。
start_camera_streamではビデオフラグがTrueの限りストリームを読み込み、エンコードして送信、ストリームの切りつめを0.03秒毎に行っている、映像は約33フレームとなり、30フレーム程度に収めたかったのでこのようになっているが、実際はラズベリーパイゼロの性能的に30フレームも送信されない。
echoの引数にcameraを追加し、ON/OFFのコマンドが来た時にフラグを変更するように追記。
<iframe width="560" height="315" src="https://www.youtube.com/embed/-R-T5lHmZfk?si=ytfG8ISsO5xb2jHl" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

カメラ映像の受信と、簡単な操作動画です。
モータードライバーとの接続が不良気味のようで、右側が動作が遅かったりします。

DRV8832を２枚利用しているので、左右の差異が影響しやすくなります、またDRV8833を利用した場合、DRV8832より消費電力が40％ほど高くなるものの、１枚で済むため消費電力も抑えられます。
DRV8833であれば配線量も少なくなり、設置場所の確保も容易になるのでクローラ方式なら１枚で2つのモーターが制御できる物を選定した方が良いですね。

[目次へ戻る](#目次)

## 8.完成の前に

写真や動画などはないのですが、ここからは文章のみで何をしたのかだけ紹介します。

まず、ラズベリーパイゼロの通常OS（デスクトップ版）では動作が重かったため、Lite版に入れ替えてあります。
Lite版の設定として
1.モニターなしでも起動するよう設定
2.指定のWi-Fiに接続するように設定
3.サーバープログラムの自動起動
を行っています。
サーバープログラム自体にも指定のWi-Fiに接続されるまで待機するという項目も追加しています、こうしておかないと起動したタイミングではまだWi-Fiに接続していないため、IPアドレスを指定してサーバープログラムを実行した際にエラーで終了してしまいます。

以下に今回作成したプログラムをGitHubに上げてます。
・[サーバー側のプログラム](https://github.com/kurogane131/Server)
・[スマホアプリのプログラム](https://github.com/kurogane131/Controller)
以下は今回利用した3Dプリンタのデータです
・[作成した3Dデータリンク](https://www.tinkercad.com/things/2AYFALIpBTL?sharecode=yl0kZACkx5EwrJhKZUjjjxyLXXlkugOndDpqdsuelfQ)
利用については自由にしていただいて構いませんが、私こと黒鉄は如何なる損失または利益についても責任も権利もありませんので、全て自己責任でお願いします。


[目次へ戻る](#目次)

## 9.完成した後の振り返り
問題点は沢山ありますが、まずはソフトウェア面での問題点や改善点から
<details><summary>ソフトウェア面の問題点と改善点</summary>

スマホアプリ側
・一度接続後、切断し再接続するとアプリが落ちる
　おそらく、シングルトンで作成したインスタンスを、もう一度作成しようとしていることでアプリが停止していると思うのだが、解決には至らなかった。

・シングルトンで作成するインスタンスの変数などについて
　カプセル化の観点から、シングルトンで保持するフィールド変数は全てprivateとし、セッターゲッターを利用してデータを管理するようにすべきだった。

・シングルトンの内容自体をクラス化し、委譲という形にしても良かったかもしれない
　関数が増えた事によって、メインアクティビティの記述数を圧迫するので、これは別でクラス化しておいた方がよかったかもしれない、今後新たなラジコンを作成する際、再利用する可能性があるので。

・映像処理に関して
　シングルトン内でデコード処理を記述したが、メインアクティビティでデコードしたものをシングルトンにセットする形の方がよかったかもしれない。

・コントローラーについて
　シークバーに画像を利用して、今回操作パネルを作成したが、シークバーなどを利用せず画像を表示して、その移動量から操作するようにした方がスムーズだったかもしれない。

ラズベリーパイゼロ側
・メインファイルが一つだけ
　作成した関数は量が多いので、別ファイルにクラスとして作成しておいた方が見やすくていいかもしれない。

・サーバーのループが終了しない
　ラズベリーパイの方では、ウェブソケット通信がが終了した後にプログラムが終了するようにできたが、ゼロに搭載してからはウェブソケット通信が終了したところで次のステップに進まないという問題が発生した、ウェブソケットクローズ後にOSを終了するような記述でも問題なかったかもしれないが、ここは今回一番謎かもしれません。

・OpenCVを利用した方がよかったか？
　今回カメラ映像の滑らかさが足りなかったので、Lite版にしましたが、それでも求めるレベルのフレームレートまで上がらなかった、OpenCVも試してみたものの、こちらは映像が取得できず今回は見送りに、OpenCVが利用できれば改善するのか、スペック的に限界なのかは確かめておきたかったですね。



</details>

<details><summary>ハードウェア面の問題点と改善点</summary>

・モータードライバーを二つ利用すると乾電池２本では足りない
　急遽二つめの電池ボックスを並列に繋げて動作するようにしました、なので電池の位置が上下に分かれてしまっています。
　これはモータードライバーを通すと3.1Vが2.5Vまで下がってしまうので、モータードライバー自体の消費電力が、乾電池からすると大きすぎたようです。

・モータードライバーで電圧を降下できる
　PWMで電圧を下げる事が出来るので、3Vのモーターでもモバイルバッテリーから5Vを受け取り、PWMで3Vくらいに落とすことで、モバイルバッテリー単体で動作できるはずです、その場合モーター始動時の電圧降下でラズベリーパイゼロが停止しないように、USBが二口あるタイプが必要になります。

・上下に分ける時に使った支柱について
　支柱だけに限らず、ナットやネジの取り付けスペースを考えてなかったため、取り付けるのが非常に大変になりました、これは今後取り付けるためにモデルの構造を考え直さないといけませんね。

・クローラの接地面が少ない
　地面側のローラー間隔が短いので、駆動輪に近い所にローラーを設置できるようにプリンタでモデルを作成した方がよかったですね、付属品だけで取り付けたのは少々失敗でした。

・クローラの張力調整ができない
　駆動輪反対側のサポート輪を、ボードの穴同士を繋げて切り取り、位置調整できるようにすればよかったと完成後に気づきました。

・各パーツのネジ位置について
　ラズベリーパイゼロの土台などは、その周りにネジ穴を作ったので、ラズベリーパイゼロより一回り大きな面積を占領してしまう、ネジ止め位置とラズベリーパイゼロを２段に重ね、横や縦にではなく高さに伸ばすようにすればもう少し配置もやりやすく、大きなモバイルバッテリーも選定出来たかもしれない。

・カメラ位置について
　前に出過ぎてしまい、操作画面で現在位置が確認しづらくなってしまった、もう少し後ろでかつ、配線の取り回しも綺麗な作りにしたい。

</details>

この次は、今回の反省や知識を生かして、ESP32-CAMを利用したラジコンを作成したいと思います、クローラ部分も3Dプリンタで作成し、今回の物より大きな物を作成したいと考えています。
ですが、次はJavaの資格を取得してからになる予定です、そちらの学習も進めないといけませんので。

[目次へ戻る](#目次)