以下の内容はhttps://nomolk.hatenablog.com/entry/2025/12/03/122841より取得しました。


Arduinoでステッピングモーターを動かす、モーターが回っても配線がねじれない絡まらない・スリップリング、アヒルを動かすプログラム【動画解説】

ヘヘヘ……ダッコフおもしろい……ダッコフ……ヘヘ……
おもしろすぎて、ダッコフの世界から帰って来れなくなってしまいました。
自分が帰ってくるのはあきらめて、代わりにアヒルくんに現実に出てきてもらうことにするよ。
今日は、ダッコフのアヒルを現実に召喚するよ。

www.youtube.com
www.nicovideo.jp

恒例の動画の技術解説エントリです。

…と、その前にひとつお知らせ!

「分割キーボードを自作する」ニコニコ動画アワード2025、ユーザ投票賞にノミネートされました!
Xとフォームでそれぞれ1日1回投票できるらしいので清き一票をお願いします!毎日!お前ならできる!やる気見せたれ!
douga-awards.nicovideo.jp
なにぶん候補が100作品あるので探すのが大変ですが、投稿順でソートして投稿日の3/26を探すと見つかりやすいよ。

というわけであらためまして動画の技術解説エントリです。

Arduinoでステッピングモーターを動かす

アヒルの方向転換に使っているのはステッピングモーター。

いつも使っているサーボモーターは0度~180度の間でしか回らないので、ぐるぐる何回転もさせることはできません。
ステッピングモーターならそれができます。わかりやすいところでは3DプリンタのXYZ軸それぞれには一つずつステッピングモーターがついていて、それでベルトを送って樹脂が出るところ(エクストルーダ―)を移動させています。

ちゃんとしたステッピングモーターってそこそこお値段するのですが、ホビー用なら28BYJ-48という異常に安い製品があります。
これなんか駆動モジュール付きを2セットで650円。どういうこと?

  • LEAUNGYOO

これを、例えばこんな感じでつないで…

こんな感じのスケッチで動かせます(動画中でチョコレート乗せてスイッチで動かしてるやつがこれ)

#include <AccelStepper.h>

const int switchForward = 3; //右回転スイッチ用ピン
const int switchBackward = 4; //左回転スイッチ用ピン

AccelStepper stepper(AccelStepper::FULL4WIRE, 8, 10, 9, 11); //制御モジュールへの接続ピン。IN1,IN3,IN2,IN4の順(紛らわしい)

const int stepsPerRevolution = 2048;
const int stepPerMove = stepsPerRevolution / 360 * 20; // 20度分まとめて動かす

float currentAngle = 0;

void setup() {
  pinMode(switchForward, INPUT_PULLUP);
  pinMode(switchBackward, INPUT_PULLUP);

  stepper.setMaxSpeed(1500); // スピードアップ
  stepper.setAcceleration(600);
  Serial.begin(9600);
}

void loop() {
  if (digitalRead(switchForward) == LOW) {
    stepper.setSpeed(800); // 前進
    stepper.runSpeed();
    currentAngle = fmod(currentAngle + 20, 360);
    Serial.println(currentAngle);
    delay(50);
  }
  if (digitalRead(switchBackward) == LOW) {
    stepper.setSpeed(-800); // 後退
    stepper.runSpeed();
    currentAngle = fmod(currentAngle - 20 + 360, 360);
    Serial.println(currentAngle);
    delay(50);
  }
}

コード中のコメントにも書きましたが、接続先のピンの書き方がIN1~IN4の順番ではないので注意です。

ライブラリは標準でstepperというのがあるものの、上のコードで使っているAccelStepperの方が動きが安定しており、また速く動かすこともできるのでお勧めです。ライブラリマネージャからインストールしましょう。

アヒルを動かしているプログラムがどんな感じになっているかは、最後の「アヒルを動かすプログラム」のところで見てください。

モーターが回っても配線がねじれない絡まらない・スリップリング

今回の最重要パーツ、スリップリングです。

配線を繋げた状態の物体をモーターで回転させると銅線がねじれたり絡まったりしてしまいますが、これを挟むことにより絡まないで回転させることができます。
こんな部品あるんですね。初めて知りました。

買ったのはこれ。

  • XUCHIL

選び方のポイントは、チャンネル数(配線できる銅線の数)、あとめちゃくちゃ大事なのが、底面にモーターの軸を差し込める穴がある物とないものがあります。

↓今回買ったもの

↓別の製品

間違って穴がないものを買ってしまうとモーターに取り付けられないので注意!

使い方は簡単。スリップリングの上下から出ている同じ色の線は電気的につながっているので、それを配線の途中に入れるだけ。
すごくいいものを知りました。

アヒルを動かすプログラム

ゲーム内のキャラの動きに合わせてアヒルを動かしていますが、ダッコフのプログラムを改造しているわけではありません。

マウスクリックやキー入力を監視したり、マウスカーソルの位置を調べたり、画面の特定座標のピクセル色を調べたりと、ゲームの外側の情報だけを使ってキャラクターの状態を調べています。

それらの情報を調べるのはPC上で動いているPythonスクリプトが行います。そこからシリアル経由でArduinoにメッセージを送り、メッセージを受信したArduinoが部品を動かします。

それぞれ何を調べてどの動きに変換しているかは、下記のようになっています。

Pythonで調べていること Arduino側の処理
マウスクリック 銃口LEDを光らせる
(Shiftを押していないとき)マウスカーソルの座標 ステッピングモーターを、画面中央から見たマウスカーソルの角度まで回す
(Shiftを押しているとき)WASDキーの押下状況 ステッピングモーターを、押された移動キーが指す方向に回す
HPゲージ内の特定のピクセルが赤くなったら ゲームオーバー処理(赤LEDを光らせてサーボモーターを動かす)

※ハート内の特定のピクセルがピンクでないときはメッセージを送らない

これはテキストで読むより動画で見てもらった方がわかりやすいかもしれません。
実際にはもう若干だけ複雑で、例えば銃の連射でマウスを長押ししているときにLEDを点滅させるのですが、その点滅処理もPython側がやっています。あとゲームオーバーからの復帰とか。

まず先に回路。こんな感じ。

5Vと書いてあるところはACアダプタで給電しています。
サーボとステッピングモーターの電源/GNDはArduinoの5Vから取らずにブレッドボード経由でACアダプタから直接取るのがポイントでしょうか。

そして最終的なコードはこんな感じ。
まずPC側のPython。

import serial
import threading
from pynput import mouse, keyboard
import math
import pyautogui
import time

# ====== Serial ======
ser = serial.Serial('COM5', 115200, timeout=0.1)

# ====== 状態管理 ======
state = {
    "mouse_pressed": False,
    "shift": False,
    "keys": set(),
    "angle": 0,
}

# サーボの状態(Python側で簡易管理して二重送信を防ぐ)
servo_state = "idle"  # idle, moved, returning

# クリック/ステッパー有効判定ピクセル
ACT_X, ACT_Y = 336, 1020
ACT_RGB = (255, 141, 141)

# サーボトリガーピクセルと色
TRIG_X, TRIG_Y = 376, 1020
TRIG_RGB = (182, 49, 48)
# 戻す判定:RGBそれぞれ20以上
def is_return_color(rgb):
    return rgb[0] >= 20 and rgb[1] >= 20 and rgb[2] >= 20

# ====== シリアル読み取り(Arduino → Pythonコンソール表示) ======
def read_serial():
    while True:
        if ser.in_waiting > 0:
            line = ser.readline().decode(errors='ignore').strip()
            print("[SERIAL]", line)

threading.Thread(target=read_serial, daemon=True).start()

# ====== ASWD → 角度 ======
def calc_aswd_angle(keys):
    dx = 0
    dy = 0
    if "W" in keys: dy -= 1
    if "S" in keys: dy += 1
    if "A" in keys: dx -= 1
    if "D" in keys: dx += 1
    if dx == 0 and dy == 0:
        return None
    return math.degrees(math.atan2(dx, -dy))

# ====== マウス座標 → 角度 ======
def calc_mouse_angle():
    w, h = pyautogui.size()
    cx, cy = w // 2, h // 2
    mx, my = pyautogui.position()

    dx = mx - cx
    dy = my - cy

    return math.degrees(math.atan2(dx, -dy))

# ====== ピクセルチェック ======
def pixel_rgb(x, y):
    try:
        return pyautogui.pixel(x, y)
    except Exception as e:
        # 取得できない環境でも落とさない
        return (0,0,0)

def matches(rgb, target=None, trig=False):
    print(rgb[0], rgb[1], rgb[2])
    if trig:
        # Rが180〜189台、GとBが40〜49台
        return 180 <= rgb[0] <= 189 and 40 <= rgb[1] <= 70 and 40 <= rgb[2] <= 70
    else:
        # 通常の厳密一致
        return rgb[0] == target[0] and rgb[1] == target[1] and rgb[2] == target[2]

# ====== Arduinoへ送信(コンソール表示付き、条件付き送信) ======
def send_state():
    global servo_state
    prev_allowed = False
    rgb_below_20 = False  # 追加:一度RGBが20以下になったか

    while True:
        # トリガーピクセルチェック(サーボ用)
        trig_rgb = pixel_rgb(TRIG_X, TRIG_Y)
        print(TRIG_X)
        # サーボ開始条件
        if servo_state == "idle" and matches(trig_rgb, trig=True):
            ser.write(b"SERVO_GO\n")
            ser.write(b"LED12 ON\n")
            print("[SEND] SERVO_GO + LED12 ON")
            servo_state = "moved"
            rgb_below_20 = False  # 初期化

        # サーボ戻す条件(RGBが一度20以下になった後、RGB>20判定)
        if servo_state == "moved":
            if trig_rgb[0] <= 20 or trig_rgb[1] <= 20 or trig_rgb[2] <= 20:
                rgb_below_20 = True  # 一度20以下になったことを記録
            elif rgb_below_20 and is_return_color(trig_rgb):
                ser.write(b"SERVO_RETURN\n")
                ser.write(b"LED12 OFF\n")
                print("[SEND] SERVO_RETURN + LED12 OFF")
                servo_state = "idle"
                rgb_below_20 = False

        # クリック/ステッパー有効判定ピクセル
        act_rgb = pixel_rgb(ACT_X, ACT_Y)
        print(ACT_X)
        allowed = matches(act_rgb, ACT_RGB)

        # LED制御(クリック時の既存LED): そのピクセルが一致しているときだけ反応
        if allowed:
            if state["mouse_pressed"]:
                msg = "LED ON\n"
            else:
                msg = "LED OFF\n"
            ser.write(msg.encode())
            print("[SEND]", msg.strip())
        else:
            ser.write(b"LED OFF\n")

        # 角度送信も許可時のみ送る(反転維持)
        if allowed:
            if state["shift"]:
                a = calc_aswd_angle(state["keys"])
                if a is None:
                    angle = calc_mouse_angle()
                else:
                    angle = a
            else:
                angle = calc_mouse_angle()
            state["angle"] = angle
            angle_msg = f"ANGLE:{-angle:.2f}\n"  # 反転は継続
            ser.write(angle_msg.encode())
            print("[SEND]", angle_msg.strip())

        time.sleep(0.1)

threading.Thread(target=send_state, daemon=True).start()

# ====== マウスイベント ======
def on_click(x, y, button, pressed):
    if button == mouse.Button.left:
        state["mouse_pressed"] = pressed

mouse.Listener(on_click=on_click).start()

# ====== キーボードイベント ======
def on_press(key):
    try:
        k = key.char.upper()
        if k in ["W", "A", "S", "D"]:
            state["keys"].add(k)
    except:
        pass

    if key == keyboard.Key.shift:
        state["shift"] = True

def on_release(key):
    try:
        k = key.char.upper()
        if k in ["W", "A", "S", "D"]:
            state["keys"].discard(k)
    except:
        pass

    if key == keyboard.Key.shift:
        state["shift"] = False

keyboard.Listener(on_press=on_press, on_release=on_release).start()

print("Running... COM5 opened.")
while True:
    time.sleep(1)


次にArduino側。

#include <AccelStepper.h>
#include <Servo.h>

#define MOTOR_PIN1 8
#define MOTOR_PIN2 9
#define MOTOR_PIN3 10
#define MOTOR_PIN4 11
#define LED_PIN 13        // クリック用LED(既存)
#define LED12_PIN 12      // サーボ動作中点灯LED
#define SERVO_PIN 6

AccelStepper stepper(AccelStepper::FULL4WIRE, MOTOR_PIN1, MOTOR_PIN3, MOTOR_PIN2, MOTOR_PIN4);
Servo servo;

const float stepPerDegree = 4096.0 / 360.0 / 2;

bool ledBaseState = false;
bool ledState = false;
unsigned long lastLedToggle = 0;

float targetAngle = 0;

// ===== サーボ制御関連 =====
bool servo_active = false;         // 「25°まで倒して→まだ戻っていない」の状態
unsigned long servo_start_time = 0;
unsigned long servo_duration = 2000;
float servo_from = 90;
float servo_to = 155;
bool servo_return_mode = false;    // true の時は 90 に戻る処理中l

// ===== 初期化 =====
void setup() {
  Serial.begin(115200);

  pinMode(LED_PIN, OUTPUT);
  pinMode(LED12_PIN, OUTPUT);

  servo.attach(SERVO_PIN);
  servo.write(90);  // 初期位置

  stepper.setMaxSpeed(1500);
  stepper.setAcceleration(600);
}

// ===== サーボを非ブロッキングで補間 =====
void startServoMove(float from, float to, unsigned long duration, bool returning) {
  servo_active = true;
  servo_return_mode = returning;
  servo_start_time = millis();
  servo_duration = duration;
  servo_from = from;
  servo_to = to;

  digitalWrite(LED12_PIN, HIGH);
}

void processServo() {
  if (!servo_active) return;

  unsigned long now = millis();
  unsigned long elapsed = now - servo_start_time;

  if (elapsed >= servo_duration) {
    servo.write(servo_to);

    if (servo_return_mode) {
      // 90°へ復帰が完了
      servo_active = false;
      digitalWrite(LED12_PIN, LOW);
    }
    // SERVO_GO 時は復帰命令が来るまで保持
    return;
  }

  float t = (float)elapsed / (float)servo_duration;
  float ang = servo_from + (servo_to - servo_from) * t;
  servo.write((int)ang);
}

// ===== ステッパー角度計算 =====
long calcTargetStep(float desiredAngle) {
  float currentAngle = stepper.currentPosition() / stepPerDegree;
  float diff = desiredAngle - currentAngle;

  // 最短距離補正
  while (diff > 180) diff -= 360;
  while (diff < -180) diff += 360;

  return stepper.currentPosition() + lround(diff * stepPerDegree);
}

// ===== メインループ =====
void loop() {
  unsigned long now = millis();

  // LED13 (クリック用) 点滅処理
  if (ledBaseState) {
    if (now - lastLedToggle >= (ledState ? 67 : 133)) {
      ledState = !ledState;
      digitalWrite(LED_PIN, ledState ? HIGH : LOW);
      lastLedToggle = now;
    }
  } else {
    digitalWrite(LED_PIN, LOW);
    ledState = false;
  }

  // サーボ補間処理
  processServo();

  // シリアル受信
  while (Serial.available()) {
    String line = Serial.readStringUntil('\n');
    line.trim();

    // ステッパーLED制御
    if (line == "LED ON") {
      ledBaseState = true;
      continue;
    }
    if (line == "LED OFF") {
      ledBaseState = false;
      continue;
    }

    // ステッパー角度
    if (line.startsWith("ANGLE:")) {
      targetAngle = line.substring(6).toFloat();
      while (targetAngle < 0) targetAngle += 360;
      while (targetAngle >= 360) targetAngle -= 360;
      long targetStep = calcTargetStep(targetAngle);
      stepper.moveTo(targetStep);
      continue;
    }

    // ====== サーボ操作 ======
    if (line == "SERVO_GO") {
      // 90° → 25°
      startServoMove(90, 155, 2000, false);
      continue;
    }

    if (line == "SERVO_RETURN") {
      // 25° → 90°
      startServoMove(155, 90, 2000, true);
      continue;
    }
  }

  stepper.run();
}

コードの中身についてはChatGPTに書いてもらってるのであんまり説明することはないですね…。

ChatGPTにプログラムを書いてもらう時のコツとして、実践しているのは以下のようなことです。

  • じわじわ機能追加していかないで、最初からできるだけ細かい仕様まで全部書いて一発出しを狙う
  • バグを直してもらおうとしてもなかなか直してくれないことがある(何回も同じ、うまくいかない修正を繰り返す)。そのときは新しいチャットを作り直してそっちにコードを貼り付けてチェックしてもらうとよい
  • どうしてもうまく動かないときは解説してもらいながら自分でデバッグ

だけど今回くらいの複雑さのプログラムならわりとサクッと作ってくれて、いい感じでした。ちょっと手で直したけど。

以上、簡単ですが技術解説でした。

www.youtube.com

高評価とチャンネル登録してもいいからね。
またね。




以上の内容はhttps://nomolk.hatenablog.com/entry/2025/12/03/122841より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14