PR

XYテーブルGUIに原点復帰・位置表示・ソフトリミットを実装【XYテーブル自作 第9回】

RaspberryPi

XYテーブル製作シリーズ 第9回

スポンサーリンク

はじめに:原点復帰・位置表示・ソフトリミットで「装置」にする

第8回で基本的なGUI操作ができるようになりました。実際の装置では、さらに原点復帰(Homing)・現在位置の管理・ソフトリミットの3つが重要です。CNCや産業装置でも必ず使われる機能で、今回これらを実装してXYテーブルコントローラの完成度を高めます。

この記事で分かること

  • GUIに「原点復帰」ボタンを追加し、X・Yを別スレッドで同時に原点復帰させる
  • current_pos 辞書でX軸・Y軸の現在位置(mm)を管理する
  • ThreadからGUIを直接触らず、queue.Queueroot.after(100, ...) で安全に位置表示を更新する
  • ソフトリミット(X_MAX_MMY_MAX_MM)で移動範囲外の指示を弾く
  • リミットスイッチをNC(ノーマルクローズ)で配線する理由(断線もフェイルセーフで検知)
  • 第9回 完成コード(xy_no9_gui.py、ZIP配布)

スポンサーリンク

ハードウェア構成(過去回のおさらい)

部品型番
マイコン ×1Raspberry Pi 3 Model A+
モータードライバ ×2A4988
モーター ×2NEMA17
電源12V 5A
リミットスイッチ ×2オムロン D2JW-01K1A1
# X軸 X_STEP=21 X_DIR=20 X_EN=16 X_MS1=17 X_MS2=27 X_MS3=22 X_RESET=5 X_SLEEP=6 X_LIMIT=4
# Y軸 Y_STEP=2  Y_DIR=26 Y_EN=18 Y_MS1=23 Y_MS2=24 Y_MS3=25 Y_RESET=12 Y_SLEEP=13 Y_LIMIT=14
A4988ドライバー2台・X軸Y軸リミットスイッチ・Raspberry Piの結線図
第7回と同じ結線

リミットスイッチをNC(ノーマルクローズ)にする理由:通常時は電流が流れ、スイッチが押されると接点が開いて電流が止まります。ケーブルが断線したときも電流が止まるため、断線も異常として検知できます(フェイルセーフ)。CNC・産業装置ではNCが基本です。


スポンサーリンク

原点復帰(Homing)をGUIに追加する

原点復帰は「原点方向へ移動 → リミットスイッチON → 停止 → バックオフ(少し戻る)→ その位置を現在位置0mmとする」という流れです。バックオフはスイッチが押されたままだと次の動作ができないために必要です。

def start_homing():
    tx = threading.Thread(target=homing_axis, args=("X軸", X_STEP, X_DIR, X_EN, X_LIMIT,
                          X_MS1, X_MS2, X_MS3, X_RESET, X_SLEEP))
    ty = threading.Thread(target=homing_axis, args=("Y軸", Y_STEP, Y_DIR, Y_EN, Y_LIMIT,
                          Y_MS1, Y_MS2, Y_MS3, Y_RESET, Y_SLEEP))
    tx.start()
    ty.start()
btn_homing = tk.Button(root, text="原点復帰", command=start_homing, bg="lightgreen")
btn_homing.grid(row=6, column=0, columnspan=2)

スポンサーリンク

現在位置の管理と、Queueで安全にGUI更新する

現在位置を current_pos 辞書で管理し、モーターが動くたびに更新します。

current_pos = {"X軸": 0.0, "Y軸": 0.0}

TkinterのStringVarで表示

x_pos_var = tk.StringVar(value="X:0.00mm")
y_pos_var = tk.StringVar(value="Y:0.00mm")
tk.Label(root, textvariable=x_pos_var).grid(row=8, column=0)

ThreadからGUIを直接更新してはいけない

Tkinterはスレッドセーフではなく、モータースレッドから直接 x_pos_var.set(...) するとエラーやフリーズの原因になります。そこで queue.Queue を挟みます。

message_queue = queue.Queue()

# モータースレッド側:メッセージを入れるだけ
message_queue.put("update_pos")

# GUIスレッド側:100msごとに取り出して画面更新
def process_queue():
    while not message_queue.empty():
        msg = message_queue.get()
        if msg == "update_pos":
            update_position_display()
    root.after(100, process_queue)

スポンサーリンク

ソフトリミット(移動範囲の制限)

ソフトウェア側で移動範囲を制限し、リニアガイドを飛び越すような動作を防ぎます。

X_MIN_MM = 0
X_MAX_MM = 255   # 機械のスペックに合わせて変更
Y_MIN_MM = 0
Y_MAX_MM = 255

# モーター動作前にチェック
target = current_pos[axis_name] + distance_mm
if axis_name == "X軸" and (target < X_MIN_MM or target > X_MAX_MM):
    print("X軸 ソフトリミット(範囲外)")
    return

スポンサーリンク

第9回 完成コード

# ============================================
# 第9回
# XYテーブルGUI + 原点復帰 + 位置表示 + ソフトリミット
# ============================================

import RPi.GPIO as GPIO
import time
import threading
import tkinter as tk
import queue    # queueモジュールをインポート  第9回追加

GPIO.setmode(GPIO.BCM)

# ============================================
# GPIO設定
# ============================================

X_STEP = 21
X_DIR  = 20
X_EN   = 16

X_MS1 = 17
X_MS2 = 27
X_MS3 = 22

X_RESET = 5
X_SLEEP = 6

X_LIMIT = 4


Y_STEP = 2
Y_DIR  = 26
Y_EN   = 18

Y_MS1 = 23
Y_MS2 = 24
Y_MS3 = 25

Y_RESET = 12
Y_SLEEP = 13

Y_LIMIT = 14

# ============================================
# モーション設定
# ============================================

BELT_PITCH = 2.0
PULLEY_TEETH = 32

MM_PER_REV = BELT_PITCH * PULLEY_TEETH

FULL_STEPS = 200
MICROSTEP = 16

STEPS_PER_REV = FULL_STEPS * MICROSTEP
STEPS_PER_MM = STEPS_PER_REV / MM_PER_REV

ACC_DEC_STEPS = 100
BACKOFF_STEPS = 200 # 原点復帰後のバックオフ量(ステップ数)

stop_flag = False

message_queue = queue.Queue()   # queueを追加   第9回追加

# ============================================
# 位置管理(第9回追加)
# ============================================

current_pos = {
    "X軸":0.0,
    "Y軸":0.0
}

# ソフトリミット(第9回追加)

X_MIN_MM = 0
X_MAX_MM = 255  # 適宜変更してください

Y_MIN_MM = 0
Y_MAX_MM = 255  # 適宜変更してください  


# ============================================
# GPIO初期化
# ============================================

def gpio_init_axis(STEP,DIR,EN,MS1,MS2,MS3,RESET,SLEEP):

    pins=[STEP,DIR,EN,MS1,MS2,MS3,RESET,SLEEP]

    for p in pins:
        GPIO.setup(p,GPIO.OUT)

    GPIO.setup(X_LIMIT,GPIO.IN,pull_up_down=GPIO.PUD_UP)
    GPIO.setup(Y_LIMIT,GPIO.IN,pull_up_down=GPIO.PUD_UP)

    GPIO.output(RESET,GPIO.HIGH)
    GPIO.output(SLEEP,GPIO.HIGH)

    GPIO.output(MS1,GPIO.HIGH)
    GPIO.output(MS2,GPIO.HIGH)
    GPIO.output(MS3,GPIO.HIGH)

    GPIO.output(EN,GPIO.LOW)


# ============================================
# 加減速
# ============================================

def get_intervals(total_steps,acc_steps,total_time):

    const_steps=max(total_steps-acc_steps*2,1)

    total_ratio=acc_steps*0.5+const_steps+acc_steps*0.5

    base_interval=total_time/total_ratio/2

    intervals=[]

    for i in range(acc_steps):
        intervals.append(base_interval*(acc_steps-i)/acc_steps)

    for i in range(const_steps):
        intervals.append(base_interval)

    for i in range(acc_steps):
        intervals.append(base_interval*(i+1)/acc_steps)

    return intervals


# ============================================
# モーター制御
# ============================================

def run_motor(axis_name,
              STEP,DIR,EN,
              MS1,MS2,MS3,
              RESET,SLEEP,
              distance_mm,
              cycles):

    global stop_flag
    global current_pos

    gpio_init_axis(STEP,DIR,EN,MS1,MS2,MS3,RESET,SLEEP)

    steps=int(abs(distance_mm)*STEPS_PER_MM)    #absを追加して正負両対応

    intervals=get_intervals(steps,ACC_DEC_STEPS,1.5)

    for c in range(cycles):

        if stop_flag:
            return

        # ソフトリミットチェック(第9回追加)

        target = current_pos[axis_name] + distance_mm

        if axis_name=="X軸":
            if target  X_MAX_MM:
                print("X軸 ソフトリミット(範囲外)")
                return

        if axis_name=="Y軸":
            if target  Y_MAX_MM:
                print("Y軸 ソフトリミット(範囲外)")
                return

        # 正転
        if axis_name=="X軸":
            GPIO.output(DIR,GPIO.LOW)
        else:
            GPIO.output(DIR,GPIO.HIGH)

        for t in intervals:

            if stop_flag:
                return

            GPIO.output(STEP,GPIO.HIGH)
            time.sleep(t)

            GPIO.output(STEP,GPIO.LOW)
            time.sleep(t)

        # 逆転
        if axis_name == "X軸":
            GPIO.output(DIR,GPIO.HIGH)
        else:
            GPIO.output(DIR,GPIO.LOW)

        for t in intervals:

            GPIO.output(STEP,GPIO.HIGH)
            time.sleep(t)

            GPIO.output(STEP,GPIO.LOW)
            time.sleep(t)

        # 位置更新(第9回追加)
        current_pos[axis_name]+=distance_mm  # 移動量を加算(正負両対応)
        message_queue.put("update_pos") # 移動完了後に位置表示更新を指示

    GPIO.output(EN,GPIO.HIGH)


# ============================================
# XY同時実行
# ============================================

def start_xy():

    global stop_flag
    stop_flag=False

    x_dist=float(entry_x_dist.get())
    x_cycle=int(entry_x_cycle.get())

    y_dist=float(entry_y_dist.get())
    y_cycle=int(entry_y_cycle.get())

    tx=threading.Thread(
        target=run_motor,
        args=("X軸",
              X_STEP,X_DIR,X_EN,
              X_MS1,X_MS2,X_MS3,
              X_RESET,X_SLEEP,
              x_dist,x_cycle)
    )

    ty=threading.Thread(
        target=run_motor,
        args=("Y軸",
              Y_STEP,Y_DIR,Y_EN,
              Y_MS1,Y_MS2,Y_MS3,
              Y_RESET,Y_SLEEP,
              y_dist,y_cycle)
    )

    tx.start()
    ty.start()


# ============================================
# 原点復帰(第9回追加)
# ============================================

def start_homing():

    global stop_flag
    stop_flag=False
    
    GPIO.setup(X_EN,GPIO.OUT)
    GPIO.setup(Y_EN,GPIO.OUT)
    GPIO.output(X_EN,GPIO.LOW)
    GPIO.output(Y_EN,GPIO.LOW)
    
    tx = threading.Thread(
        target=homing_axis,
        args=("X軸",
              X_STEP,X_DIR,X_EN,
              X_LIMIT,
              X_MS1,X_MS2,X_MS3,
              X_RESET,X_SLEEP)
    )

    ty = threading.Thread(
        target=homing_axis,
        args=("Y軸",
              Y_STEP,Y_DIR,Y_EN,
              Y_LIMIT,
              Y_MS1,Y_MS2,Y_MS3,
              Y_RESET,Y_SLEEP)
    )

    tx.start()
    ty.start()

def homing_axis(axis_name,
                STEP,DIR,EN,
                limit_pin,
                MS1,MS2,MS3,
                RESET,SLEEP):

    print(axis_name,"原点復帰開始")

    gpio_init_axis(STEP,DIR,EN,MS1,MS2,MS3,RESET,SLEEP)

    GPIO.setup(limit_pin,GPIO.IN,pull_up_down=GPIO.PUD_UP)

    # 原点方向
    if axis_name == "X軸":
        homing_dir = GPIO.HIGH
        backoff_dir = GPIO.LOW
    else:
        homing_dir = GPIO.LOW
        backoff_dir = GPIO.HIGH

    GPIO.output(DIR,homing_dir)

    time.sleep(0.05)

    # 原点探索
    while GPIO.input(limit_pin) == GPIO.LOW:

        GPIO.output(STEP,GPIO.HIGH)
        time.sleep(0.001)

        GPIO.output(STEP,GPIO.LOW)
        time.sleep(0.001)

        if stop_flag:
            return

    time.sleep(0.05)

    # BACKOFF
    GPIO.output(DIR,backoff_dir)

    for _ in range(BACKOFF_STEPS):

        GPIO.output(STEP,GPIO.HIGH)
        time.sleep(0.001)

        GPIO.output(STEP,GPIO.LOW)
        time.sleep(0.001)

    GPIO.output(EN,GPIO.HIGH)

    current_pos[axis_name] = 0.0
    message_queue.put("update_pos") # 原点復帰後に位置表示更新を指示

    print(axis_name,"原点復帰完了")


# ============================================
# 緊急停止
# ============================================

def stop_motor():

    global stop_flag
    stop_flag=True

    GPIO.output(X_EN,GPIO.HIGH)
    GPIO.output(Y_EN,GPIO.HIGH)


# ============================================
# 位置表示更新(第9回追加)
# ============================================

def update_position_display():

    x_pos_var.set(f"X:{current_pos['X軸']:.2f} mm")
    y_pos_var.set(f"Y:{current_pos['Y軸']:.2f} mm")

# ============================================
# Queue(第9回追加)
# ============================================

def process_queue():

    while not message_queue.empty():
        msg = message_queue.get()
        if msg == "update_pos":
            update_position_display()

    root.after(100, process_queue) # 100msごとにキューをチェック

# ============================================
# GUI
# ============================================

root=tk.Tk()
root.title("XY Table Controller")


tk.Label(root,text="X移動量(mm)").grid(row=0,column=0)
entry_x_dist=tk.Entry(root)
entry_x_dist.grid(row=0,column=1)

tk.Label(root,text="Xサイクル").grid(row=1,column=0)
entry_x_cycle=tk.Entry(root)
entry_x_cycle.grid(row=1,column=1)


tk.Label(root,text="Y移動量(mm)").grid(row=2,column=0)
entry_y_dist=tk.Entry(root)
entry_y_dist.grid(row=2,column=1)

tk.Label(root,text="Yサイクル").grid(row=3,column=0)
entry_y_cycle=tk.Entry(root)
entry_y_cycle.grid(row=3,column=1)


# 現在位置表示(第9回追加)

x_pos_var=tk.StringVar(value="X:0.00 mm")
y_pos_var=tk.StringVar(value="Y:0.00 mm")

tk.Label(root,textvariable=x_pos_var).grid(row=4,column=0)
tk.Label(root,textvariable=y_pos_var).grid(row=4,column=1)


tk.Button(root,
          text="XY同時実行",
          command=start_xy,
          bg="lightblue").grid(row=5,column=0,columnspan=2,pady=10)


tk.Button(root,
          text="緊急停止",
          command=stop_motor,
          bg="red",
          fg="white").grid(row=6,column=0,columnspan=2,pady=10)


# 原点復帰ボタン(第9回追加)

tk.Button(root,
          text="原点復帰",
          command=start_homing,
          bg="lightgreen").grid(row=7,column=0,columnspan=2,pady=10)


root.after(100, process_queue) # Queue(キュー)の監視処理開始
root.mainloop()
⬇ xy_no9_gui.py をダウンロード(ZIP)
原点復帰ボタンと現在位置表示を追加した第9回のXYテーブル操作GUI
第9回で完成するGUI


スポンサーリンク

よくある質問(FAQ)

Qなぜ Thread から直接 GUI(StringVar)を更新してはいけないのですか?

ATkinterはスレッドセーフに設計されていません。別スレッドからウィジェットやStringVarを操作すると、多くの場合は動くものの、タイミングによってクラッシュやフリーズが起きます。queue.Queue にメッセージを積み、root.after でGUIメインスレッドが取り出して更新するのが安全な定番パターンです。

Qroot.after(100, process_queue) の100は何ですか?

A100ミリ秒ごとにキューをチェックして画面を更新する、という意味です。小さくすると表示が滑らかになりますがCPU負荷が上がり、大きくすると表示がカクつきます。位置表示なら50〜200ms程度が実用的です。

Qソフトリミットの範囲(255mm)はどう決めればいいですか?

Aリニアガイドの有効ストロークから、両端に安全マージン(5〜10mm)を引いた値にします。原点復帰でバックオフした位置が0なので、そこから実際に動ける距離を実測して X_MAX_MM に設定します。

Q原点復帰中に緊急停止したいです。

Ahoming_axis のステップループ内で if stop_flag: return をチェックします。緊急停止ボタンで stop_flag=True にし、あわせて GPIO.output(EN, GPIO.HIGH) でドライバの電流を切ると即座に止まります。

Qcurrent_pos が実際の位置とずれていきます。

A脱調(パルスを送ったのにモーターが回っていない)が起きると、指令上の current_pos と実機がずれます。加減速を効かせて脱調を防ぎ、定期的に原点復帰して current_pos をリセットするのが確実です。


スポンサーリンク

XYテーブル製作シリーズ(全12回)

補足記事

第1.5回・補足 A4988の電流制限(Vref)調整方法|焼損を防ぐ計算式とテスターでの設定手順 モーターを回す前に必ずやる A4988 の電流調整。 🔀 番外編 L6470が動かない原因|A4988との違いとSPI通信・デイジーチェーンの罠 高機能ドライバ L6470 を A4988 と同じ感覚で使ってハマった話。 🗺️ 全体像 自作XYテーブルの全手順ロードマップ|Raspberry Pi+ステッピングモーターで2軸制御 第0回〜第11回の全工程をまとめたロードマップ。

ニンジン🥕アイコン

Follow me on X

本ブログの中の人「ニンジン🥕」はXやってます。新着記事はいち早くポストでお知らせしているので、見逃したくない方は @ninjin_py_vba をフォローしてみてください🥕

@ninjin_py_vba をフォロー

この記事が役に立ったら、シェアしてもらえると嬉しいです🥕

Xでポスト はてブ LINE Pocket

PROJECT LOG — SERIES INDEX
スポンサーリンク

シリーズ記事一覧

電子工作・自動化ツールの製作記録をシリーズごとにまとめています。気になるテーマからお読みください。


超音波距離センサーHC-SR04をRaspberry Pi Picoで使う|仕組み・配線・精度検証
.uds1-block { position: relative; overflow: hidden; border-radius: 12px; border: 1px solid #f0cba8; background: linear-g…
今週の注目ガジェット〜09/07週〜|電子工作・自作向け新製品まとめ(TickrCast/Radxa Linkr ほか)
ニンジンのガジェット通信 09/07週。ESP32-P4の自作気象モニター、極小IP-KVM「Radxa Linkr」、ESP32のDINレール型PLC、LTE開発キットなど、電子工作で使えそうな新製品5つを解説。
サーボモータの角度をタクトスイッチで可変|OLED(SSD1306)に現在角度をリアルタイム表示
Raspberry Pi Pico 2W×MG996RにタクトスイッチとOLED(SSD1306)を追加。ボタンで角度を可変しながら現在角度をリアルタイム表示するMicroPython実装を、framebufの文字拡大やサーボの遊び対策まで実機ベースで解説します。
MG996RサーボモーターをRaspberry Pi Pico 2Wで動かす:PWM制御とキャリブレーション実践
MG996RサーボモーターをRaspberry Pi Pico 2Wで動かす方法を解説。公称180度のはずが実測198度だった個体差の原因究明から、ホーンのズレ補正、タクトスイッチでの角度制御まで実機検証の様子を紹介します。
Tapo P110Mの消費電力をExcelレポート化する【年次集計・電気代試算・セキュリティ判断編】
FastAPI+openpyxlでTapo P110Mの消費電力をExcelレポート化。15秒間隔の生ログを日次集計する設計や、31円/kWhでの電気代試算、外部公開を見送った理由まで解説します。

コメント