koktoh の雑記帳

気ままに書いていきます

RaspberryPi Zero W でラジコン作った

はじめに

ずっと我が家で埃をかぶっていた Raspberry Pi Zero W で、突然ラジコン作りたくなったのでつくりました

これはその備忘録的なやつです

完成品

どんなことができるの?

  • Xbox コントローラーで操作するラジコン
  • カメラの映像をリアルタイムでストリーミングして見れる(レイテンシはある)
  • ライトが点いて暗いところでも走れる(気がする)

使ったもの

名前 説明 参考リンクなど
ツイストクローラー工作セット(2chリモコン) ベースとなる筐体 (本体が激しく上下に振れるのでカメラ搭載にはあんまり向かなかった) タミヤ公式
連結式クローラー&スプロケットセット 樹脂製のクローラーから置き換え(使わなくてもいい) タミヤ公式
ユニバーサルプレートセット Raspberry Pi やカメラを固定する タミヤ公式
SG90 用カメラパンチルトキット うちに転がってたやつ アマゾン検索結果
Raspberry Pi Zero W うちに転がってたやつ(作ってるうちにカメラ用 FFC コネクタ壊しちゃったので引退) スイッチサイエンス
Raspberry Pi Zero 2 W FFC コネクタ壊した Raspberry Pi Zero W の代わり スイッチサイエンス
Raspberry Pi Camera V2 うちに転がってたやつ スイッチサイエンス
Raspberry Pi Zero カメラケーブル いろんな長さが入ってて比較的安い(使ってるのは30cm) アマゾン
Raspberry Pi マウント用ネジセット Raspberry Pi 本体や HAT を固定するのにちょうどいいスペーサーとかが揃ってるセット アマゾン
7.4V 2S Li-Po バッテリー ラジコンのバッテリーとしてちょうどいい大きさで、しかも2つ入り アマゾン
Xbox ワイヤレス コントローラー うちに転がってたやつ Microsoft 公式
カメラ照明基板 カメラをパンチルトキットに取り付けられるようにするのと、暗所でもカメラで見るための基板 (思ったほど明るくなかった) 自作
電源・モータードライバ基板 バッテリーから Raspberry Pi Zero の 5V 電源を取り出すのと、モーターやサーボを駆動するための基板 自作
その他 導線とかネジとか必要なものを適宜

プログラム

作ったプログラムはここにあります

github.com

環境構築

WSL2 から作業したいので、 WSL2 と、ついでに Windows Terminal をインストールします
Windows Terminal は最高なんで、みんな入れましょう

learn.microsoft.com

管理者権限でコマンドプロンプトか Power Shell を起動して、以下を実行

wsl --install

Microsoft Store で UbuntuWindows Terminal をインストール

apps.microsoft.com

apps.microsoft.com

Windows Terminal の設定とかは各自調べていい感じにしてください

これ以降は基本的に WSL2 (Ubuntu) 上で作業します

Raspberry Pi の準備

SD カードにイメージを書き込む

やり方は検索すれば無限に転がってるので割愛

今回はヘッドレス(画面とか表示しない)で運用するので、そのイメージ(Lite って書いてあるやつ)を選択してください

とりあえず公式の通りにやればできます

注意点としては、 SSH を有効にして、認証方法をパスワードにしておくくらいです

https://www.raspberrypi.com/documentation/computers/getting-started.html#raspberry-pi-imagerwww.raspberrypi.com

今回使ってる OS のバージョンとかはこんな感じです(Raspberry Pi OS (Legacy) Lite)

$ cat /etc/os-release
PRETTY_NAME="Raspbian GNU/Linux 11 (bullseye)"
NAME="Raspbian GNU/Linux"
VERSION_ID="11"
VERSION="11 (bullseye)"
VERSION_CODENAME=bullseye
ID=raspbian
ID_LIKE=debian
HOME_URL="http://www.raspbian.org/"
SUPPORT_URL="http://www.raspbian.org/RaspbianForums"
BUG_REPORT_URL="http://www.raspbian.org/RaspbianBugs"

WSL2 から SSH 接続して作業できるようにする

WSL2 から Raspberry PiSSH 接続して作業していくので、そのための設定をしていきます
Raspberry Pi に周辺機器を一切接続せず、完全にリモートでやっていきます

やってるうちにエラーが出てきても、エラー文でググればどうすればいいのか説明してくれてる人がいるので、頑張ってください

Raspberry Pi の IP を調べる

Raspberry PiSSH するには Raspberry Pi の IP アドレスがわからないとどうしようもありません

まあ、 Raspberry Pi にキーボードやらディスプレイ繋いで直接見れば簡単ですが、ここは意地でもやりません

nmap をインストール

ローカル IP をスキャンするために nmap をインストールします

sudo apt install -y nmap

ローカル IP をスキャンして Raspberry Pi の IP を見つける

ローカル IP は基本的に 192.168.1.XXX なので、それをスキャンします
ルーターの設定によっては 192.168.2.XXX とか 192.168.10.XXX とかかもしれないので、それぞれ読み替えてください

まずは Raspberry Pi の電源を 入れていない 状態で nmap のスキャンを実行します(スキャンが終わるまで時間がかかるので、焦らず待ちましょう)

sudo nmap -sn 192.168.1.0/24

次に、 Raspberry Pi の電源を入れて(起動が完了して電源 LED が点灯している状態)でスキャンを実行します

すると、 IP アドレスが増えてると思うので、それが Raspberry Pi の IP アドレスです

初回 SSH 接続

Raspberry Pi の IP アドレスがわかったので、 SSH 接続します

ssh <USER>@<IP>

<USER> はイメージを書き込んだ時に設定したユーザー名、 <IP> は先ほど確認した Raspberry Pi の IP アドレスです
パスワード入力を求められるので、イメージを書き込んだ時に設定したパスワードを入力します

これで Raspberry Pi に入れました

IP アドレスを固定

今後 SSH 接続するとき Raspberry Pi の IP アドレスが変わると面倒なので固定します

/etc/dhcpcd.conf を編集します

sudo vi /etc/dhcpcd.conf

ファイルの最後に以下を追加します

interface wlan0
static ip_address=<IP>/24
static routers=192.168.1.1
static domain_name_servers=192.168.1.1

<IP> は何でもいいですが、先ほど確認した Raspberry Pi の IP アドレスを入れておけばいいと思います

routersdomain_name_servers は、自分が使用しているルーターの設定に合わせて変えてください

確認

まずは適当に ping を打って、ちゃんと設定できたか確認します

ping -c 5 www.google.com

Network is unreachable とか出てきたら、どこか設定が間違ってるので確認してください

問題なければ再起動して、設定した IP アドレスに対して SSH 接続できるか確認しましょう

sudo reboot

もう一度 SSH 接続できれば成功です

ssh <USER>@<IP>

SSH キーで接続できるようにする

https://www.raspberrypi.com/documentation/computers/remote-access.html#passwordless-ssh-accesswww.raspberrypi.com

毎回 SSH 接続するときにパスワード入力するのは面倒なので SSH キーで認証するようにします

Raspberry Pi に入っていたら WSL2 に戻ります
以降は WSL2 上での作業です

SSH キーの確認

以下のコマンドを実行します

ls ~/.ssh

これで id_rsa.pub などのファイルがあれば、すでに SSH キーがあるので、生成の必要はありません

SSH キーの生成

SSH キーがなかった場合、この手順を実行します

以下のコマンドで SSH キーを生成します

ssh-keygen

デフォルトでは id_rsa という名前になりますが、違う名前にしたければ以下のようにします

ssh-keygen -f <NAME>

これで SSH キー(秘密鍵 id_rsa 、公開鍵 id_rsa.pub)やその他必要なものが生成されました

以下のコマンドで生成されたものが確認できます

ls ~/.ssh

公開鍵を Raspberry Pi に送る

以下のコマンドを実行します(途中でパスワードの入力が求められる)

ssh-copy-id <USER>@<IP>

名前を変えた場合は以下です(絶対パスで公開鍵を指定しないとダメっぽい)

ssh-copy-id -i ~/.ssh/<NAME>.pub <USER>@<IP>

これで SSH 接続(ssh <USER>@<IP>)すると、パスワード入力を求められることなく Raspberry Pi に入れると思います

Raspberry Pi の基本的な設定は終了です

パッケージを最新にアップデートする

いつものおまじないです

sudo apt update
sudo apt upgrade -y

ついでに pip も入れます

sudo apt install -y python3-pip

Raspberry Pi をアクセスポイントにする

最終的にカメラの映像をストリーミングするのですが、 Raspberry Pi に直接接続して見られるようにしたほうが便利です(Wi-Fi が飛んでない屋外で遊んだりするときとか)

そこで、 Raspberry Pi をアクセスポイントにします
アクセスポイントとして設定するのに hostapddnsmasq というパッケージを使います

以下の記事を参考にしました

www.mikan-tech.net

ちなみに、 Raspberry Pi OS (bookworm) で NetworkManager を使った場合の方法もまとめてます

koktoh.hatenablog.com

現在のネットワークインターフェースと MAC アドレスを確認

まずは現在設定されているネットワークインターフェースを確認してみましょう

iw dev

実行結果

phy#0
        Unnamed/non-netdev interface
                wdev 0x2
                addr xx:xx:xx:xx:xx:xx
                type P2P-device
                txpower 31.00 dBm
        Interface wlan0
                ifindex 2
                wdev 0x1
                addr xx:xx:xx:xx:xx:xx
                ssid <SSID 名>
                type managed
                channel 10 (2457 MHz), width: 20 MHz, center1: 2457 MHz
                txpower 31.00 dBm

wlan0addr の値(MAC アドレス)は今後も使うので、どこかにメモしておいてください

仮想ネットワークインターフェースを作る

まずは ap0 という名前でネットワークインターフェースを作ります

sudo iw phy phy0 interface add ap0 type __ap

次に、 ap0MAC アドレスを設定します
さっき確認した addr と同じで大丈夫です

sudo ip link set ap0 address xx:xx:xx:xx:xx:xx

確認すると ap0 が新たにできています

phy#0
        Interface ap0
                ifindex 3
                wdev 0x3
                addr xx:xx:xx:xx:xx:xx
                type AP
                channel 10 (2457 MHz), width: 20 MHz, center1: 2457 MHz
                txpower 31.00 dBm
        Unnamed/non-netdev interface
                wdev 0x2
                addr xx:xx:xx:xx:xx:xx
                type P2P-device
                txpower 31.00 dBm
        Interface wlan0
                ifindex 2
                wdev 0x1
                addr xx:xx:xx:xx:xx:xx
                ssid <SSID 名>
                type managed
                channel 10 (2457 MHz), width: 20 MHz, center1: 2457 MHz
                txpower 31.00 dBm

udev でアクセスポイントの設定を永続化

Raspberry Pi を再起動すると ap0 は消えてしまうので、 udev で起動時に ap0 を作成するようにします

まずはファイルを作ります

sudo vi /etc/udev/rules.d/99-ap0.rules

以下の内容を書き込みます

SUBSYSTEM=="ieee80211", ACTION=="add|change", ATTR{macaddress}=="xx:xx:xx:xx:xx:xx", KERNEL=="phy0", \
  RUN+="/sbin/iw phy phy0 interface add ap0 type __ap", \
  RUN+="/bin/ip link set ap0 address xx:xx:xx:xx:xx:xx"

xx:xx:xx:xx:xx:xx は先ほどの MAC アドレスです

パッケージのインストール

アクセスポイントとして動作させるのに必要なパッケージ、 hostapddnsmasq をインストールします

sudo apt install -y hostapd dnsmasq

DHCP サーバの設定

IP アドレス中の XXX は適宜決めてください

/etc/dnsmasq.conf を編集します

sudo vi /etc/dnsmasq.conf

ファイルの最後に以下を追加します

interface=ap0
dhcp-range=192.168.XXX.100,192.168.XXX.150,255.255.255.0,12h

これで ap0DHCP サーバ機能が有効となります
この設定の場合、 ap0 に繋がった機器へ 192.168.XXX.100 から 192.168.XXX.150 の間のどれかの IP アドレスが割り当てられます

DHCP クライアントの設定

/etc/dhcpcd.conf を編集して ap0 の IP アドレスを固定します
ルーター本体の IP アドレス(192.168.1.1 とか 192.168.2.1 とか)に相当するものですね

sudo vi /etc/dhcpcd.conf

ファイルの最後に以下を追加します

interface ap0
static ip_address=192.168.XXX.1/24
nohook wpa_supplicant

IP アドレス中の XXXDHCP サーバの設定 で設定したものに合わせてください

hostapd の設定

/etc/hostapd/hostapd.conf を作成、編集します(このファイルは自動で作られないらしい)

sudo vi /etc/hostapd/hostapd.conf

ファイル内容は以下のようにします
アクセスポイントのパスワードは 8文字以上16文字以下 にしてください

ctrl_interface=/var/run/hostapd
ctrl_interface_group=0
interface=ap0
driver=nl80211
ssid=<アクセスポイントの SSID 名>
hw_mode=g
country_code=JP
channel=11
ieee80211d=1
wmm_enabled=0
macaddr_acl=0
auth_algs=1
wpa=2
wpa_passphrase=<アクセスポイントのパスワード>
wpa_key_mgmt=WPA-PSK
rsn_pairwise=CCMP

最後にサービスに登録して、 Raspberry Pi の起動時に hostapd が実行されるようにします

sudo systemctl unmask hostapd.service
sudo systemctl enable hostapd.service

各サービスの起動と確認

各サービスを再起動します

sudo systemctl restart dhcpcd.service
sudo systemctl restart hostapd.service
sudo systemctl restart dnsmasq.service

スマホなどでアクセスポイントを探すと、先ほど設定した SSID が表示されるはずです
先ほど設定したパスワードで接続できるはずです

なお、このアクセスポイントは独立しているため、インターネットや宅内ネットワークにアクセスすることはできません
今回はカメラ映像をストリーミングする用途なので、これで問題ありません

最後に再起動して、アクセスポイントが再び表示されるか確認しましょう

sudo reboot

Bluetooth コントローラーを接続する

ラジコンなのでコントローラーが必要です
今回は Xbox コントローラーを Bluetooth で接続して使います(他のコントローラーを使う場合は、適宜読み替えてください)

ヘッドレスなので、 bluetoothctl を使ってコマンドラインから設定します
Raspberry Pi OS には最初から入っているので、特になにもしなくても使えるはずです

bluetoothctl についてはこちらに詳しくまとめられています

qiita.com

bluetoothctl 起動

まずは bluetoothctl を起動して、 bluetoothctl の CLI に入ります

bluetoothctl

実行結果

Agent registered
[CHG] Controller XX:XX:XX:XX:XX:XX Pairable: yes
[bluetooth]# 

Controller は、 Bluetooth の送受信機みたいなものと考えてください(Raspberry Pi には Controller が1つしか載ってない)
今後はこの Controller に対して設定していきます

Controller の現在の設定を表示

show

実行結果

Controller XX:XX:XX:XX:XX:XX (public)
        Name: raspberrypi
        Alias: raspberrypi
        Class: 0x00000000
        Powered: yes
        Discoverable: no
        DiscoverableTimeout: 0x000000b4
        Pairable: yes
        UUID: Generic Attribute Profile (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)
        UUID: Generic Access Profile    (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)
        UUID: PnP Information           (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)
        UUID: A/V Remote Control Target (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)
        UUID: A/V Remote Control        (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)
        UUID: Device Information        (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)
        Modalias: usb:XXXXXXXXXXXXXXX
        Discovering: no
        Roles: central
        Roles: peripheral
Advertising Features:
        ActiveInstances: 0x00 (0)
        SupportedInstances: 0x05 (5)
        SupportedIncludes: tx-power
        SupportedIncludes: appearance
        SupportedIncludes: local-name

ペアリング時の認証方法の変更

パソコンに Bluetooth 機器を接続する時、6桁の認証コードの入力が求められたりします
今回の場合、そういうのは面倒なので全部なしにします

agent off

実行結果

Agent unregistered

Bluetoothバイスをスキャンする

Bluetoothバイスをスキャンします
これを実行すると、ペアリング・接続可能な Bluetoothバイスの名前と MAC アドレスが表示されます

実行する前に Xbox コントローラーの電源を入れておきましょう

scan on

実行結果

Discovery started
[CHG] Controller XX:XX:XX:XX:XX:XX Discovering: yes
[NEW] Device XX:XX:XX:XX:XX:XX <Bluetooth デバイス1>
[NEW] Device XX:XX:XX:XX:XX:XX <Bluetooth デバイス2>
[NEW] Device XX:XX:XX:XX:XX:XX <Bluetooth デバイス3>
[NEW] Device XX:XX:XX:XX:XX:XX Xbox Wireless Controller
...
...

Xbox Wireless Controller が見つかりましたね

Xbox コントローラーとペアリングする

まずは Controller をペアリング可能にします(show コマンドで Pairable: yes になってたら不要)

pairable on

実行結果

Changing pairable on succeeded
[CHG] Controller XX:XX:XX:XX:XX:XX Pairable: yes

先ほど確認した Xbox コントローラーの MAC アドレスを指定してペアリングを実行します

pair <MAC アドレス>

実行結果

Attempting to pair with XX:XX:XX:XX:XX:XX
[CHG] Device XX:XX:XX:XX:XX:XX Connected: yes
[CHG] Device XX:XX:XX:XX:XX:XX Bonded: yes
[CHG] Device XX:XX:XX:XX:XX:XX UUIDs: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[CHG] Device XX:XX:XX:XX:XX:XX UUIDs: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[CHG] Device XX:XX:XX:XX:XX:XX UUIDs: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[CHG] Device XX:XX:XX:XX:XX:XX UUIDs: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[CHG] Device XX:XX:XX:XX:XX:XX UUIDs: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[CHG] Device XX:XX:XX:XX:XX:XX UUIDs: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[CHG] Device XX:XX:XX:XX:XX:XX ServicesResolved: yes
[CHG] Device XX:XX:XX:XX:XX:XX Paired: yes
...
中略
...
Pairing successful

ペアリングされているか確認

最後に、 Xbox コントローラーがちゃんとペアリングされているか確認します

bluetoothctl v5.55 (Legacy 標準)

paired-devices

bluetoothctl v5.66 (bookworm 標準)

devices Paired

実行結果

Device XX:XX:XX:XX:XX:XX Xbox Wireless Controller

Xbox Wireless Controller が表示されれば OK です

bluetoothctl 終了

ペアリングが終わったので bluetoothctl を終了します

exit

カメラを接続する

公式の手順に従ってケーブルを繋ぎます

https://www.raspberrypi.com/documentation/accessories/camera.html#installing-a-raspberry-pi-camerawww.raspberrypi.com

動作確認

試しにカメラで画像を撮ります

まずコンフィグからカメラを使えるようにします

sudo raspi-config

3 Interface Options を選択します

I1 Legacy Camera を選択します

Yes を選択します

「今後サポートされなくなるかもしれないよ」という警告が出てくるので、 OK します

Finish を選択して終了します

再起動を勧めてくるので Yes で再起動します

これでカメラを使う準備ができたので、試しに撮ります

raspistill -o test.jpg

WSL2 で画像をコピーしてきて確認してみましょう

scp <USER>@<IP>:~/test.jpg /mnt/<コピー先のパス>

確認すると撮れてるはずです

カメラの映像をストリーミングする

ストリーミングする方法は様々あるようですが、軽量で遅延が少ないらしい mjpg-streamer を使います

こちらを参考にしました

ponkichi.blog

必要なパッケージをインストール

mjpg-streamer は GitHub からソースコードを引っ張ってきてビルドする必要があるので、そのためのパッケージをインストールします

sudo apt install -y git cmake libjpeg-dev

mjpg-streamer をインストール

mjpg-streamer を clone し、ビルドしてインストールします

今回使用したソースコードはこちらです

github.com

git clone https://github.com/neuralassembly/mjpg-streamer.git
cd mjpg-streamer/mjpg-streamer-experimental
make
sudo make install

mjpg-streamer の動作確認

mjpg-streamer/mjpg-streamer-experimental に移動して以下のコマンドを実行します

mjpg_streamer -o './output_http.so -w ./www -p 8080' -i './input_raspicam.so -x 1280 -y 720 -fps 30 -q 10'

オプションの意味などは参考記事や公式などで確認してください

ブラウザで <ラズパイの IP>:8080 に接続すると、 mjpg-streamer のメインページが表示されます

左の Stream を選択するとリアルタイムのストリーミング映像が見れます
ストリーミング映像だけを見たい場合は <ラズパイの IP>:8080/?action=stream にアクセスします

終了は Ctrl + C です

mjpg-streamer 起動用スクリプトの作成

いちいちオプションを指定して起動するのは面倒なので、シェルスクリプトを作って起動するようにします

cd /usr/local/bin
vi start_mjpg_streamer.sh

中身

#!/bin/bash

export LD_LIBRARY_PATH=/usr/local/lib

# mjpg-streamer config
WIDTH=1280
HEIGHT=720
FPS=30
QUALITY=10
PORT=8080

# Kill process that is already running
pkill mjpg_streamer

# Run mjpg-streamer
/usr/local/bin/mjpg_streamer \
    -i "input_raspicam.so -x $WIDTH -y $HEIGHT -fps $FPS -quality $QUALITY" \
    -o "output_http.so -w /usr/local/share/mjpg-streamer/www -p $PORT"

exit 0

実行権限とかを変更しておきます

chmod 755 start_mjpg_streamer.sh

スクリプトを実行して、 mjpg-streamer が問題なく起動し、ストリーミングされていることを確認しましょう

sh start_mjpg_streamer.sh

ここまで出来たら、ソースコードディレクトリごと削除しても問題ありません

mjpg-streamer 起動用スクリプトをサービス化する

Raspberry Pi が起動したときに mjpg-streamer が起動するように、サービス登録します

sudo vi /etc/systemd/system/mjpg_streamer.service

中身

[Unit]
Description=MJPG-Streamer service
After=network.target

[Service]
Type=simple
KillMode=process
Restart=always
WorkingDirectory=/usr/local/bin
ExecStart=/usr/local/bin/start_mjpg_streamer.sh

[Install]
WantedBy=multi-user.target

After=network.target を指定することで、ネットワーク周りの起動が終わってからサービスを起動するようにしています

systemctl に登録して起動します

sudo systemctl enable mjpg_streamer
sudo systemctl start mjpg_streamer

ストリーミング映像が見られれば OK です

ストリーミング表示用ページを作る

直接 <ラズパイの IP>:8080/?action=stream にアクセスするだけでも十分ですが、映像を画面全体に表示したいので、専用のページを作ります
あと、こういうページを作っておけば、ブラウザからラジコンの設定弄ったりするページを追加したりしやすい気がする(現状のソースコードではそういうのができるようになってないので、やりたい人は頑張ってください)

面倒ならやらなくてもいいです

サクッと flask で構築します

flask をインストール

pip install flask

pip がインストールされてなければ先にインストールしてください

sudo apt install -y python3-pip

コードを書く

最終的なファイル構造はこうなります

camera_streaming
├── streaming.py
└── templates
    └── index.html

streaming.py (メインとなるプログラム)を作成

ディレクトリを作ってファイルを作ります

sudo mkdir /usr/local/bin/camera_streaming
sudo cd /usr/local/bin/camera_streaming
sudo vi streaming.py

中身

from flask import Flask, render_template

app = Flask(__name__)


@app.route("/")
def index():
    return render_template("index.html")


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=False, threaded=True)

index.html (ストリーミング映像を表示するページ)を作成

templates/index.html を作ります

sudo mkdir templates
sudo cd templates
sudo vi index.html

中身

<!DOCTYPE html>
<html>
    <head>
        <title>RasPi Zero Camera Stream</title>
    </head>
    <body style="padding:0; margin:0; background-color:#333;">
        <div style="height: 100vh; display: flex; justify-content: center;">
            <img style="height:100%; margin:0;" src="http://<ap0 の ip_adress>:8080/?action=stream" >
        </div>
    </body>
</html>

ap0 の ip_adress を指定しているので、 ap0 に繋いだ状態でないとストリーミング映像は表示されません(ページ自体は表示される)

こんな風にページに直接埋め込んで表示できるのがいいですね

動作確認

streaming.py を実行します

python3 /usr/local/bin/camera_streaming/streaming.py

ap0 に繋いだスマホなどのブラウザから DHCP クライアントの設定 で設定した IP アドレスにアクセスします

<ap0 の ip_address>:8000

カメラのストリーミング映像が見られたら成功です

確認が終わったら Ctrl + C で終了しましょう

サービス化する

とりあえずカメラのストリーミングはラジコン操作と独立で動いてて構わないので、ストリーミング機能だけでサービス化します

サービスファイルを作ります

sudo vi /etc/systemd/system/camera_streaming.service

中身

[Unit]
Description=Camera streaming service
After=network.target

[Service]
User=<Raspberry Pi のユーザー名>
Group=<Raspberry Pi のユーザー名>
Type=simple
Restart=always
WorkingDirectory=/usr/local/bin/camera_streaming
ExecStart=/usr/bin/python3 streaming.py

[Install]
WantedBy=multi-user.target

After=network.target を指定することで、ネットワーク周りの起動が終わってからサービスを起動するようにしています

systemctl に登録して起動します

sudo systemctl enable camera_streaming
sudo systemctl start camera_streaming

同じようにブラウザからアクセスして問題なければ OK です

ラジコンのプログラムを作る

本丸のラジコンのプログラムです

とはいっても、あんまり詳しく説明はしないので、ソースコードを読んで感じてください

あと、これが python 初挑戦みたいなもんなので、微妙な部分がいっぱいあると思います

パッケージのインストール

pygame のインストール

コントローラーの入力を取得するために pygame をインストールします

python3 -m pip install -U pygame --user

pygame には libsdl2-2.0-0 というパッケージも必要なのでインストールします

sudo apt install -y libsdl2-2.0-0

pigpio のインストール

GPIO を制御するための pigpio をインストールします

sudo apt install -y python3-pigpio

pigpio はデーモンを起動しないと使えないので起動します

sudo pigpiod

また、 Raspberry Pi が起動したときに自動的に起動するように /etc/rc.local を編集します

sudo vi /etc/rc.local

最後の exit 0 の前に pigpiod を追加します

pigpiod    # ここに追加
exit 0

コントローラーの入力を取得する

やってることは公式のやつほとんどそのまんまです

www.pygame.org

    # 一部抜粋
    def _run(self) -> None:
        current_input = ControllerInput()

        while True:
            self._running_event.wait()

            event = pygame.event.wait()

            if event.type == pygame.QUIT:
                break

            if not self._controller_connected:
                continue

            if (
                event.type == pygame.JOYDEVICEREMOVED
                and event.instance_id == self._controller.get_instance_id()
            ):
                self._controller_connected = False
                self.data.clear()
                self._controller_data.event = ControllerEventType.Disconnected
                self._controller_data.controller_input.reset()
                current_input.reset()
                self.data.put(self._controller_data)
                self._logger.warn("Controller disconnected.")
                self._running_event.clear()
                self._reconnect_controller()
                continue

            current_input.reset()

            for i in range(self._controller.get_numaxes()):
                axis = self._controller.get_axis(i)
                current_input.axes.append(self._axis_converter.convert(i, axis))

            for i in range(self._controller.get_numbuttons()):
                button = self._controller.get_button(i)
                current_input.buttons.append(button)

            for i in range(self._controller.get_numhats()):
                hat = self._controller.get_hat(i)
                current_input.hats.append(hat)

            if self._controller_data.controller_input == current_input:
                continue

            self._controller_data.event = ControllerEventType.InputChanged
            self._controller_data.controller_input.copy(current_input)

            self._logger.debug("Controller input:%s", self._get_controller_input())

            self.data.put(self._controller_data)

_run() 自体は、別スレッドで無限ループしてます

工夫したところと言えば

  • コントローラーと接続が切れたら、ループを一時停止して再接続ルーチンを起動する
  • _axis_converter でアナログスティックの入力を丸める
  • 入力が変わった時だけ通知する

とかですかね

_axis_converter での丸め方は analogaxis.py とかで定義してます
最終的には Speed の5段階にしてます

class Speed(IntEnum):
    Zero = 0
    Slow = 1
    Middle = 2
    High = 3
    Max = 4

そこら辺を変えたいなら、 analogaxis.py とか、関係するところを編集すれば OK です

詳しくはソースを見てください

モーターの制御

motordriver.py でそこら辺を書いてます

サーボモーターの制御

「右スティックを倒している間だけその方向に動かしたい」ので、サーボへの信号出力も別スレッドでループさせています(_run() が別スレッドで動いてる)

    # 一部抜粋
    def _run(self) -> None:
        while True:
            self._running_event.wait()

            if self._exit:
                break

            if self._clockwise:
                self._current_duty += self._step

                if self._current_duty > self._max_duty:
                    self._current_duty = self._max_duty
                    self.stop()
                    continue
            else:
                self._current_duty -= self._step

                if self._current_duty < self._min_duty:
                    self._current_duty = self._min_duty
                    self.stop()
                    continue

            self._servo.hardware_PWM(self._pin, self._freq, self._current_duty)
            time.sleep(self._interval)

「ボタンを押している間だけ」とかもできます

モーターの制御

こっちは特に別スレッドで無限ループとかはしてません(不要なので)

    # 一部抜粋
    def move(self, dutycycle: float, forward: bool = True) -> None:
        """
        Move the motor.

        Args:
            dutycycle (float): Parcentage of dutycycle (from 0 to 1).
            forward (bool): True for forward movement, False for backward.
        """
        dutycycle_ = int(self._max_dutycycle * dutycycle)

        if dutycycle_ > self._max_dutycycle:
            dutycycle_ = self._max_dutycycle

        self._logger.debug(
            "Move motor to %s in %d",
            "foward" if forward else "backward",
            dutycycle_,
        )

        if forward:
            self._motor.set_PWM_dutycycle(self._backward_pin, 0)
            self._motor.set_PWM_dutycycle(self._forward_pin, dutycycle_)
        else:
            self._motor.set_PWM_dutycycle(self._forward_pin, 0)
            self._motor.set_PWM_dutycycle(self._backward_pin, dutycycle_)

メインループ

これらのパーツを組み合わせてゴリっと作ったのがこちら
全体は radiocontrol.py を見てください

# 一部抜粋
def main():
    logger.info("Start initializing.")

    controller = Controller(controller_type=ControllerType.Xbox, name=CONTROLLER_NAME)

    led = LED(LED_PIN, LED_NAME)

    pan_servo = ThreadedServo(
        PAN_SERVO_PIN,
        SG90_MIN_PULSE_WIDTH,
        SG90_MAX_PULSE_WIDTH,
        SG90_FRAME_WIDTH,
        SERVO_INITIAL_PULSE,
        name=PAN_SERVO_NAME,
    )
    tilt_servo = ThreadedServo(
        TILT_SERVO_PIN,
        SG90_MIN_PULSE_WIDTH,
        SG90_MAX_PULSE_WIDTH,
        SG90_FRAME_WIDTH,
        SERVO_INITIAL_PULSE,
        name=TILT_SERVO_NAME,
    )

    left_motor = Motor(LEFT_FORWARD_PIN, LEFT_BACKWARD_PIN, name=LEFT_MOTOR_NAME)
    right_motor = Motor(RIGHT_FORWARD_PIN, RIGHT_BACKWARD_PIN, name=RIGHT_MOTOR_NAME)

    pi = pigpiohelper.get_instance()
    pi.set_mode(MOTOR_DRIVER_STATE_PIN, pigpio.INPUT)
    pi.set_pull_up_down(MOTOR_DRIVER_STATE_PIN, pigpio.PUD_DOWN)

    logger.info("End initializing.")

    try:
        controller.connect()

        # Wait for the controller to be connected before calling controller.run().
        while True:
            state = controller.data.get()

            if state.event == ControllerEventType.Connected:
                break

            if state.event == ControllerEventType.Quit:
                raise Exception("Faild to connect controller.")

        controller.run()

        logger.info("Start main loop of radio control.")
        while True:
            state = controller.data.get()

            if state.event == ControllerEventType.Quit:
                break

            if state.event == ControllerEventType.Disconnected:
                pan_servo.stop()
                tilt_servo.stop()
                left_motor.stop()
                right_motor.stop()

            if state.event != ControllerEventType.InputChanged:
                continue

            input = state.controller_input

            # LED lighting
            if XboxControllerInputHelper.HatX(input) > 0:
                # LED on if Hat Right is pushed.
                led.on()
            elif XboxControllerInputHelper.HatX(input) < 0:
                # LED off if Hat Left is pushed.
                led.off()

            if XboxControllerInputHelper.HatY(input) > 0:
                # Increment brightness of LED if Hat Up is pushed.
                led.increment_brightness()
            elif XboxControllerInputHelper.HatY(input) < 0:
                # Decrement brightness of LED if Had Down is pushed.
                led.decrement_brightness()

            # Control camera servo
            if XboxControllerInputHelper.StickRightX(input).value == 0:
                # Stop servo if Right Stick's X axis is neutral position.
                pan_servo.stop()
            else:
                # Run the servo clockwise or counterclockwise based on the tilt direction of the Left Stick's X axis.
                speed = Speed(XboxControllerInputHelper.StickRightX(input).value)
                clockwise = XboxControllerInputHelper.StickRightX(input).positive
                run_servo(pan_servo, speed, not clockwise)

            if XboxControllerInputHelper.StickRightY(input).value == 0:
                # Stop servo if Right Stick's Y axis is neutral position.
                tilt_servo.stop()
            else:
                # Run the servo clockwise or counterclockwise based on the tilt direction of the Left Stick's Y axis.
                speed = Speed(XboxControllerInputHelper.StickRightY(input).value)
                clockwise = XboxControllerInputHelper.StickRightY(input).positive
                run_servo(tilt_servo, speed, clockwise)

            if XboxControllerInputHelper.ButtonStickRight(input):
                # Move to initial position if Right Stick is pushed.
                pan_servo.initial()
                tilt_servo.initial()

            # Check motor driver chip's state
            if pi.read(MOTOR_DRIVER_STATE_PIN):
                logger.warn("Motor driver chip down. Waiting restart.")
                continue

            # Spin turn
            if (
                XboxControllerInputHelper.TriggerLeft(input).value != 0
                and XboxControllerInputHelper.TriggerRight(input).value == 0
            ):
                # Spin turn to left if only Trigger Left is pushed.
                speed = Speed(XboxControllerInputHelper.TriggerLeft(input).value)
                run_motor(left_motor, speed, False)
                run_motor(right_motor, speed)
                continue
            elif (
                XboxControllerInputHelper.TriggerLeft(input).value == 0
                and XboxControllerInputHelper.TriggerRight(input).value != 0
            ):
                # Spin turn to right if only Trigger Right is pushed.
                speed = Speed(XboxControllerInputHelper.TriggerRight(input).value)
                run_motor(left_motor, speed)
                run_motor(right_motor, speed, False)
                continue

            # Running motor
            if (
                XboxControllerInputHelper.StickLeftY(input).value == 0
                and XboxControllerInputHelper.StickLeftX(input).value == 0
            ):
                # Stop motor if Left Stick is neutral position.
                left_motor.stop()
                right_motor.stop()
            elif (
                XboxControllerInputHelper.StickLeftY(input).value != 0
                and XboxControllerInputHelper.StickLeftX(input).value == 0
            ):
                # Run forward or backward if Left Stick is tilted only Y axis.
                speed = Speed(XboxControllerInputHelper.StickLeftY(input).value)
                forward = not XboxControllerInputHelper.StickLeftY(input).positive
                run_motor(left_motor, speed, forward)
                run_motor(right_motor, speed, forward)
            elif (
                XboxControllerInputHelper.StickLeftY(input).value == 0
                and XboxControllerInputHelper.StickLeftX(input).value != 0
            ):
                # Turn left or right if Left Stick is tilted only X axis.
                speed = Speed(XboxControllerInputHelper.StickLeftX(input).value)
                if XboxControllerInputHelper.StickLeftX(input).positive:
                    right_motor.stop()
                    run_motor(left_motor, speed)
                else:
                    left_motor.stop()
                    run_motor(right_motor, speed)
            else:
                # Turn left or right
                #
                # ex) Turn left ->
                # RUNNING SPEED is based on Left Stick's Y axis value.
                # Right motor runs at RUNNING SPEED.
                # Left motor's speed is (RUNNING SPEED - Left Stick's X axis value * 0.15).
                # So, left motor runs 15-60% slower than RUNNING SPEED (slowest is 0).
                speed = Speed(XboxControllerInputHelper.StickLeftY(input).value)
                dutycycle = get_dutycycle(speed) - (
                    0.15 * XboxControllerInputHelper.StickLeftX(input).value
                )
                forward = not XboxControllerInputHelper.StickLeftY(input).positive

                if dutycycle < 0:
                    dutycycle = 0

                if XboxControllerInputHelper.StickLeftX(input).positive:
                    run_motor(left_motor, speed, forward)
                    run_motor_raw(right_motor, dutycycle, forward)
                else:
                    run_motor(right_motor, speed, forward)
                    run_motor_raw(left_motor, dutycycle, forward)

            if (
                XboxControllerInputHelper.ButtonBack(input)
                and XboxControllerInputHelper.ButtonStart(input)
            ):
                # Quit when Back button and Start button pushed.
                logger.info("End main loop of radio control.")
                break

    except Exception as e:
        logger.exception(e)

    finally:
        controller.quit()
        pan_servo.quit()
        tilt_servo.quit()
        left_motor.quit()
        right_motor.quit()
        led.quit()

        logger.info("End program.")

全体的に、本当はもう少し Factory パターンとか使っていい感じにしたかったんですが、 python の型ヒントが思ったようにいかなかったので諦めました

Controller().data.get() で返ってくるやつを XboxControllerDataControllerData から継承)とかにしたかったけど、型ヒントが想定した感じにならなくてインテリセンスの恩恵が受けられないので、わざわざ XboxControllerInputHelper とかいう静的メソッドだけのクラス作って取得したりとか微妙すぎる
XboxControllerInputHelper はインテリセンスでいい感じに補完してもらうために作った、マジで意味のないクラス
python つよつよの人、いい解決法あったら教えてくれ~~

サービス化する

ソースコード/usr/local/bin/radio_control/ に配置して、サービス登録してしまいます

sudo vi /etc/systemd/system/radio_control.service

中身

[Unit]
Description=Radio Control service
After=network.target

[Service]
User=<Raspberry Pi のユーザー名>
Group=<Raspberry Pi のユーザー名>
Type=simple
WorkingDirectory=/usr/local/bin/radio_control
ExecStart=/usr/bin/python3 radiocontrol.py

[Install]
WantedBy=multi-user.target

systemctl に登録して起動します

sudo systemctl enable radio_control
sudo systemctl start radio_control

基板を作る

ゴリっと基板を作ります

まあ、ユニバーサル基板とかに作ればいいんですけど、やっぱちゃんと PCB にした方がカッコいいんで

出来上がったのがこちら

左側が、カメラマウントおよび照明基板、右側が、電源・モータードライバ基板です
電源スイッチも付ければよかったんですけど忘れちゃいました

組み立てる

タミヤのユニバーサルプレートを頑張って加工して、カメラとか Raspberry Pi が固定できるようにします
基板にも必要な部品(導線とかピンヘッダとか)を半田付けします

そして、出来上がったのがこちら(再掲)
走破性はいいけど、常に接地してるシーソー型の2つの転輪部分を軸に、車体が激しく前傾後傾するので、カメラを通して操作するのがちょっと難しい

なかなかイカしてると思います(自画自賛

実際、遊んでるとめちゃくちゃ楽しいです

普通(?)のバージョンも作りました
重心が後ろに寄ってるので登坂能力がちょっと弱いけど、車体は安定しているのでカメラを通しての操作はしやすい

おわりに

めちゃくちゃ長くなりました

でも、これを見ればみんな Raspberry Pi Zero W を使ってラジコンが作れるようになるんじゃないかと思います

作った基板はあと4枚あるんですが、どうしましょうかね……

こいつで遊びながら考えます