0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

大人のつくってあそぼ ミニ四駆サイズのロボットカー編

0
Posted at

1. 研究の背景

 30年くらい前、NHK教育の「つくってあそぼ」のワクワクさんに憧れた少年少女はどれくらいいたでしょう?私もまたあれで工作が好きになった少年でもありました。
 あれから30年ほど経ち別ベクトルで工作への情熱が再燃しました。働いて得た金で電子パーツが買える、AIに聞けば工作に使えるパーツの候補を提示してくれる。
 そして何より、前回記事でロボットボールの制御スクリプト作ってみたけどミニ四駆サーキットの坂を登れなかった事が今回のロボットカーの開発動機になります。後既製品のロボットカーですとサーキットを走れる程度まで小型化されたものがないですので自分で作るしかなかった事とかエンジニアでありながらただミニ四駆組み立てるだけじゃ面白くないってのも理由としてありました(ただし構造を理解するためにセイントドラゴンプレミアムは組み立てました)。エンジニアならこんな事やれるというのをミニ四駆に興味持たせてくれた友人達に見せるのも恩返しですからね。見るからにロボットでしかないものをミニ四駆と言い張る…良い…。

2. ロボットカーに使った材料

  • タミヤ 楽しい工作シリーズ (ユニット) No.168 ダブルギヤボックス (左右独立4速タイプ) 70168 × 2
    1,100 × 2 =2,200
    https://amzn.asia/d/078ObgZR
  • タミヤ ミニ四駆グレードアップパーツシリーズ No.498 GP.498 HG カーボンフロントワイドステー 1.5mm 15498 × 1(付けるかどうかは自由、サーキット走らせないなら無くても可)
    1,034
    https://amzn.asia/d/0hK8VqOn
  • タミヤ(TAMIYA) 楽しい工作シリーズ No.157 ユニバーサルプレート 2枚セット (70157)
    632
    https://amzn.asia/d/00OPfxFL
  • Kaito Denshi(海渡電子) 電池ボックス 単3 乾電池 4本 BH341-2A ケーブル20cm 1個 × 1
    550
    https://amzn.asia/d/0fznU1cX
  • 六角スペーサー 六角柱 M3ねじ 176個セット ナット ボルト 六角真鍮スペーサー スタンドオフ ナット ネジ 基板固定/機械/修理/精密機械に 収納ケース付き × 1
    1,379
    https://amzn.asia/d/09WwuLNU
  • SDカードリーダー【業界新版 3in1 Type-C & i-Phone SD/TF変換アダプタ】カードリーダー 0TG機能 設定不要 高速伝送 双方向転送 写真/ビデオ/動画 i-Phone17/タイプC スマホ/MacBook/PC/Galaxy/Android/USB Cなど機器に適用 × 1(ラズパイzero w OS書き込み用、OS書き込み済みのmicro SDがあるなら無くても可)
    1,380
    https://amzn.asia/d/0eFQZz9r
  • 単3形リチウム充電池 8本 1.5V 大容量3000mWh/2000mAh
    急速充電器付き| 約1600回充電可能| 1-8本同時充電| 2.5時間充電| 自然放電抑制 × 1
    2,980
    https://amzn.asia/d/0izdtQIt
  • 10個マイクロ130 DC 3-6V 0.4-0.45A 電動2000-20000RPM 2mmシャフト130 for Arduino/トイカー/DIYホビー用ミニチュアDCモータ、ジャンパワイヤ、モータスタンド付き × 1
    999
    https://amzn.asia/d/03A2pugM
  • TAGATORON(タガトロン)13-12mm 超軽量2段アルミベアリングローラーセット (8. ゴールド) × 1(サーキット走らせないなら無くても可、後でグリーンがあると知ってグリーンを買い直してそっちを使用)
    1,182
    https://amzn.asia/d/0gpzGnex
  • Geekworm Raspberry pi インストールツール M2.5シリーズ 六角真鍮スペーサー スタンドオフ ナット ネジ キット
    収納ケース入り Pi4B / 3B +/ 3B / Zero W 対応 × 1
    1,999
    https://amzn.asia/d/0iXuIfGK
  • Vesiri Raspberry pi zero 2W 日本技適取得 ラズベリーパイzero 2W 32GB カード プログラミング コンピュータ 開発ボードUSB Micro電源アダプター スイッチ付き電源ケーブル PSE認証/透明アクリル保護ケース/USB カードリーダー/ヒートシンク/品質改良 MINI HDOUTケーブル/OTGケーブル/2*20ピン × 1(GPIOピンヘッダーがはんだ付けされてないためHDMIケーブルなどセットアップに必要な周辺機器一式あるならGPIOピンヘッダーはんだ付け済みのボード購入推奨)
    18,980
    https://amzn.asia/d/08ebkF9A
  • Raspberry Pi Zero WH GPIOピンヘッダー ハンダ付け済み Wi-Fi & Bluetooth × 1(↑のラズパイZスターターセットを購入してはんだ付けを行う場合は無しで可)
    6,500
    https://amzn.asia/d/0jcwF4F6
  • 【M2.5x15】ステンレス ナベ頭 小ねじ(10本) × 1
    660
    https://amzn.asia/d/0ahQj6sj
  • WORKPRO ニッパー 薄刃 130mm プラスチック 斜めプライヤー 精密ニッパー ワイヤー 軟鉄 銅線切断 DIY手作り プラモデル 手工業職人用 繊細な作業工具 × 1
    1,280
    https://amzn.asia/d/00BebPC7
  • uxcell M3x6x0.5mm フラットワッシャー m3 平ワッシャー 薄型 平座金 304ステンレス 丸座金 丸型スペーサー 耐久性 防錆性 ネジ座金 100個 × 1
    851
    https://amzn.asia/d/0i40b6Iy
  • 【M3x15】ステンレス ナベ頭 小ねじ(10本) × 1
    600
    https://amzn.asia/d/0eijL83B
  • ブレッドボードジャンパーワイヤー デュポンワイヤー オス-オス オス-メス メス-メス 3種類混合 10cm(120本セット) × 1
    599
    https://amzn.asia/d/0ak54vJp
  • タミヤ(TAMIYA) 楽しい工作シリーズ No.101 トラックタイヤ 36mm (70101) × 1(3セット以上の購入を要求されますので1セットで売ってるのがあればそっちでも可)
    396 × 3 ‎ = 1,188
    https://amzn.asia/d/0eXJozWW
  • PATIKIL 4個 ミニ ブレッドボード はんだ付け不要 ブレッドボード 小 ブレッドボード 55 接点 プロトタイプ PCB ブレッドボード 電子工作 キット プレート ZY-55 緑 × 1
    560
    https://amzn.asia/d/0ek41YVz
  • uxcell ワッシャー付き 十字穴ナベ頭 ネジ ネジナット M2x12mm 304ステンレス鋼 25セット × 1
    969
    https://amzn.asia/d/0aPKRgzz
  • RasTech for Raspberry Piカメラモジュール 500万画素 raspberry pi camera module 超小型カメラ
    ラズベリーパイカメラ セットFor Raspberry Pi 5,Pi 4,3B,3B+,Zero W,Zero 2W対応 クリアスタンド1,ケーブル3付属 raspi5 カメラ CSI接続 pi camera 広角 DIY・AI開発向け × 1(AI連携を意図、必須ではない)1,680
    https://amzn.asia/d/06NGBgtG
  • ミニスパナセット 極薄スパナ コンビスパナセット 10本組 4mm/4.5mm/5mm/5.5mm/6mm/7mm/8mm/9mm/10mm/11mm 片目片口スパナ コンビレンチ ミリ規格 コンビネーションレンチセット ショートタイプ 薄型 軽量 収納袋付き 作業工具 父の日 ギフト お父さん プレゼント × 1
    1,499
    https://amzn.asia/d/0eV7wsZM
  • Wera(ヴェラ) マイナスドライバー3.0×80 110001 × 1
    https://www.amazon.jp/dp/B0001P18HS
  • タミヤ(TAMIYA) ミニ四駆特別企画 HG 19mm オールアルミベアリングローラー (テーパータイプ・グリーン) 95609 × 1(サーキット走らせないなら無くても可)
    1,200
    https://amzn.asia/d/02nVIH3c
  • サムコス QIコネクター デュポンコネクタセット 620個セット2.54mm ピッチ オス メス ジャンパーピン 2550 コネクター 配線コネクタ × 1
    747
    https://amzn.asia/d/00WHxpJq
  • タミヤ ミニ四駆グレードアップパーツ No.549 HG カーボンリヤワイドプレート 2mm (スライドダンパー対応) 15549 × 1(サーキット走らせないなら無くても可)
    1,078
    https://amzn.asia/d/0857L4Pz
  • 20枚 マジックテープ 両面テープ ベルクロ 幅3cm×長さ10cm 超強力タイプ 耐熱 防水 再利用可 家庭用 業務用
    599
    https://amzn.asia/d/0g152iYl
  • 90度USB-C to Micro USBケーブル、Micro USBからType-Cへのデータ同期と充電に対応、編み込み、対応Samsun Galax S7/S6、Kindl、Pixe 3 XL/2 XL、PS4。1M/3.2フィート
    1,655
    https://amzn.asia/d/0iFcYvpQ
  • Anker Nano Power Bank (22.5W, Built-In USB-C Connector) グリーン
    モバイルバッテリー 5000mAh 軽量 小型 コンパクト【PowerIQ搭載/USB-C一体型】 iPhone 17 / 16 / 15 シリーズ/Switch 2対応
    3,990
    https://amzn.asia/d/0irTrM1G
  • PATIKIL 8個 ヒートシンク 銅 12×12×6mm 放熱器 熱暴走対策 放熱板 薄型 小型 DIY用 チップ ノートPC 回路基板 銅色
    冷却用 銅色
    1,556
    https://amzn.asia/d/0gW4fQq0
  • Raspberry Pi用 MAX14870搭載 デュアルモータードライバ
    5,469
    https://www.switch-science.com/products/3871?variant=42381997179078&country=JP&currency=JPY&utm_medium=product_sync&utm_source=google&utm_content=sag_organic&utm_campaign=sag_organic&srsltid=AU7gw4XGxW0XLYUUWS-rGwLyFnJ8YlwLlQjkyrnQ5h-RDGFxCclK9ihETq8
  • DY.2ten 熱伝導テープ 幅20mm×長さ25m ヒートシンク LED基板の熱拡散用 熱拡散 熱伝導 両面テープ(ヒートシンク使わない、あるいは最初から両面テープが付けてあるなら不要)
    899
    https://amzn.asia/d/04GJ77e9
  • Glvaner 六角スペーサー M2 真鍮 六角ねじ 六角柱 ボルト スタンドオフ ネジ ナット300個セット 収納ケース付き ゴールド(必須ではないですが走行中ナットがよく外れて落下しましたのでサイドローラーとかカメラモジュールのネジにスペーサー被せると安定します)
    1,580
    https://amzn.asia/d/0dTY1N9J

合計金額: 67,584円。実際には他にもいくつかパーツやモータードライバー取り寄せつつレイアウト考えたりプログラム書いたりと試行錯誤してますので10万くらいは機体の開発だけで行った気がします。

3. 完成品

 こちらが最終的な完成品です。
IMG_1734.jpeg

こだわりポイント:

  • 好きなカラーであるグリーン系を中心にパーツを選択した事
  • 後からラズパイとモーターの電源分離を前提にレイアウト設計微修正した時に組紐の技術を使って編んだ飾り紐、ロボットという無機質なものの中に人の手ないしは職人技のような温もりを表現した感じ
  • スペーサーの材質を全て真鍮にする事で重厚感やメタリックな黄金色の輝きを強調

4. 大まかなレイアウト

 具体的な組み立て手順事細かに書いてたらそれだけで文量が膨大になってしまいますので大まかなレイアウトの図面貼るだけにしておきます。

  • 下段プレート
    無題328_20261001225155.png
  • 上段プレート
    無題329_20261001230807.png

5. ラズパイ zero セットアップ

 前提としてクライアント側で使用するPCとラズパイを同一のWi-Fiに接続するかスマホのテザリングとかで繋げられるようにします。

 ラズパイzeroのイメージが入ったラズパイを起動し、初期設定を済ませてVNC接続できるとこまでできた後はモータードライバーのPythonスクリプトのレポジトリにアクセスしREADMEに従ってラズパイ側に必要な設定をしていきます。

 この時に詰まったのが sudo apt-get p7zip などのコマンドを打ってもエラーが出てしまう事であり、そうなる理由はRaspbian Buster(Debian 10)がサポート終了でp7zipが入れられなかった事です。AIに教えてもらった以下の手順で解決しましたがAIがなかったら調査のために何日かかったのやら…。

1. sources.listの修正
sudo nano /etc/apt/sources.list

 テキストを以下の1行に変更。

deb https://legacy.raspbian.org/raspbian/ buster main contrib non-free rpi
2. raspi.listの修正
sudo nano /etc/apt/sources.list.d/raspi.list

 中身を以下の1行に変更。

deb http://archive.raspberrypi.org/debian/ buster main
3. インストールの再実行
sudo apt-get update
sudo apt-get install p7zip

 モータードライバーの使用のために必要な設定が終わりましたら次はカメラモジュールを使用するために必要な設定をしていきます。

1. raspi-configでカメラを有効化
raspi-config

 設定画面が出たらインターフェース→カメラ→有効化するか聞かれたらはいを押して再起動。

2. 静止画撮影テスト
raspistill -o test.jpg

 コマンド実行時のカレントディレクトリ内にtest.jpgが追加されていたら成功。

6. 必要なライブラリのインストール

 動作確認時に使用したのはクライアント側:M1 Mac、サーバー側ラズパイ zero WHでしたのでWindowsでクライアント側のPythonスクリプト使う場合Windowsに合わせて実装を直す必要はあります。後はuv環境の整備までは面倒でしたのでやってません。

1. クライアント側(Macに入れるライブラリ)
  • pip3 install又はpip installで入れるもの
    • python-opencv
    • pygame
    • websockets
2. サーバー側(ラズパイに入れるライブラリ)
  • pip3 installで入れるもの
    • fastapi
    • uvicorn
    • websockets
  • sudo apt installで入れるもの
    • python3-opencv
    • python3-numpy

7. 制御スクリプトの用意

 Geminiとの雑談の延長線上で雛形作ってもらってClaudeに動く形まで改造してもらって実機を動かしながら旋回の処理とかパラメータ微調整して作りました。

1. サーバー側の準備
import asyncio
import json
import time
cv2_imported = True
try:
    import cv2
except ImportError:
    cv2_imported = False

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
import uvicorn

app = FastAPI()

# ==========================================
# モータードライバーの初期化
# 実機(Pololu Dual MAX14870 + dual_max14870_rpi.py)が使えない環境ではダミーにフォールバックする
# ==========================================

# 移動速度(常に最高速で走行する。加減速ランプは行わない)
SPEED_PERCENT = 100

# ステアリングの不感帯(この範囲内の傾きは「倒していない」とみなす)
STEER_DEADZONE = 0.1

CONTROL_INTERVAL_SEC = 0.05  # モーター制御ループの周期(クライアントからの次のコマンドを待つ間もこの周期で駆動し続ける)


def clamp_speed(speed):
    try:
        speed = int(round(float(speed)))
    except (TypeError, ValueError):
        speed = 0
    return max(0, min(100, speed))


def calc_wheel_speeds(stick_x):
    """Lスティックの左右方向の傾きから左右モーターの速度(符号付き%)を算出する

    傾きが大きいほど内側のタイヤを減速し、さらに倒し切ると内側のタイヤを逆回転させて
    その場に近い旋回(信地旋回〜超信地旋回)ができるようにする。
    戻り値は SPEED_PERCENT に対する符号付きの値(負の値は逆回転を意味する)。

    例: stick_x=1.0 (右に最大まで倒す) の場合、
        右タイヤは-100(逆回転)、左タイヤは100のままとなり、その場で右に旋回する。
    """
    left_speed = SPEED_PERCENT
    right_speed = SPEED_PERCENT

    if abs(stick_x) > STEER_DEADZONE:
        # 不感帯を抜けた量を 0.0(不感帯境界) 〜 1.0(倒し切り) に正規化する
        turn_ratio = (abs(stick_x) - STEER_DEADZONE) / (1.0 - STEER_DEADZONE)
        # 内側のタイヤの比率を 1.0(直進と同じ速度) から 0.2 まで線形に変化させる
        inner_ratio = 1.0 - 0.8 * turn_ratio
        if stick_x > 0:
            # 右に倒している -> 右折 -> 右タイヤを減速・逆回転
            right_speed = SPEED_PERCENT * inner_ratio
        else:
            # 左に倒している -> 左折 -> 左タイヤを減速・逆回転
            left_speed = SPEED_PERCENT * inner_ratio

    return left_speed, right_speed


class RealMotorController:
    """dual_max14870_rpi の motors(pigpio 経由でPololu Dual MAX14870を制御)をラップする"""

    def __init__(self, motors, max_speed):
        self.motors = motors
        self.max_speed = max_speed

    def _to_signed_speed(self, direction, speed_percent):
        # speed_percent が負の場合は、そのタイヤだけ direction と逆方向に回転させる
        # (信地旋回・超信地旋回でその場に近い旋回を行うため)
        magnitude = clamp_speed(abs(speed_percent))
        value = int(round(magnitude / 100 * self.max_speed))
        forward = (direction == "forward")
        if speed_percent < 0:
            forward = not forward
        # 配線・モーターの向きによっては符号が逆になる場合があるので、
        # 実機で前後が逆に動く場合はここのプラスマイナスを入れ替える
        return value if forward else -value

    def drive(self, direction, left_speed, right_speed):
        left_value = self._to_signed_speed(direction, left_speed)
        right_value = self._to_signed_speed(direction, right_speed)
        # motor1 = 左輪, motor2 = 右輪(配線が逆の場合は入れ替える)
        self.motors.setSpeeds(-left_value, right_value)

    def stop(self):
        self.motors.setSpeeds(0, 0)


class DummyMotorController:
    """実機が無い環境(開発PCなど)向けの動作確認用ダミー"""

    def drive(self, direction, left_speed, right_speed):
        # 負の値は逆回転(信地旋回・超信地旋回)を表すのでそのまま表示する
        print(f"[Motor] {direction.upper()} L={left_speed:.0f}% R={right_speed:.0f}%")

    def stop(self):
        print("[Motor] STOP")


try:
    from dual_max14870_rpi import motors as _motors, MAX_SPEED as _MAX_SPEED
    motor = RealMotorController(_motors, _MAX_SPEED)
    print("[Motor] Hardware driver (Pololu Dual MAX14870) initialized.")
except Exception as e:
    print(f"[Motor] Hardware driver unavailable ({e}). Using dummy driver.")
    motor = DummyMotorController()


# 簡易カメラジェネレーター
# Raspbian Buster + Camera Module 1 は legacy カメラスタックのみ対応のため、
# bcm2835-v4l2 カーネルモジュールで /dev/video0 を有効化した上で cv2.VideoCapture(0) を使う
# (事前に `sudo modprobe bcm2835-v4l2` が必要)
#
# カメラはモータードライバーと同様にサーバー起動時に一度だけ開き、プロセスが生きている間は
# 開いたままにする(bcm2835-v4l2 はクライアント接続ごとに open/close を繰り返すと
# 再オープンに失敗しやすく、クライアント再接続時に映像が戻らなくなるため)。
camera = None
if cv2_imported:
    _camera_candidate = cv2.VideoCapture(0)
    if _camera_candidate.isOpened():
        camera = _camera_candidate
        print("[Camera] /dev/video0 opened.")
    else:
        _camera_candidate.release()
        print("[Camera] Could not open /dev/video0. /video_feed will return an empty stream.")


def generate_frames():
    if camera is None:
        return
    while True:
        success, frame = camera.read()
        if not success:
            # 一時的な読み取り失敗ではカメラを閉じず、少し待って読み直す
            time.sleep(0.1)
            continue
        _, buffer = cv2.imencode('.jpg', frame)
        frame_bytes = buffer.tobytes()
        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')

@app.get("/video_feed")
async def video_feed():
    """カメラ映像のストリーミング配信エンドポイント"""
    return StreamingResponse(generate_frames(), media_type="multipart/x-mixed-replace; boundary=frame")


class DriveState:
    """クライアントから最後に受信した操作状態(cmd/stick_x)を保持する"""

    def __init__(self):
        self.cmd = "stop"
        self.stick_x = 0.0

    def update(self, cmd, stick_x):
        self.cmd = cmd
        self.stick_x = stick_x


async def motor_control_loop(state: DriveState):
    """クライアントから次のコマンドが送られてくるまで、現在の操作状態を維持してモーターを駆動し続けるループ"""
    while True:
        if state.cmd == "stop":
            motor.stop()
        else:
            left_speed, right_speed = calc_wheel_speeds(state.stick_x)
            motor.drive(state.cmd, left_speed, right_speed)
        await asyncio.sleep(CONTROL_INTERVAL_SEC)


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    motor.stop()
    print("Client connected. Motor standby (STOP).")

    state = DriveState()
    control_task = asyncio.create_task(motor_control_loop(state))

    try:
        while True:
            data = await websocket.receive_text()
            message = json.loads(data)
            cmd = message.get("cmd", "stop")
            stick_x = message.get("stick_x", 0.0)

            if cmd not in ("forward", "backward"):
                cmd = "stop"

            state.update(cmd, stick_x)

            await websocket.send_text(json.dumps({"status": f"processed_{cmd}"}))

    except WebSocketDisconnect:
        print("Client disconnected. Safety STOP.")
    finally:
        control_task.cancel()
        try:
            await control_task
        except asyncio.CancelledError:
            pass
        motor.stop()

if __name__ == "__main__":
    motor.stop()
    uvicorn.run(app, host="0.0.0.0", port=8000)
  • モータードライバー制御コードとWebRTC用のスクリプトを一つのディレクトリにまとめて python3 server.py を実行。localhost:8000でサーバーが立ち上がったら成功。

注意点: ラズパイにスクリプトをクローンしようとした場合認証通らない上通そうと思うと手順が面倒(参考記事)ですのでクライアント側で使う予定のPCにPololu社のスクリプト入れて上記スクリプトとまとめて公開リポジトリを作成し、ラズパイへのクローンが終わったらリポジトリを消すか非公開に変えるかといったやり方もありです。

2. クライアント側の起動
  • クライアント側で使用するPCに仮想環境を作成して活性化(面倒なら飛ばしても良いです)。
  • 以下の形でclient.pyを用意。
import asyncio
import json
import os
import threading

# ジョイスティック入力のみ使うため、SDLの映像ドライバをdummyに固定する。
# こうしないとpygame.init()がCocoaのメインメニュー操作を試み、別スレッドから呼んだ際にクラッシュする(macOS)。
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")

import cv2
import pygame
import websockets

# ラズパイのIPアドレスとポートを指定
RASPBERRY_PI_IP = ""  # ←ご自身のラズパイのIPに変更してください
WS_URI = f"ws://{RASPBERRY_PI_IP}:8000/ws"
STREAM_URL = f"http://{RASPBERRY_PI_IP}:8000/video_feed"  # 将来的なカメラ配信用

# ゲームパッドのボタン/軸割り当て(お手元のコントローラーに合わせて調整してください)
BUTTON_A = 0  # 前進
BUTTON_B = 1  # 後退
AXIS_LSTICK_X = 0  # Lスティック 左右

# ステアリングの量子化しきい値(傾きを3段階の値に丸める。)
STEER_DEADZONE = 0.1 
STEER_THRESHOLD = 0.5 

SEND_INTERVAL_SEC = 0.05  # 状態監視のポーリング間隔(実際の送信は状態が変化した時のみ)

# 状態管理用
running = True


def camera_stream_main():
    """メインスレッドでカメラ映像を受信して表示する

    macOSではcv2.imshow等のGUI表示はメインスレッドからしか呼び出せないため、
    表示処理はメインスレッドで行い、ゲームパッド/WebSocket通信を別スレッドに追い出している。
    """
    global running
    # ※ラズパイ側で映像配信のエンドポイントを用意した場合に機能します
    cap = cv2.VideoCapture(STREAM_URL)
    while running:
        ret, frame = cap.read()
        if ret:
            cv2.imshow("Robot Camera View", frame)
            if cv2.waitKey(1) & 0xFF == ord('q'):
                running = False
                break
        else:
            # 映像がまだ取れない場合のダミー表示やウェイト
            cv2.waitKey(100)
    cap.release()
    cv2.destroyAllWindows()


def quantize_stick(stick_x):
    """Lスティックの傾きを離散値へ量子化する(3段階: 0, 0.5, 1.0)

    |x| <= 0.1                   -> 0.0(不感帯)
    0.1   < |x| < 0.5            -> 0.5
    0.5   <= |x| <= 1.0          -> 1.0
    0.775 <= |x| <= 1.0          -> 1.0
    """
    magnitude = abs(stick_x)
    if magnitude <= STEER_DEADZONE:
        quantized = 0.0
    elif magnitude < STEER_THRESHOLD:
        quantized = 0.5
    else:
        quantized = 1.0
    return quantized if stick_x >= 0 else -quantized


async def ws_controller_loop():
    """ゲームパッドの入力状態を監視し、変化があった時だけラズパイへ送信するループ"""
    global running

    # pygame(ゲームパッド)の初期化
    pygame.init()
    pygame.joystick.init()

    joystick = None
    if pygame.joystick.get_count() > 0:
        joystick = pygame.joystick.Joystick(0)
        joystick.init()
        print(f"[Joystick] Connected: {joystick.get_name()}")
    else:
        print("[Joystick] Warning: No gamepads found. Robot will stay stopped.")

    async with websockets.connect(WS_URI) as ws:
        print("Connected to robot server.")

        last_sent_payload = None

        while running:
            pygame.event.pump()

            a_pressed = bool(joystick.get_button(BUTTON_A)) if joystick else False
            b_pressed = bool(joystick.get_button(BUTTON_B)) if joystick else False
            stick_x = joystick.get_axis(AXIS_LSTICK_X) if joystick else 0.0

            # AボタンとBボタンが同時に押されていたらAボタン(前進)を優先する
            if a_pressed:
                direction = "forward"
            elif b_pressed:
                direction = "backward"
            else:
                direction = "stop"

            payload = {
                "cmd": direction,
                "stick_x": quantize_stick(stick_x),
            }

            # 入力状態に変化がある時だけ送信(サーバー側は次の送信があるまで現状態を維持する)
            if payload != last_sent_payload:
                await ws.send(json.dumps(payload))
                last_sent_payload = payload
                print(f"Sent command: {payload}")

            await asyncio.sleep(SEND_INTERVAL_SEC)

    pygame.quit()


def network_worker():
    """別スレッドでゲームパッド監視とWebSocket通信のイベントループを回す"""
    global running
    try:
        asyncio.run(ws_controller_loop())
    except websockets.exceptions.ConnectionClosed:
        print("Connection closed by server.")
    finally:
        running = False


if __name__ == "__main__":
    # ゲームパッド/WebSocket通信用スレッドの起動
    net_thread = threading.Thread(target=network_worker, daemon=True)
    net_thread.start()

    try:
        # GUI表示はメインスレッドで行う(macOSの制約)
        camera_stream_main()
    except KeyboardInterrupt:
        print("Interrupted by user, exiting.")
    finally:
        running = False
        net_thread.join(timeout=2)
  • 使用するゲームコントローラーを有線接続かBluetoothでペアリング。
  • python client.py(仮想環境使わない場合はpython3 client.py) でクライアント側を起動。クライアント側のPCでカメラモジュールで撮影中の映像が表示されたら成功。

8. 実行結果

 実行結果は以下の通りです。

 パラメータ調整はやってみましたが自然に旋回するとなるとこれが限界で予想以上に曲がらなかったです。ピボットターンとかしたい場合ルンバ方式(差動二輪駆動(ディファレンシャル・ドライブ))で最初から設計する必要がありますね。今回の目的はミニ四駆サーキットを走らせる事でしたので設計考え始めた頃にそのタイプは早々に却下してました。

9. まとめ

 2026年5月くらいに隙間時間にネットで電子パーツ眺め始めたとこから始まって7月にパーツ取り寄せて以後追加でパーツ取り寄せたり組み立ててコード書いて動かしたりを繰り返していたら気づいたら10月に入ってしまったのが予想外でした。8月末ごろに完成できれば記事のタイトルを「大人の自由研究」にする予定でした。
 ゲームコントローラーで制御できるようになったら次はローカルLLMやMCP、RAGと組み合わせてAIによる自動操縦とかPalmimo Devkitが納品されたらそれをハブにして今回開発したロボットや家にあるIoT、家電との連携をと計画してましたがAIとの連携を考えた場合もう少し小回りの効く設計にしないとまずいと痛感しました。
 今回作った作品はサーキットで走らせて遊ぶとかミニ四駆愛好家に需要が出たら同型の機体を受注製作するとかかもしれません。私としてはロボット製作未経験から自分でレイアウト設計して動かせたという成功体験の方が大きいので満足です。
 今回の研究の過程で取り寄せた電子パーツとか少し余りましたし足りない材料追加で取り寄せて小回りの効く設計で別の機体を作り直しても良いかもしれませんね。イメージとしてはお掃除機能を捨て去って高速でスピンしたり爆走したりしながら赤外線を飛ばすルンバみたいな。

 とりあえず完成した機体はどこかのタイミングで近場のミニ四駆サーキットで試走はしてみたいところです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?