PR

サーボモータの角度をタクトスイッチで可変|OLED(SSD1306)に現在角度をリアルタイム表示

ハードウェア制御 / 電子工作

MG996R × Pico 2W 発展編

スポンサーリンク

はじめに

こんにちは。ニンジンです🥕

前回の記事で、Raspberry Pi Pico 2Wとメタルギアサーボ「MG996R」を組み合わせ、タクトスイッチを押すと決まった角度サイクル(0°→90°→0° など)で動かすところまで作りました。実測してみると公称180°のはずが198°まで回ったり、サーボホーンが狙った角度に取り付けられなかったりと、実機ならではのクセがいろいろ出てきた回でした。

今回はその発展編です。前回あえて使わなかった0.96インチのOLEDディスプレイ(SSD1306)を追加し、次のものを作ります。

  • タクトスイッチの「+」「−」ボタンで角度を1°ずつ連続的に可変できる
  • 押しっぱなしにすると早送りで角度が変わる(押し続けるほど速くなる)
  • 現在の角度をOLEDに数字と横バーグラフでリアルタイム表示する

前回同様、作りながら出てきたつまずき(サーボのギアの遊び、OLED描画が早送りの足を引っ張る問題など)も含めて紹介します。まずは完成した様子をご覧ください。

▲ +/−ボタンで角度を可変し、OLEDに現在角度が表示される様子

この記事で分かること

  • framebufで標準8×8フォントを拡大描画し、OLEDに大きな数字を表示する方法
  • time.ticks_ms()ベースの状態管理で、sleepに頼らないキビキビしたボタン操作を作る方法
  • タクトスイッチの「単押しで1°・長押しで早送り(加速リピート)」の実装
  • 金属ギアサーボのバックラッシュ(遊び)など、実機で初めて分かるクセと対処

スポンサーリンク

+/−タクトスイッチでMG996Rの角度を可変しOLED(SSD1306)に現在角度を表示する構成

今回作るものの全体像です。

  • タクトスイッチ「+」を押す → 角度が1°増える(押しっぱなしで早送り)
  • タクトスイッチ「−」を押す → 角度が1°減る(押しっぱなしで早送り)
  • 現在角度(0〜180°)をOLEDに大きな数字+横バーで表示
  • 0°と180°で頭打ち(それ以上は動かない)

前回 servo-moter001 の配線に、OLEDのI2C配線4本を足すだけです。

使用部品(servo-moter001に0.96インチOLED SSD1306を追加)

前回使った部品に、OLEDディスプレイを1点追加します。OLEDモジュールの端子は、基板のシルク印字が左から GND / VDD / SCK / SDA の順です(SCKはSCLと同じ意味)。

0.96インチ OLEDディスプレイ(I2C接続・SSD1306)
今回追加

現在角度を数字+バーで表示する主役

0.96インチ OLEDディスプレイ(I2C接続・SSD1306)

I2C接続の128×64モノクロ有機ELディスプレイ。配線がSDA/SCLの2本だけで済みます。熱中症警戒アラート表示器の記事で使ったものと同じ品です。

I2C接続SSD1306128×64
MG996R メタルギア・デジタルサーボ
実機使用

今回の実機はこちら

MG996R メタルギア・デジタルサーボ

180度の可動範囲を持つ、ホビー向けの定番メタルギアサーボです。前回・今回ともこの実機で検証しています。

メタルギア高トルク180度

Amazonは在庫切れのため掲載していません(楽天市場のみ)

MG995 メタルギア・デジタルサーボ(2個セット)
代替品

MG996Rが入手困難な場合に

MG995 メタルギア・デジタルサーボ(2個セット)

MG996Rが品薄・入手困難な場合の代替候補です。メタルギア仕様でスペックはほぼ同等です。

メタルギア2個セット代替品
Raspberry Pi Pico 2WH
必須

無線対応・ヘッダー実装済み

Raspberry Pi Pico 2WH

RP2350搭載、Wi-Fi/Bluetooth対応で、ヘッダーピンもはんだ付け済みのモデルです。届いてすぐブレッドボードで使えます。

RP2350搭載Wi-Fi/Bluetoothヘッダー実装済み
ブレッドボード 400穴
必須

配線の土台に

ブレッドボード 400穴

サーボ・タクトスイッチ・OLED・電源周りをまとめて配線するための土台です。400穴サイズで十分です。

ハンダ付け不要400ポイント
Freenove ブレークアウトボード
必須

配線ミスの防止に

Freenove ブレークアウトボード

Pico(全世代対応)を差し込むだけで全ピンをラベル表示してくれる拡張ボードです。

全世代対応ピン名表示
ELEGOO 50pcs オスメスジャンパーワイヤ
必須

配線に足りないワイヤーはこれで

ELEGOO 50pcs オスメスジャンパーワイヤ

ミニブレッドボード付属で、追加購入なしですぐ使い始められます。OLEDやサーボの接続に。

オスメス200mmミニブレッドボード付属
USB電源ブレイクアウトボード
必須

USB電源をブレッドボードに引き込む

USB電源ブレイクアウトボード

USB Type-Aメスコネクタをブレッドボードに挿せるピンヘッダー形状に変換する部品です。ケーブルを傷めずにサーボ用のUSB電源を引き込めます。

USB Type-ADIP変換
タクトスイッチ(LeTradeJP 10種類セット)
必須

+/−ボタンに2個使用

タクトスイッチ(LeTradeJP 10種類セット)

サイズ違いのタクトスイッチが10種類入ったセットです。今回はこのうち2個を+/−ボタンとして使用します。

タクトスイッチ10種類セット
上記に加え、以下は手持ちの汎用部品を使用しました。特定商品ではないため紹介は割愛します。
  • USB ACアダプタ(5V/2A)+ USBケーブル1本(被覆を剥いて電源線として使用)
  • 電解コンデンサ 16V/220μF(電源ラインの安定化用)

スポンサーリンク

結線:OLEDのSDA=GP0 / SCL=GP1 / VDD=3V3(OUT)、サーボとスイッチは前回のまま

配線は下図の通りです。サーボの信号線・電源・GND、タクトスイッチ2個は前回 servo-moter001 とまったく同じ配置のまま。そこにOLEDの4本(SDA / SCL / VDD / GND)を追加します。

今回の結線図(前回にOLEDのI2C配線4本を追加)
▲ 今回の結線図(前回にOLEDのI2C配線4本を追加)
線・部品接続先(Pico 2W)物理ピン
MG996R 信号線(オレンジ)GP1520
MG996R 電源線(赤)外部USB電源 +5V
MG996R GND線(茶)外部USB電源GND + Pico GND(共通)
タクトスイッチ「+」GP14 – GND間19
タクトスイッチ「−」GP13 – GND間17
OLED SDAGP01
OLED SCL(SCK)GP12
OLED VDD3V3(OUT)36
OLED GNDGND38
OLEDモジュールの端子とジャンパーの接続
▲ OLEDの端子(GND/VDD/SCK/SDA)への接続
Pico側 GP0・GP1 への接続
▲ Pico側 GP0(1番)・GP1(2番)への接続
Pico側 3V3(OUT)・GND への接続
▲ OLEDの電源はPicoの3V3(OUT)とGNDから
結線全体
▲ 結線全体
OLEDの電源をサーボ用の5Vから取らない:OLEDのVDDは、サーボ用に用意した外部5VではなくPicoの3V3(OUT)から取ります。サーボは動作時に大きな電流が流れて電源電圧が一瞬下がるため、同じラインからOLEDに給電すると表示が乱れたりリセットしたりします。
共通GNDは前回同様に必須:サーボ用外部電源のGND、PicoのGND、OLEDのGNDは、すべて1本の共通ラインにまとめます。基準電位(0V)がズレると、サーボのPWMもOLEDのI2C通信も誤動作します。

スポンサーリンク

OLED(SSD1306)に大きな数字を表示する — framebufで8×8フォントを拡大描画する

MicroPythonのssd1306ライブラリには文字を描くoled.text()がありますが、フォントは8×8ピクセル固定です。128×64のOLEDに「128 deg」と出しても7文字×8ピクセル=56ピクセルぶんしかなく、離れると読めません。今回は角度をパッと見たいので、この標準フォントを拡大して大きく表示します。

i2c.scan()で0x3Cを確認する

まずは配線が正しいか、I2Cバス上にOLEDが見えているかを確認します。

from machine import Pin, I2C

i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
print("I2Cデバイス検出:", i2c.scan())  # [60] (0x3C) が出れば正常

I2C(0, ...)はI2Cコントローラー0番を使う指定です。Raspberry Pi Picoでは、I2C0で使えるSDA/SCLのピンの組み合わせが決まっており(GP0/GP1、GP4/GP5、GP8/GP9 …)、今回はGP0=SDA、GP1=SCLを使います。実行してI2Cデバイス検出: [60]と表示されれば配線OKです(60は0x3C)。[](空リスト)が出たら、SDA/SCLの挿し間違い、ジャンパーの半挿し、VDD/GNDの未接続を疑ってください。

FrameBufferに描いて拡大コピーする(text_scaled)

標準フォントを拡大する仕組みはシンプルです。小さなFrameBufferに一度text()で普通に描き、その1ピクセルずつを読み取って、点いているピクセルをscale×scaleの四角として本番のoledに打ち直します。

import framebuf

def text_scaled(fb, s, x, y, scale, color=1):
    # 標準8x8フォントの文字列を scale 倍に拡大して描画する
    w = len(s) * 8
    tmp = framebuf.FrameBuffer(bytearray(8 * ((w + 7) // 8)), w, 8, framebuf.MONO_HLSB)
    tmp.fill(0)
    tmp.text(s, 0, 0, 1)
    for ty in range(8):
        for tx in range(w):
            if tmp.pixel(tx, ty):
                fb.fill_rect(x + tx * scale, y + ty * scale, scale, scale, color)

framebuf.FrameBuffer(buffer, width, height, format)は、自前のバイト列をピクセルバッファとして扱えるようにするクラスです。MONO_HLSBは1ビット=1ピクセルのモノクロ形式で、高さ8・幅wなら必要なバイト数は8 * ((w + 7) // 8)になります。scale=2にすると「128 deg」で約88ピクセル、128幅のOLEDにちょうど収まります。

横バーグラフをrect()とfill_rect()で描く

数字だけだと直感的に分かりにくいので、0〜180°を横バーでも表示します。枠をrect()、中身をfill_rect()で描くだけです。

def draw_status(angle):
    oled.fill(0)
    text_scaled(oled, "{:>3d} deg".format(angle), 4, 6, 2)   # 上段: 数字(2倍角)
    bx, by, bw, bh = 6, 42, 116, 16                          # 下段: バー
    oled.rect(bx, by, bw, bh, 1)                             # 枠
    fill_w = int((angle / 180) * (bw - 4))                   # 角度に応じた塗り幅
    oled.fill_rect(bx + 2, by + 2, fill_w, bh - 4, 1)        # 中身
    oled.show()

"{:>3d}".format(angle)で右寄せ3桁にしておくと、1桁・2桁・3桁で数字の位置がガタつきません。まずはOLED単体の動作確認用に、0/45/90/135/180°を順番に表示し続けるスクリプトを作りました。

対応ファイル: servo-moter002-test001.py

test001.py実行時のシェル(I2Cデバイス検出:[60])
▲ test001.py実行時のシェル(I2Cデバイス検出:[60])
0°表示
▲ 0°(バーは空)
90°表示
▲ 90°(バーが半分)
180°表示
▲ 180°(バーが満タン)

▲ 表示が0°→180°まで切り替わる様子。ここまで表示できれば結線OK

✓ 動作確認方法

  • ThonnyでコードをF5実行し、シェルに I2Cデバイス検出: [60] が出る
  • OLEDに「0 deg」「45 deg」…と1秒おきに切り替わる
  • 数字の下のバーが角度に応じて伸びる

スポンサーリンク

タクトスイッチの「単押しで1°・長押しで早送り」をMicroPythonで実装する

次に、タクトスイッチで角度を可変する部分を作ります。前回同様、タクトスイッチはNO(ノーマルオープン)タイプで、内部プルアップを使い「押すとLOW(0)になる」形で読み取ります。

button_up = Pin(14, Pin.IN, Pin.PULL_UP)    # +
button_down = Pin(13, Pin.IN, Pin.PULL_UP)  # −

sleepで待つ素朴版は方向転換の反応が鈍い(test002)

最初に書いたのが次の形です(OLEDはまだ付けず、コンソールに角度をprintして確認)。

while True:
    for pin, delta in ((button_up, +1), (button_down, -1)):
        if not pressed(pin):
            continue
        step(delta)                       # 1回目は即時
        time.sleep_ms(REPEAT_DELAY_MS)    # 単押しならここで指を離す
        interval = START_INTERVAL_MS
        while pressed(pin):                # 押し続けている間はリピート
            if not step(delta):
                break
            time.sleep_ms(interval)
            interval = max(MIN_INTERVAL_MS, interval - ACCEL_MS)
        time.sleep_ms(30)                 # チャタリング対策
    time.sleep_ms(10)

対応ファイル: servo-moter002-test002.py

一応動くのですが、time.sleep_ms()で処理を止めて待つ作りなので、以下の問題があります。

  • +を押した直後、必ずREPEAT_DELAY_MS(0.4秒)のsleepが入るため、その間はほかのボタンを一切見ていない
  • +で角度を上げてすぐ−を押す、というとき最悪0.4秒ぶん反応が遅れる
  • 端(0°/180°)でボタンを押しっぱなしにしていると、何もせず0.4秒ごとに空回りする

実際に触ると「キビキビ感がない」とすぐ分かります。この素朴版は、次のtest003で直す「悪い例」として置いておきます。

test002.py実行時のシェル出力
▲ test002.py実行時のシェル出力

▲ +/−ボタンでサーボが動く様子(OLEDなし)

time.ticks_ms()ベースの状態管理に書き換える(test003)

sleepで待つのをやめ、「今の時刻」と「次に動かす予定時刻」を比べる方式に変えます。ループは止めずに回し続け、各ボタンが「今、動かすべきか?」を毎回自分で判断します。

class RepeatButton:
    def __init__(self, pin, delta):
        self.pin = pin
        self.delta = delta
        self.holding = False
        self.t_next = 0
        self.interval = FAST_START_MS
        self.t_release = 0

    def poll(self, now):
        # 今このボタンで動かすべきなら delta を、そうでなければ 0 を返す
        down = self.pin.value() == 0

        if not down:
            if self.holding:
                self.holding = False
                self.t_release = now
            return 0

        if not self.holding:
            if time.ticks_diff(now, self.t_release) < DEBOUNCE_MS:
                return 0  # 離した直後のチャタリングを弾く
            self.holding = True
            self.interval = FAST_START_MS
            self.t_next = time.ticks_add(now, REPEAT_DELAY_MS)
            return self.delta  # 押した瞬間の1回

        if time.ticks_diff(now, self.t_next) >= 0:
            self.t_next = time.ticks_add(now, self.interval)
            self.interval = max(FAST_MIN_MS, self.interval - ACCEL_MS)
            return self.delta  # 早送り
        return 0

時刻の比較にtime.ticks_ms()をそのまま<-で扱わず、time.ticks_diff()time.ticks_add()を使うのがポイントです。ticks_ms()の値は一定時間でオーバーフロー(桁あふれ)して0に戻るため、素朴に引き算すると桁あふれの瞬間におかしくなります。

メインループはこうなります。sleepが3msだけになり、+↔−の切り替えは体感で分からないレベルまで即応になりました。

while True:
    now = time.ticks_ms()
    for b in buttons:
        d = b.poll(now)
        if d == 0:
            continue
        new_angle = max(0, min(180, angle + d))
        if new_angle != angle:
            angle = new_angle
            set_angle(angle)
            pending_draw = True
    ...
    time.sleep_ms(3)

押し続けるほど早送りが加速する処理

早送りの「だんだん速くなる」は、リピート間隔intervalを1ステップごとにACCEL_MSずつ縮め、FAST_MIN_MSで頭打ちにするだけです。

REPEAT_DELAY_MS = 300     # 単押し -> 早送りに入るまでの待ち
FAST_START_MS = 150       # 早送り開始直後の間隔
FAST_MIN_MS = 12          # 最速の間隔
ACCEL_MS = 14             # 1ステップごとに間隔を縮める量
  • ちょい押し → 1°だけ動く
  • 押し続ける → 0.3秒後に早送り開始、150ms間隔から徐々に12ms間隔まで加速

数値は好みで調整してください。FAST_MIN_MSを小さくしすぎると、後述のOLED描画が追いつかなくなります。

角度を0〜180にクランプして可動域外でサーボを唸らせない

角度の計算は前回 servo-moter001 の実測キャリブレーション値をそのまま流用します。set_angle()の中で0〜180にクランプしておくことで、可動域の外を指令してサーボが唸る事故を防ぎます。

MIN_US = 500
MAX_US = 2340
HORN_OFFSET_DEG = 7

def set_angle(angle):
    angle = max(0, min(180, angle))          # 0〜180にクランプ
    corrected = angle + HORN_OFFSET_DEG
    pulse_us = MIN_US + (corrected / 180) * (MAX_US - MIN_US)
    servo.duty_ns(int(pulse_us * 1000))

さらにメインループ側でも、new_angleが現在角度と同じ(=すでに0°または180°)ならset_angle()を呼ばないようにしているので、端で押し続けてもサーボには何も送られず、静かに止まったままになります。


スポンサーリンク

実機で分かったつまずきポイント

方向を変えると最初の1〜2°が動かない — 金属ギアサーボのバックラッシュ(遊び)

test003を触っていて気づいた違和感がこれです。例えば180°まで上げてから「−」ボタンを押していくと、コンソールには 角度: 179 178 177 …とちゃんと出ているのに、サーボのホーンは177°になるまで動きませんでした。そこから先は1°ずつ普通に動きます。

コンソールに数字が出ている=set_angle()は毎回呼ばれているので、これはプログラムのバグではありません。サーボのギアの「遊び(バックラッシュ)」です。ギア同士のかみ合わせにはわずかな隙間があり、ある方向に回した後で逆向きの指令を出すと、まずこの隙間を詰めるぶんだけ空走してから、やっとホーンが動き出します。範囲の端だけでなく、90°付近で方向転換しても同じだけ遅れます(=方向反転のときだけ)。

対処としては、「安い金属ギアサーボにはこの遊びがある。正確な位置決め用途には向かない」と割り切るのが現実的です。今回作っているのは「表示された数字=実際の角度」であることが売りの装置なので、下手にプログラムで補正するとかえって表示と実機がズレます。

バックラッシュの量は0°側と180°側で非対称だった

さらに細かく見ると、遊びの量が可動域の端で違いました。

方向転換した位置動き出すまでの遊び
0°付近約1°(1回ぶん動かず、次で2°ぶんまとめて動く)
180°付近約2°(2回ぶん動かず、その後まとめて動く)

前回 servo-moter001 で、この個体は「パルス幅の上限側に約18°ぶんの余裕があり、下限側は約3°ぶんしか余裕がない」という非対称なクセがありました。今回のバックラッシュの非対称(上=ルーズ、下=タイト)も同じ傾向で、この個体のギアやポテンショメータの特性が上下で偏っているのだと思われます。安価なクローン品ではこの手の個体差が普通にあります。

OLEDのshow()(I2C通信)が早送りのボトルネックになる — 描画を間引く

最初、角度が変わるたびにdraw_status()(内部でoled.show())を呼んでいたところ、早送りの最高速を上げても頭打ちになる現象が出ました。oled.show()は128×64=1024バイトのバッファをI2C(400kHz)で丸ごと送るため、1回あたり20ms以上かかります。早送りの最速間隔は12msなので、ステップごとに描画すると、サーボの動きが描画速度に引きずられて遅くなるわけです。

対策として、サーボのステップ処理とOLED描画を分離しました。

DRAW_MIN_INTERVAL_MS = 40   # OLED再描画の最短間隔

while True:
    now = time.ticks_ms()
    for b in buttons:
        d = b.poll(now)
        ...
        if new_angle != angle:
            angle = new_angle
            set_angle(angle)          # サーボは毎ステップ即反映
            pending_draw = True

    # OLED は最短 40ms おきにまとめて描画する
    if pending_draw and time.ticks_diff(now, last_draw) >= DRAW_MIN_INTERVAL_MS:
        draw_status(angle)
        last_draw = now
        pending_draw = False

    time.sleep_ms(3)

サーボは1°ごとに滑らかに動き、OLEDの数字・バーは最大25fps程度で追従します。早送りを止めたときの最終角度はpending_drawフラグで必ず描画されるので、表示がズレて止まることはありません。

【気になる人向け】方向反転時に1°余分に送る簡易バックラッシュ補正とそのトレードオフ

どうしてもバックラッシュが気になる場合、「直前の動作方向を覚えておき、方向が反転した最初の1回だけ、余分に動かして遊びを詰める」という補正が考えられます。

BACKLASH_DEG = 1
last_move_dir = 0

def move(delta):
    global angle, last_move_dir
    target = max(0, min(180, angle + delta))
    if target == angle:
        return False
    d = 1 if delta > 0 else -1
    extra = 0
    if last_move_dir != 0 and d != last_move_dir:
        extra = BACKLASH_DEG * d          # 方向反転時だけ遊びを先に詰める
    set_angle(target + extra)             # サーボには少し多めに送る
    angle = target                        # 表示は指令値どおり
    last_move_dir = d
    return True

ただしトレードオフがあります。

  • 遊びがちょうどBACKLASH_DEGならホーン位置と表示が一致するが、遊びの量が違えば最大1°ズレる
  • 方向反転後、ホーンは常にBACKLASH_DEGぶん先に送られた位置で保持される(停止位置が表示と1°ズレる)
  • 前述の通り遊びは0°側と180°側で非対称なので、片方に合わせるともう片方が合わない

「表示=実角度」を厳密に守りたいこの装置では、補正は入れないほうがすっきりします。あくまで「こういう手もある」という参考です。


スポンサーリンク

完成:本番プログラム(main.py)全文

ここまでの要素をすべて統合したものが以下です。Pico本体にmain.pyとして保存すれば、PCなしで単体動作します。

# servo-moter002 本番プログラム (main.py)
# +/−タクトスイッチで MG996R の角度を可変し、OLED(SSD1306) に
# 現在角度を数値+横バーグラフでリアルタイム表示する
from machine import Pin, PWM, I2C
import framebuf
import ssd1306
import time

# ===== サーボ設定 (servo-moter001 の実測キャリブレーション値) =====
servo = PWM(Pin(15))
servo.freq(50)

MIN_US = 500
MAX_US = 2340
HORN_OFFSET_DEG = 7


def set_angle(angle):
    angle = max(0, min(180, angle))
    corrected = angle + HORN_OFFSET_DEG
    pulse_us = MIN_US + (corrected / 180) * (MAX_US - MIN_US)
    servo.duty_ns(int(pulse_us * 1000))


# ===== OLED 設定 =====
i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
print("I2Cデバイス検出:", i2c.scan())
oled = ssd1306.SSD1306_I2C(128, 64, i2c)


def text_scaled(fb, s, x, y, scale, color=1):
    # 標準8x8フォントの文字列を scale 倍に拡大して描画する
    w = len(s) * 8
    tmp = framebuf.FrameBuffer(bytearray(8 * ((w + 7) // 8)), w, 8, framebuf.MONO_HLSB)
    tmp.fill(0)
    tmp.text(s, 0, 0, 1)
    for ty in range(8):
        for tx in range(w):
            if tmp.pixel(tx, ty):
                fb.fill_rect(x + tx * scale, y + ty * scale, scale, scale, color)


def draw_status(angle):
    oled.fill(0)
    text_scaled(oled, "{:>3d} deg".format(angle), 4, 6, 2)
    bx, by, bw, bh = 6, 42, 116, 16
    oled.rect(bx, by, bw, bh, 1)
    fill_w = int((angle / 180) * (bw - 4))
    oled.fill_rect(bx + 2, by + 2, fill_w, bh - 4, 1)
    oled.show()


# ===== ボタン(状態管理方式) =====
REPEAT_DELAY_MS = 300      # 単押し -> 早送りに入るまでの待ち
FAST_START_MS = 150        # 早送り開始直後の間隔
FAST_MIN_MS = 12           # 最速の間隔
ACCEL_MS = 14              # 1ステップごとに間隔を縮める量
DEBOUNCE_MS = 20           # 離した後、この時間は次の押下を無視する
DRAW_MIN_INTERVAL_MS = 40  # OLED再描画の最短間隔


class RepeatButton:
    def __init__(self, pin, delta):
        self.pin = pin
        self.delta = delta
        self.holding = False
        self.t_next = 0
        self.interval = FAST_START_MS
        self.t_release = 0

    def poll(self, now):
        # 今このボタンで動かすべきなら delta を、そうでなければ 0 を返す
        down = self.pin.value() == 0

        if not down:
            if self.holding:
                self.holding = False
                self.t_release = now
            return 0

        if not self.holding:
            if time.ticks_diff(now, self.t_release) < DEBOUNCE_MS:
                return 0  # チャタリング中の誤検出を弾く
            self.holding = True
            self.interval = FAST_START_MS
            self.t_next = time.ticks_add(now, REPEAT_DELAY_MS)
            return self.delta  # 押した瞬間の1回

        if time.ticks_diff(now, self.t_next) >= 0:
            self.t_next = time.ticks_add(now, self.interval)
            self.interval = max(FAST_MIN_MS, self.interval - ACCEL_MS)
            return self.delta  # 早送り
        return 0


button_up = Pin(14, Pin.IN, Pin.PULL_UP)
button_down = Pin(13, Pin.IN, Pin.PULL_UP)
buttons = [RepeatButton(button_up, +1), RepeatButton(button_down, -1)]

# ===== メイン =====
angle = 90
set_angle(angle)
draw_status(angle)
print("準備完了。現在角度:", angle)

last_draw = time.ticks_ms()
pending_draw = False

while True:
    now = time.ticks_ms()
    for b in buttons:
        d = b.poll(now)
        if d == 0:
            continue
        new_angle = max(0, min(180, angle + d))
        if new_angle != angle:
            angle = new_angle
            set_angle(angle)          # サーボはステップごとに即反映
            pending_draw = True

    if pending_draw and time.ticks_diff(now, last_draw) >= DRAW_MIN_INTERVAL_MS:
        draw_status(angle)
        print("角度:", angle)
        last_draw = now
        pending_draw = False

    time.sleep_ms(3)

対応ファイル: servo-moter002-test003.py(内容はmain.pyと同じ)

test003.py実行時のシェル出力
▲ test003.py実行時のシェル出力
59°表示
▲ 59°表示
148°表示
▲ 148°表示(サーボホーンも簡易分度器上で148°付近)

▲ +/−ボタンで角度を可変し、OLEDがリアルタイムに追従する様子

✓ 動作確認方法

  • +ボタンの単押しで1°、押しっぱなしで早送り(だんだん速く)になる
  • −ボタンも同様
  • 0°と180°で頭打ちになり、そこで押し続けてもサーボが唸らない
  • OLEDの数字とバーが操作に追従する
  • 方向転換時に最初の1〜2°ホーンが動かない(バックラッシュ。仕様)

スポンサーリンク

main.pyとしてPico本体に保存しPCなしで単体動作させる

MicroPythonは電源投入時にboot.pymain.pyの順で自動実行します。Pico本体のストレージにmain.pyという名前で保存しておけば、PCもThonnyも繋がず、モバイルバッテリーなどの電源だけで単体動作します。

Thonnyで「ファイル」→「名前を付けて保存」を選び、保存先のダイアログで「Raspberry Pi Pico」を選択、ファイル名をmain.pyにして保存します。

つまずきポイント:保存先の選択を間違えて、PC側(デスクトップなど)にファイルを保存しただけになっていることがあります。この状態だとPCから切り離すと動きません。保存ダイアログで必ず「Raspberry Pi Pico」を選び、ファイル名はmain.pyにしてください。

保存後、USBケーブルを一度抜き差しして、PCから切り離した状態(モバイルバッテリー給電など)でもボタン操作でサーボが動き、OLEDに角度が出ることを確認します。サーボ用の外部5V電源は別途必要です。


スポンサーリンク

Q&A(よくある質問)

QOLEDに「度」や「°」の記号を表示できますか?

AMicroPython標準の8×8フォントはASCII(半角英数記号)のみ対応で、「°」や漢字はそのままでは表示できません。本記事では deg 表記にしています。記号を出したい場合は、小さな図形として自前で丸を描くか、日本語対応のビットマップフォントを別途読み込む必要があります。

Q単押ししたつもりなのに、角度が2°進むことがあります。

Aサーボのギアの遊び(バックラッシュ)です。直前と逆方向に動かした最初の1回は、ホーンが動かず次のステップでまとめて動くように見えます。コンソールの数字が1°ずつ進んでいれば、プログラムは正常です。安価な金属ギアサーボの特性で、方向反転時のみ発生します。

Q早送り(反応の速さ)を変えたいです。

AREPEAT_DELAY_MS(早送りが始まるまでの待ち)、FAST_START_MS(早送り開始直後の間隔)、FAST_MIN_MS(最速の間隔)、ACCEL_MS(加速の強さ)を調整してください。FAST_MIN_MSを10ms以下にすると、OLEDの描画(show()は1回20ms超)が追いつかなくなるので、その場合はDRAW_MIN_INTERVAL_MSも合わせて見直します。

QOLEDに何も表示されません。i2c.scan()が[]になります。

AI2C配線の接続不良が主な原因です。SDA(GP0)とSCL(GP1)の挿し間違い、ジャンパーの半挿し、VDD(3V3)/GNDの未接続を確認してください。ブレイクアウトボードとPico本体の挿し込みが甘い「半挿入」でも起こります。

QGP0/GP1以外のピンでOLEDを繋げますか?

A繋げます。ただしRaspberry PicoのI2Cは、コントローラーごとに使えるピンの組み合わせが決まっています。I2C(0)ならGP0/GP1、GP4/GP5、GP8/GP9、GP12/GP13、GP16/GP17、GP20/GP21のいずれか。I2C(1)ならGP2/GP3、GP6/GP7、GP10/GP11などです。使うピンに合わせてI2C()のscl=・sda=を書き換えてください(今回はGP13をボタンで使っているので、I2C0のGP0/GP1を選んでいます)。

Qサーボが停止中に小刻みに震えます(ジッタ)。

A電源のノイズや、指令パルスの微妙なゆらぎが原因です。電源ライン(5V-GND間)に電解コンデンサを入れる、共通GNDを確実にする、といった対策で軽減します。ただし安価なサーボはある程度のジッタが残ることが多く、完全には消えないと考えたほうがよいです。目標角度に達してしばらく操作がなければPWM出力を止める(servo.duty_ns(0))方法もありますが、外力で押し戻される用途では保持力がなくなる点に注意してください。


スポンサーリンク

まとめ

前回作ったMG996R×Raspberry Pi Pico 2Wの回路に0.96インチOLED(SSD1306)を足し、タクトスイッチの+/−で角度を1°ずつ・長押しで早送りしながら、現在角度をOLEDにリアルタイム表示するところまで作りました。

  • framebufで標準8×8フォントを拡大描画し、数字を大きく見せる
  • time.ticks_ms()ベースの状態管理で、sleepに頼らないキビキビしたボタン操作を作る
  • 実機で初めて分かるクセ(方向反転時のバックラッシュ、その0°/180°非対称、OLED描画が早送りの足を引っ張る問題)にどう対処したか

前回に続き、「動かしてみて初めて分かること」を中心に紹介しました。


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

Xでポスト はてブ LINE Pocket
ニンジン🥕アイコン

Follow me on X

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

@ninjin_py_vba をフォロー

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での電気代試算、外部公開を見送った理由まで解説します。

コメント