🦗 M5Stack實作教學:用手機遙控的仿生六足昆蟲

M5StickC + Hat 8Servos v1.1 + 6 顆 SG-90 ・ UIFlow 1.x Remote+ ・ 和 FlyBrain 同一套三腳架 CPG

5 個手機按鈕 6 顆伺服馬達 韌體已用電腦自動測試・已上機實測轉彎角度
01

這是什麼

用一台 M5StickC、一塊 Hat 8Servos v1.1 和 6 顆 SG-90 伺服馬達,做一隻六隻腳的仿生昆蟲。程式用 UIFlow 1.x 寫(手機遙控用的 Remote+ 只有 UIFlow 1.x 有,UIFlow 2.0 沒有),手機打開 Remote+ 頁面就有五個按鈕:前進、後退、左轉、右轉、迴轉(180 度)。每按一次走固定幾步,然後停下來站好。

牠走路的方式和 FlyBrain 數位雙生裡的果蠅是同一套:六隻腳分成兩組「三腳架」輪流踏步,節奏由一個中樞模式產生器(CPG)產生,公式和 flybrain/cpg.py 完全相同。想知道 CPG 在真果蠅身上是什麼,可以看

神經系統實作教學・六隻腳的 CPG 那一節 →

02

材料清單

材料數量說明
M5StickC1主控板,負責連 WiFi、跑程式、顯示動作名稱
Hat 8Servos v1.11插在 StickC 上方的擴充板,有 8 個伺服馬達接口和電池座
SG-90 伺服馬達6每隻腳一顆;買 180° 的版本,不要買 360° 連續旋轉版
Hat 用鋰電池(16340;你的 Hat 電池座若是 18350 就用 18350)1伺服馬達的電源,一定要充飽(見「電源注意」)
機身與六隻腳1 組雷切壓克力、3D 列印或厚紙板都可以;腳底要做成單向腳(見「已知限制」)
USB-C 傳輸線1燒錄韌體和充 StickC 的電;要能傳資料的線,不是只能充電的線
03

接線

腳位置Hat 通道伺服角度的方向
L1左前腳CH60° = 腳往前、180° = 腳往後(LEG_DIRECTION = -1)
L2左中腳CH70° = 腳往前、180° = 腳往後(LEG_DIRECTION = -1)
L3左後腳CH80° = 腳往前、180° = 腳往後(LEG_DIRECTION = -1)
R1右前腳CH2180° = 腳往前、0° = 腳往後(LEG_DIRECTION = 1)
R2右中腳CH3180° = 腳往前、0° = 腳往後(LEG_DIRECTION = 1)
R3右後腳CH4180° = 腳往前、0° = 腳往後(LEG_DIRECTION = 1)
—(不接)CH1保留不用
—(不接)CH5保留不用
機身(俯視,頭朝上)Hat 8ServosCH1 不接CH2CH3CH4CH5 不接CH6CH7CH8L1 左前腳CH6・0° = 往前L2 左中腳CH7・0° = 往前L3 左後腳CH8・0° = 往前R1 右前腳CH2・180° = 往前R2 右中腳CH3・180° = 往前R3 右後腳CH4・180° = 往前Hat 經 I²C(位址 0x36)接 StickC:G0 = SDA、G26 = SCL
示意圖:只畫出哪一隻腳接哪一個通道。排針實際的位置與方向請以 Hat 板子上印的編號為準。
  • Hat 上印的通道編號是 1~8,程式裡的 write_servo_angle(通道, 角度) 用的也是 1~8(不是從 0 開始),它會自己換算成 Hat 的暫存器編號(通道 − 1)。
  • 左邊三隻腳 L1 前、L2 中、L3 後 → CH6、CH7、CH8;右邊 R1 前、R2 中、R3 後 → CH2、CH3、CH4。CH1 和 CH5 不接。
  • Hat 透過 I²C 和 StickC 溝通(位址 0x36),用的是 StickC 頂端的 G0(SDA)和 G26(SCL);Hat 直接插上去就接好了,不用另外拉線。
  • SG-90 的線:棕色 = GND、紅色 = 電源、橘色 = 訊號。插的時候對好 Hat 上的標示,插反不會轉。
04

電源注意

Hat 8Servos v1.1 的伺服電源一開始是關著的,要在 I²C 位址 0x36 的電源暫存器 0x30 寫入 1 才會通電。韌體的 Servos8Hat 一找到 Hat 就會先做這件事;如果你自己重寫程式,忘了這一步,馬達會完全不動。
  • Hat 8Servos 的最大負載是 DC 4.2V @ 1.3A,8 個通道共用。一顆 SG-90 卡住(stall)時會吃很多電流,六顆同時用力時很容易超過,電壓掉下去,StickC 就會當機重開或馬達亂抖(brown-out)。
  • 伺服馬達要靠 Hat 的鋰電池供電,而且要充飽。只插 USB-C 線時,StickC 本身會亮,但不夠推動六顆馬達。
  • 第一次測試時把機器人架高、讓腳懸空,確認每隻腳都會動、方向正確,再放到地上走。
  • 腳卡住(被東西擋住、裝得太緊)時馬達會一直用力,又熱又耗電,看到就趕快按 A 鍵停止。
05

UIFlow 操作步驟

  1. 電腦安裝 M5Burner,選 StickC,下載並燒錄 UIFlow 1.x 的韌體(不要選 UIFlow 2.0,2.0 沒有 Remote+)。
  2. 燒錄時填入 WiFi 名稱和密碼(StickC 只能連 2.4 GHz 的 WiFi)。重開機後連上網,螢幕上會顯示一組 API KEY。
  3. 電腦瀏覽器打開 flow.m5stack.com,選 UIFlow 1.x,在設定裡輸入 StickC 螢幕上的 API KEY、裝置選 StickC,等它顯示已連線。
  4. 切回 Blockly(積木)畫面。如果剛才已經在 Python 畫面改過程式、切不回積木,請開一個新專案。
  5. 打開 Remote+ 設計面板,拉出 5 個 Button,按鈕文字和名稱分別設成:前進(forward)、後退(backward)、左轉(left)、右轉(right)、迴轉(uturn)。Remote+ 網頁上的按鈕是在這個面板建立、存在 M5Stack 雲端的;只貼 Python 程式碼不會長出按鈕,手機上的頁面會是空的。
  6. 切到 Python 畫面,填上 main.py 的程式碼(按「下載 main.py」或在下方按複製),然後上傳程式:先按 Run 試跑,沒問題再按 Download 存進 StickC,之後一開機就會自動執行。
  7. 用手機掃 Remote+ 頁面上的 QR code,手機上就會出現五個按鈕,按下去機器人就會走。
老實說:Remote+ 按鈕的 callback 會叫什麼名字、在 Python 檢視裡要怎麼和按鈕綁在一起,需要連上 UIFlow 雲端才看得到,我們沒辦法在離線環境驗證。如果按鈕沒反應,先在 Blockly 檢視拉一個 Remote+ 按鈕事件積木,再切到 Python 看它產生的程式長什麼樣子,照著改名字。
06

UIFlow 積木加 Remote+ 版

想用積木拉的話,可以做成混合版:按鈕、A 鍵和主迴圈用積木,步態的數學運算放進 Execute code 積木。積木只有十幾塊,看得出整支程式的流程;計算部分是同一份程式,所以走起來和 Python 版一模一樣。

Setup(開機執行一次)Execute code(貼上整段「積木版程式庫」)Loop(一直重複)Execute codetick()Wait 20 ms左邊:程式庫只貼一次;Loop 每 20 毫秒走一小步Button forward CallbackExecute codestart("forward")Button backward CallbackExecute codestart("backward")Button left CallbackExecute codestart("left")Button right CallbackExecute codestart("right")Button uturn CallbackExecute codestart("uturn")Button A wasPressedExecute codestop()右邊:每個按鈕事件裡只放一行
示意圖:積木的顏色和外形是畫出來的,實際以 UIFlow 1.x 畫面為準。
UIFlow 1.x 實際畫面:main.m5f(Setup 貼程式庫、Loop、A 鍵、5 個 Remote+ 按鈕)
UIFlow 1.x 實際畫面:main.m5f(Setup 貼程式庫、Loop、A 鍵、5 個 Remote+ 按鈕)
  1. 在 Blockly(積木) 畫面,照「UIFlow 操作步驟」在 Remote+ 面板拉好 5 個按鈕:前進(forward)、後退(backward)、左轉(left)、右轉(right)、迴轉(uturn)。下面粗體英文是 UIFlow 1.x 畫面上積木的實際名字,可以對照上方的截圖。
  2. 從 Advanced → Execute 拉一個 Execute code 積木,接在 Setup 下面,把下方的「積木版程式庫」整段貼進去(按「複製」)。
  3. 從 Event 拉 Loop 積木接在下面,Loop 裡放一個 Execute code 寫 tick(),再從 Timer 拉 Wait 20 ms。
  4. 從 Remote+ 拉 5 個按鈕事件積木:Button forward Callback、Button backward Callback、Button left Callback、Button right Callback、Button uturn Callback,每個裡面放一個 Execute code,分別寫 start("forward")、start("backward")、start("left")、start("right")、start("uturn")。
  5. 從 Button 拉 Button A wasPressed,裡面放 Execute code 寫 stop(),當作緊急停止。
  6. 切到 Python 檢視看一下:裡面應該有一行 remoteInit()(UIFlow 用了 Remote+ 積木時會自己加)。如果沒有,就在 Setup 的程式庫積木下面再接一個 Execute code 寫 remoteInit(),否則手機按鈕不會有反應。
  7. 按 Run 試跑,沒問題再按 Download。也可以直接按下方的「下載 main.m5f」,在 UIFlow 打開已經拉好的版本。
積木名稱照 UIFlow 1.x 實際畫面(上方截圖)寫。如果 Execute code 積木一次貼不進整段程式庫,就改用下面的 Python 版。
積木版程式庫(從 main.py 自動擷取到 Remote+ 按鈕之前,294 行)下載 main.m5f
from m5stack import *
from m5ui import *
from uiflow import *
# I2C 和 Pin 用來直接和伺服馬達 Hat 溝通(原因寫在「伺服馬達」那一段)。
from machine import I2C, Pin
# math 提供 sin(正弦)和 pi(圓周率),用來算出腳擺動的波形。
import math

# ---------------- wiring 接線 ----------------
# 六隻腳各接在 Hat 的哪一個通道。清單裡的順序就是腳的順序。
LEFT_CHANNELS = [6, 7, 8]                    # 左腳 L1、L2、L3 接的通道
RIGHT_CHANNELS = [2, 3, 4]                   # 右腳 R1、R2、R3 接的通道
# 把兩個清單接起來,得到六隻腳的順序 L1 L2 L3 R1 R2 R3(和 flybrain/config.py 相同)。
# 之後程式裡說「第 leg 隻腳」,leg = 0~2 是左腳、3~5 是右腳。
CHANNELS = LEFT_CHANNELS + RIGHT_CHANNELS
# 每隻腳的轉動方向:+1 表示角度變大時腳往前擺,-1 表示角度變大時腳往後擺。
# 左右兩邊的馬達是鏡像裝的:左邊馬達 0 度是往前、180 度是往後;右邊剛好相反。
LEG_DIRECTION = [-1, -1, -1, 1, 1, 1]
# 每隻腳的微調角度(單位:度)。組裝時馬達裝歪了一點,就在這裡補回來,平常是 0。
LEG_TRIM = [0, 0, 0, 0, 0, 0]
NEUTRAL = 90             # 站好時每顆馬達的角度:90 度是正中間
ANGLE_MIN = 10           # 馬達最小只轉到 10 度,避免撞到機構的極限
ANGLE_MAX = 170          # 馬達最大只轉到 170 度

# ---------------- gait 步態 ----------------
FREQ_HZ = 1.0            # 每秒走幾個步態週期;電池 3.7 V 時 SG-90 跟得上這個速度
AMPLITUDE_DEG = 30       # 腳從正中間往前、往後各擺幾度
# 波形的「歪斜程度」:讓腳往前收得快、往後推地慢,像真的昆蟲一樣。
HARMONIC_RATIO = 0.25
# 每隻腳的起始相位(單位:弧度)。0 和 pi 剛好差半個週期,
# 所以 L1、L3、R2(相位 0)和 L2、R1、R3(相位 pi)兩組三腳架會輪流擺動。
PHASE_OFFSETS = [0.0, math.pi, 0.0, math.pi, 0.0, math.pi]
STEP_MS = 20             # 每 20 毫秒更新一次馬達角度(一秒 50 次)
MOVE_CYCLES = 3          # 按一次前進或後退,走 3 個步態週期
# 轉彎要走幾個週期,是在實體機器人上量出來的:3 個週期大約轉 75 度,6 個週期大約轉 135 度。
# 想重新校正時:新的週期數 = 舊的週期數 × 想要的角度 ÷ 量到的角度。
TURN_CYCLES = 1.8        # 左轉、右轉:3 × 45 ÷ 75 -> 轉 45 度
UTURN_CYCLES = 8         # 迴轉:6 × 180 ÷ 135 -> 轉 180 度

# 動作表:每個按鈕名稱對應一組(左邊幅度, 右邊幅度, 走幾個週期, 螢幕顯示的文字)。
# 幅度 1.0 表示那一邊正常往前走;幅度是負的,那一邊的步伐就倒過來播放(變成往後走)。
# 這是因為波形是「奇函數」:-wave(p) 剛好等於 wave(-p)。
# 轉彎時讓兩邊互相反著走(一邊往前、一邊往後),身體就原地旋轉,
# 比只讓一邊走、另一邊停住轉得快。
MOTIONS = {
    "forward": (1.0, 1.0, MOVE_CYCLES, "FORWARD"),
    "backward": (-1.0, -1.0, MOVE_CYCLES, "BACK"),
    "left": (-1.0, 1.0, TURN_CYCLES, "LEFT"),
    "right": (1.0, -1.0, TURN_CYCLES, "RIGHT"),
    "uturn": (1.0, -1.0, UTURN_CYCLES, "U-TURN"),
}


# 找出波形 sin(p) + HARMONIC_RATIO × sin(2p) 在一個週期裡的最大高度。
# 輸入:沒有。輸出:最大高度(一個正的小數)。
# 為什麼:之後把波形除以這個數,波形的最高點就剛好是 1,擺動角度才會剛好是 AMPLITUDE_DEG。
def _wave_peak():
    largest = 0.0
    # 把一整圈(0 到 2π)切成 720 小格,一格一格算,記下看過的最大值。
    for step in range(720):
        phase_in_radians = step * math.pi / 360.0
        height = math.sin(phase_in_radians) + HARMONIC_RATIO * math.sin(2.0 * phase_in_radians)
        # 波形有正有負,我們要的是離 0 最遠的距離,所以取絕對值。
        if abs(height) > largest:
            largest = abs(height)
    return largest


# 程式一開始就先算好最大高度存起來,之後每次畫波形直接拿來用,不用重算。
WAVE_PEAK = _wave_peak()


# 步態波形:給一個相位(弧度),回傳腳該擺到哪裡,範圍是 -1 到 1。
# 輸入:phase 相位。輸出:-1(腳在最後面)到 1(腳在最前面)之間的數。
# 這一行公式和 FlyBrain 的 flybrain/cpg.py 完全一樣,所以機器人走路的樣子和模擬的果蠅相同。
def wave(phase):
    height = math.sin(phase) + HARMONIC_RATIO * math.sin(2.0 * phase)
    return height / WAVE_PEAK


# ---------------- screen font 螢幕字型 ----------------
# 把螢幕換成小字型。
# 輸入、輸出:都沒有。
# 為什麼:StickC 的螢幕只有 80 像素寬,大字放不下動作名稱。
# 不同版本的 UIFlow 小字型的名字不一樣,所以兩個名字都試,有哪個就用哪個。
def use_small_font():
    for font_name in ("FONT_DefaultSmall", "FONT_Small"):
        # hasattr 檢查 lcd 有沒有這個名字的字型;getattr 用名字把字型拿出來。
        if hasattr(lcd, font_name):
            lcd.font(getattr(lcd, font_name))
            return


# ---------------- servos 伺服馬達 ----------------
# 這支程式不用 UIFlow 內建的 hat.get(hat.SERVOS),因為實際測試時,
# Hat v1.1 明明插好了它還是報錯「Servos hat maybe not connect」。
# 所以改成自己用 I2C 直接寫 Hat 的暫存器,順便能在螢幕上顯示匯流排上真正找到了什麼。
HAT_SDA = 0                  # StickC 上方排針的 G0 腳:I2C 資料線 SDA
HAT_SCL = 26                 # StickC 上方排針的 G26 腳:I2C 時脈線 SCL
HAT_V11_ADDR = 0x36          # Hat 8Servos v1.1 的 I2C 位址
HAT_V10_ADDR = 0x38          # 舊版 Hat 8Servos v1.0 的位址:角度暫存器一樣,但沒有電源開關
REG_POWER = 0x30             # 只有 v1.1 有的電源暫存器:寫 1 才會打開馬達電源


# 把 I2C 掃到的位址清單轉成好讀的文字,例如 [54, 104] 變成 "['0x36', '0x68']"。
# 輸入:addresses 位址清單(整數)。輸出:一段文字,用來顯示在螢幕和錯誤訊息上。
def addresses_as_text(addresses):
    hex_addresses = []
    for address in addresses:
        hex_addresses.append(hex(address))
    return str(hex_addresses)


# Servos8Hat 代表插在 StickC 上的那塊伺服馬達 Hat。
# 建立它的時候會找出 Hat 是哪一版,之後用 write_servo_angle() 叫某一個通道的馬達轉到某個角度。
class Servos8Hat:
    # 建立時執行一次:打開 I2C、掃描匯流排、認出 Hat 的版本。
    # 找不到 Hat 就在螢幕寫 NO HAT 並停下來,不會假裝在走。
    def __init__(self):
        self.i2c = I2C(1, sda=Pin(HAT_SDA), scl=Pin(HAT_SCL), freq=100000)
        # scan() 問一遍匯流排上所有裝置,回傳有回答的位址清單。
        found = self.i2c.scan()
        if HAT_V11_ADDR in found:
            self.addr = HAT_V11_ADDR
            # v1.1 開機時馬達電源是關的,一定要寫 1 到電源暫存器,馬達才會動。
            self.i2c.writeto_mem(self.addr, REG_POWER, bytes([1]))
        elif HAT_V10_ADDR in found:
            self.addr = HAT_V10_ADDR
        else:
            use_small_font()
            lcd.clear()
            lcd.print("NO HAT", 4, 6, 0xff4444)
            lcd.print("I2C: " + addresses_as_text(found), 4, 24, 0xffffff)
            raise OSError("8Servos hat not on I2C, found " + addresses_as_text(found))

    # 叫一顆馬達轉到指定角度。
    # 輸入:channel 是 Hat 上印的通道 1~8;angle 是 0~180 的整數角度。
    # Hat 的第 n 個通道的角度暫存器位址是 n - 1(CH1 在 0x00、CH2 在 0x01……)。
    def write_servo_angle(self, channel, angle):
        self.i2c.writeto_mem(self.addr, channel - 1, bytes([angle]))


servos = Servos8Hat()
# 每隻腳上一次真正送給馬達的角度。一開始還沒送過,所以是 None(沒有值)。
last_angle = [None] * 6
# 每隻腳上一次「想要」的角度(還沒套上方向和微調之前),滑回站姿時要從這裡出發。
last_pose = [NEUTRAL] * 6


# 讓第 leg 隻腳轉到 angle 度。
# 輸入:leg 是 0~5 的腳編號;angle 是「想要」的角度(90 = 正中間)。
# 這裡會先套上這隻腳的方向和微調,再把角度限制在安全範圍裡,最後才送給馬達。
def write_leg(leg, angle):
    # 套方向:離正中間多少度,乘上 +1 或 -1,左右兩邊的鏡像就對上了;再加上微調。
    angle = NEUTRAL + LEG_DIRECTION[leg] * (angle - NEUTRAL) + LEG_TRIM[leg]
    # 馬達只接受整數角度,先四捨五入。
    angle = int(round(angle))
    # 限制範圍:太小就用最小值,太大就用最大值。
    if angle < ANGLE_MIN:
        angle = ANGLE_MIN
    if angle > ANGLE_MAX:
        angle = ANGLE_MAX
    # 角度和上次一樣就不送,讓 I2C 匯流排安靜一點。
    if angle != last_angle[leg]:
        servos.write_servo_angle(CHANNELS[leg], angle)
        last_angle[leg] = angle


# 六隻腳全部回到正中間(站姿)。
def stand():
    for leg in range(6):
        last_pose[leg] = NEUTRAL
        write_leg(leg, NEUTRAL)


# 依照步態波形,把六隻腳擺到這個相位該在的位置。
# 輸入:phase 目前的相位;amp_left、amp_right 是左右兩邊的幅度(來自動作表 MOTIONS)。
def pose(phase, amp_left, amp_right):
    for leg in range(6):
        # leg 0~2 是左腳,用左邊的幅度;3~5 是右腳,用右邊的幅度。
        if leg < 3:
            amplitude = AMPLITUDE_DEG * amp_left
        else:
            amplitude = AMPLITUDE_DEG * amp_right
        # 每隻腳加上自己的相位差,兩組三腳架才會輪流擺。
        last_pose[leg] = NEUTRAL + amplitude * wave(phase + PHASE_OFFSETS[leg])
        write_leg(leg, last_pose[leg])


# ---------------- screen 螢幕 ----------------
# 清掉螢幕,上面一行寫 FlyBrain,下面一行寫 text(例如目前的動作名稱)。
def show(text):
    lcd.clear()
    lcd.print("FlyBrain", 4, 6, 0x2dd4bf)
    lcd.print(text, 4, 24, 0xffffff)


# ---------------- motion state 動作狀態 ----------------
# 轉彎走 1.8 個週期,停下來時腳還在步伐中間,每隻腳偏了大約 30 度。
# 如果六顆馬達同一瞬間一起跳回 90 度,電流會突然變很大,讓 Hat 斷電。
# 所以動作結束(或被別的按鈕打斷)時,先用 SETTLE_STEPS 個小步慢慢滑回站姿。
# 每個動作也都從站姿、相位 0 開始,所以同一個按鈕每次轉的角度都一樣。
SETTLE_STEPS = 10            # 滑回站姿分 10 小步,共 200 毫秒,每步大約 3 度

motion = None                # 正在做的動作名稱;None 表示沒有在走
pending = None               # 腳還沒收回來時又按的按鈕,等站好再做
steps_left = 0               # 這個動作還剩幾個 20 毫秒小步
settle_left = 0              # 滑回站姿還剩幾小步
settle_from = [NEUTRAL] * 6  # 開始滑回時每隻腳的位置
phase = 0.0                  # 目前的步態相位(弧度)


# 開始做一個動作:從相位 0 開始,算出這個動作要走幾個 20 毫秒小步。
# 輸入:name 動作名稱,例如 "forward"。
# 例如前進:3 個週期 × 1000 毫秒 ÷ (每秒 1 個週期 × 每步 20 毫秒) = 150 小步。
def begin(name):
    # global 表示要改的是函式外面那個變數,不是在函式裡另外建一個新的。
    global motion, pending, steps_left, phase
    motion = name
    pending = None
    phase = 0.0
    cycles = MOTIONS[name][2]
    steps_left = int(round(cycles * 1000 / (FREQ_HZ * STEP_MS)))


# 停止步態,從腳現在的位置開始慢慢滑回站姿。
def settle():
    global motion, settle_left
    motion = None
    for leg in range(6):
        settle_from[leg] = last_pose[leg]
    settle_left = SETTLE_STEPS


# Remote+ 按鈕會呼叫這個函式。
# 輸入:name 動作名稱。
# 如果腳正在走或還沒收回來,就先記下這個按鈕,等腳回到站姿再開始,不會突然換動作。
def start(name):
    global pending
    show(MOTIONS[name][3])
    if motion is not None:
        # 正在走:記下新動作,先滑回站姿。
        pending = name
        settle()
    elif settle_left > 0:
        # 正在滑回站姿:記下新動作,滑完就開始。
        pending = name
    else:
        # 已經站好:直接開始。
        begin(name)


# A 鍵的緊急停止:不等待、不滑行,直接站好,並清掉所有排隊中的動作。
def stop():
    global motion, pending, steps_left, settle_left
    motion = None
    pending = None
    steps_left = 0
    settle_left = 0
    stand()
    show("READY")


# 一個 20 毫秒的節拍:如果正在走,就讓步態往前一小步;如果正在滑回站姿,就滑一小步。
# 主迴圈(積木版是 Loop 積木)每次呼叫這個函式,然後等 STEP_MS 毫秒。
def tick():
    global phase, steps_left, settle_left
    if motion is not None:
        amp_left = MOTIONS[motion][0]
        amp_right = MOTIONS[motion][1]
        # 相位每 20 毫秒前進一點:一秒走 FREQ_HZ 圈,一圈是 2π。
        phase += 2.0 * math.pi * FREQ_HZ * STEP_MS / 1000.0
        pose(phase, amp_left, amp_right)
        steps_left -= 1
        if steps_left <= 0:
            settle()
        return
    if settle_left > 0:
        settle_left -= 1
        # share 從 0.9 慢慢變到 0:腳離正中間的距離每一步縮小一點。
        share = settle_left / SETTLE_STEPS
        for leg in range(6):
            last_pose[leg] = NEUTRAL + (settle_from[leg] - NEUTRAL) * share
            write_leg(leg, last_pose[leg])
        if settle_left == 0:
            # 滑完了:有排隊的動作就開始做,沒有就顯示 READY。
            if pending is not None:
                begin(pending)
            else:
                show("READY")


use_small_font()
stop()

程式碼全文與逐段解說

不用積木、直接用 Python 的話,就把下面整支 main.py 填進 UIFlow 的 Python 畫面。它和上面的積木版是同一份程式:積木版只是把 Remote+ 按鈕、A 鍵和主迴圈換成積木。

firmware/m5stickc_hexapod/main.py(351 行)下載 main.py
# ===== 使用前請先在 UIFlow 1.x 完成以下步驟 =====
# 1. 切回 Blockly(積木)畫面。
# 2. 打開 Remote+ 設計面板,拉出 5 個 Button,按鈕文字和名稱分別設成:
#    前進(forward)、後退(backward)、左轉(left)、右轉(right)、迴轉(uturn)。
#    Remote+ 網頁上的按鈕是在這個面板建立、存在 M5Stack 雲端的;只貼程式碼不會長出按鈕。
# 3. 切到 Python 畫面填上這份 main.py 的程式碼,
#    再上傳程式:先按 Run 試跑,沒問題再按 Download。
# ================================================

# FlyBrain 迷你六足機器人:M5StickC + Hat 8Servos v1.1 + 6 顆 SG-90 伺服馬達,
# 用手機上的 UIFlow 1.x Remote+ 網頁遙控。
#
# 接線(Hat 8Servos v1.1 上印的通道編號是 1~8):
#   左邊三隻腳:L1 前腳、L2 中腳、L3 後腳 -> 接 CH6、CH7、CH8
#   右邊三隻腳:R1 前腳、R2 中腳、R3 後腳 -> 接 CH2、CH3、CH4
#
# Remote+ 有五個按鈕:前進、後退、左轉、右轉、迴轉(180 度)。
# 每按一次,機器人就照那個動作走固定的步數,走完自己站好。
# StickC 正面的 A 鍵是緊急停止。
#
# 走路的方式和電腦模擬裡的果蠅一樣(flybrain/cpg.py 的三腳架步態):
# 六隻腳分成兩組三腳架,A 組 {L1, L3, R2} 和 B 組 {L2, R1, R3} 輪流擺動,
# 所以任何時候都有三隻腳撐在地上,身體不會倒。

# 下面三行載入 UIFlow 的工具:螢幕 lcd、按鍵 btnA、等待 wait_ms、Remote+ 的 remoteInit 等。
from m5stack import *
from m5ui import *
from uiflow import *
# I2C 和 Pin 用來直接和伺服馬達 Hat 溝通(原因寫在「伺服馬達」那一段)。
from machine import I2C, Pin
# math 提供 sin(正弦)和 pi(圓周率),用來算出腳擺動的波形。
import math

# ---------------- wiring 接線 ----------------
# 六隻腳各接在 Hat 的哪一個通道。清單裡的順序就是腳的順序。
LEFT_CHANNELS = [6, 7, 8]                    # 左腳 L1、L2、L3 接的通道
RIGHT_CHANNELS = [2, 3, 4]                   # 右腳 R1、R2、R3 接的通道
# 把兩個清單接起來,得到六隻腳的順序 L1 L2 L3 R1 R2 R3(和 flybrain/config.py 相同)。
# 之後程式裡說「第 leg 隻腳」,leg = 0~2 是左腳、3~5 是右腳。
CHANNELS = LEFT_CHANNELS + RIGHT_CHANNELS
# 每隻腳的轉動方向:+1 表示角度變大時腳往前擺,-1 表示角度變大時腳往後擺。
# 左右兩邊的馬達是鏡像裝的:左邊馬達 0 度是往前、180 度是往後;右邊剛好相反。
LEG_DIRECTION = [-1, -1, -1, 1, 1, 1]
# 每隻腳的微調角度(單位:度)。組裝時馬達裝歪了一點,就在這裡補回來,平常是 0。
LEG_TRIM = [0, 0, 0, 0, 0, 0]
NEUTRAL = 90             # 站好時每顆馬達的角度:90 度是正中間
ANGLE_MIN = 10           # 馬達最小只轉到 10 度,避免撞到機構的極限
ANGLE_MAX = 170          # 馬達最大只轉到 170 度

# ---------------- gait 步態 ----------------
FREQ_HZ = 1.0            # 每秒走幾個步態週期;電池 3.7 V 時 SG-90 跟得上這個速度
AMPLITUDE_DEG = 30       # 腳從正中間往前、往後各擺幾度
# 波形的「歪斜程度」:讓腳往前收得快、往後推地慢,像真的昆蟲一樣。
HARMONIC_RATIO = 0.25
# 每隻腳的起始相位(單位:弧度)。0 和 pi 剛好差半個週期,
# 所以 L1、L3、R2(相位 0)和 L2、R1、R3(相位 pi)兩組三腳架會輪流擺動。
PHASE_OFFSETS = [0.0, math.pi, 0.0, math.pi, 0.0, math.pi]
STEP_MS = 20             # 每 20 毫秒更新一次馬達角度(一秒 50 次)
MOVE_CYCLES = 3          # 按一次前進或後退,走 3 個步態週期
# 轉彎要走幾個週期,是在實體機器人上量出來的:3 個週期大約轉 75 度,6 個週期大約轉 135 度。
# 想重新校正時:新的週期數 = 舊的週期數 × 想要的角度 ÷ 量到的角度。
TURN_CYCLES = 1.8        # 左轉、右轉:3 × 45 ÷ 75 -> 轉 45 度
UTURN_CYCLES = 8         # 迴轉:6 × 180 ÷ 135 -> 轉 180 度

# 動作表:每個按鈕名稱對應一組(左邊幅度, 右邊幅度, 走幾個週期, 螢幕顯示的文字)。
# 幅度 1.0 表示那一邊正常往前走;幅度是負的,那一邊的步伐就倒過來播放(變成往後走)。
# 這是因為波形是「奇函數」:-wave(p) 剛好等於 wave(-p)。
# 轉彎時讓兩邊互相反著走(一邊往前、一邊往後),身體就原地旋轉,
# 比只讓一邊走、另一邊停住轉得快。
MOTIONS = {
    "forward": (1.0, 1.0, MOVE_CYCLES, "FORWARD"),
    "backward": (-1.0, -1.0, MOVE_CYCLES, "BACK"),
    "left": (-1.0, 1.0, TURN_CYCLES, "LEFT"),
    "right": (1.0, -1.0, TURN_CYCLES, "RIGHT"),
    "uturn": (1.0, -1.0, UTURN_CYCLES, "U-TURN"),
}


# 找出波形 sin(p) + HARMONIC_RATIO × sin(2p) 在一個週期裡的最大高度。
# 輸入:沒有。輸出:最大高度(一個正的小數)。
# 為什麼:之後把波形除以這個數,波形的最高點就剛好是 1,擺動角度才會剛好是 AMPLITUDE_DEG。
def _wave_peak():
    largest = 0.0
    # 把一整圈(0 到 2π)切成 720 小格,一格一格算,記下看過的最大值。
    for step in range(720):
        phase_in_radians = step * math.pi / 360.0
        height = math.sin(phase_in_radians) + HARMONIC_RATIO * math.sin(2.0 * phase_in_radians)
        # 波形有正有負,我們要的是離 0 最遠的距離,所以取絕對值。
        if abs(height) > largest:
            largest = abs(height)
    return largest


# 程式一開始就先算好最大高度存起來,之後每次畫波形直接拿來用,不用重算。
WAVE_PEAK = _wave_peak()


# 步態波形:給一個相位(弧度),回傳腳該擺到哪裡,範圍是 -1 到 1。
# 輸入:phase 相位。輸出:-1(腳在最後面)到 1(腳在最前面)之間的數。
# 這一行公式和 FlyBrain 的 flybrain/cpg.py 完全一樣,所以機器人走路的樣子和模擬的果蠅相同。
def wave(phase):
    height = math.sin(phase) + HARMONIC_RATIO * math.sin(2.0 * phase)
    return height / WAVE_PEAK


# ---------------- screen font 螢幕字型 ----------------
# 把螢幕換成小字型。
# 輸入、輸出:都沒有。
# 為什麼:StickC 的螢幕只有 80 像素寬,大字放不下動作名稱。
# 不同版本的 UIFlow 小字型的名字不一樣,所以兩個名字都試,有哪個就用哪個。
def use_small_font():
    for font_name in ("FONT_DefaultSmall", "FONT_Small"):
        # hasattr 檢查 lcd 有沒有這個名字的字型;getattr 用名字把字型拿出來。
        if hasattr(lcd, font_name):
            lcd.font(getattr(lcd, font_name))
            return


# ---------------- servos 伺服馬達 ----------------
# 這支程式不用 UIFlow 內建的 hat.get(hat.SERVOS),因為實際測試時,
# Hat v1.1 明明插好了它還是報錯「Servos hat maybe not connect」。
# 所以改成自己用 I2C 直接寫 Hat 的暫存器,順便能在螢幕上顯示匯流排上真正找到了什麼。
HAT_SDA = 0                  # StickC 上方排針的 G0 腳:I2C 資料線 SDA
HAT_SCL = 26                 # StickC 上方排針的 G26 腳:I2C 時脈線 SCL
HAT_V11_ADDR = 0x36          # Hat 8Servos v1.1 的 I2C 位址
HAT_V10_ADDR = 0x38          # 舊版 Hat 8Servos v1.0 的位址:角度暫存器一樣,但沒有電源開關
REG_POWER = 0x30             # 只有 v1.1 有的電源暫存器:寫 1 才會打開馬達電源


# 把 I2C 掃到的位址清單轉成好讀的文字,例如 [54, 104] 變成 "['0x36', '0x68']"。
# 輸入:addresses 位址清單(整數)。輸出:一段文字,用來顯示在螢幕和錯誤訊息上。
def addresses_as_text(addresses):
    hex_addresses = []
    for address in addresses:
        hex_addresses.append(hex(address))
    return str(hex_addresses)


# Servos8Hat 代表插在 StickC 上的那塊伺服馬達 Hat。
# 建立它的時候會找出 Hat 是哪一版,之後用 write_servo_angle() 叫某一個通道的馬達轉到某個角度。
class Servos8Hat:
    # 建立時執行一次:打開 I2C、掃描匯流排、認出 Hat 的版本。
    # 找不到 Hat 就在螢幕寫 NO HAT 並停下來,不會假裝在走。
    def __init__(self):
        self.i2c = I2C(1, sda=Pin(HAT_SDA), scl=Pin(HAT_SCL), freq=100000)
        # scan() 問一遍匯流排上所有裝置,回傳有回答的位址清單。
        found = self.i2c.scan()
        if HAT_V11_ADDR in found:
            self.addr = HAT_V11_ADDR
            # v1.1 開機時馬達電源是關的,一定要寫 1 到電源暫存器,馬達才會動。
            self.i2c.writeto_mem(self.addr, REG_POWER, bytes([1]))
        elif HAT_V10_ADDR in found:
            self.addr = HAT_V10_ADDR
        else:
            use_small_font()
            lcd.clear()
            lcd.print("NO HAT", 4, 6, 0xff4444)
            lcd.print("I2C: " + addresses_as_text(found), 4, 24, 0xffffff)
            raise OSError("8Servos hat not on I2C, found " + addresses_as_text(found))

    # 叫一顆馬達轉到指定角度。
    # 輸入:channel 是 Hat 上印的通道 1~8;angle 是 0~180 的整數角度。
    # Hat 的第 n 個通道的角度暫存器位址是 n - 1(CH1 在 0x00、CH2 在 0x01……)。
    def write_servo_angle(self, channel, angle):
        self.i2c.writeto_mem(self.addr, channel - 1, bytes([angle]))


servos = Servos8Hat()
# 每隻腳上一次真正送給馬達的角度。一開始還沒送過,所以是 None(沒有值)。
last_angle = [None] * 6
# 每隻腳上一次「想要」的角度(還沒套上方向和微調之前),滑回站姿時要從這裡出發。
last_pose = [NEUTRAL] * 6


# 讓第 leg 隻腳轉到 angle 度。
# 輸入:leg 是 0~5 的腳編號;angle 是「想要」的角度(90 = 正中間)。
# 這裡會先套上這隻腳的方向和微調,再把角度限制在安全範圍裡,最後才送給馬達。
def write_leg(leg, angle):
    # 套方向:離正中間多少度,乘上 +1 或 -1,左右兩邊的鏡像就對上了;再加上微調。
    angle = NEUTRAL + LEG_DIRECTION[leg] * (angle - NEUTRAL) + LEG_TRIM[leg]
    # 馬達只接受整數角度,先四捨五入。
    angle = int(round(angle))
    # 限制範圍:太小就用最小值,太大就用最大值。
    if angle < ANGLE_MIN:
        angle = ANGLE_MIN
    if angle > ANGLE_MAX:
        angle = ANGLE_MAX
    # 角度和上次一樣就不送,讓 I2C 匯流排安靜一點。
    if angle != last_angle[leg]:
        servos.write_servo_angle(CHANNELS[leg], angle)
        last_angle[leg] = angle


# 六隻腳全部回到正中間(站姿)。
def stand():
    for leg in range(6):
        last_pose[leg] = NEUTRAL
        write_leg(leg, NEUTRAL)


# 依照步態波形,把六隻腳擺到這個相位該在的位置。
# 輸入:phase 目前的相位;amp_left、amp_right 是左右兩邊的幅度(來自動作表 MOTIONS)。
def pose(phase, amp_left, amp_right):
    for leg in range(6):
        # leg 0~2 是左腳,用左邊的幅度;3~5 是右腳,用右邊的幅度。
        if leg < 3:
            amplitude = AMPLITUDE_DEG * amp_left
        else:
            amplitude = AMPLITUDE_DEG * amp_right
        # 每隻腳加上自己的相位差,兩組三腳架才會輪流擺。
        last_pose[leg] = NEUTRAL + amplitude * wave(phase + PHASE_OFFSETS[leg])
        write_leg(leg, last_pose[leg])


# ---------------- screen 螢幕 ----------------
# 清掉螢幕,上面一行寫 FlyBrain,下面一行寫 text(例如目前的動作名稱)。
def show(text):
    lcd.clear()
    lcd.print("FlyBrain", 4, 6, 0x2dd4bf)
    lcd.print(text, 4, 24, 0xffffff)


# ---------------- motion state 動作狀態 ----------------
# 轉彎走 1.8 個週期,停下來時腳還在步伐中間,每隻腳偏了大約 30 度。
# 如果六顆馬達同一瞬間一起跳回 90 度,電流會突然變很大,讓 Hat 斷電。
# 所以動作結束(或被別的按鈕打斷)時,先用 SETTLE_STEPS 個小步慢慢滑回站姿。
# 每個動作也都從站姿、相位 0 開始,所以同一個按鈕每次轉的角度都一樣。
SETTLE_STEPS = 10            # 滑回站姿分 10 小步,共 200 毫秒,每步大約 3 度

motion = None                # 正在做的動作名稱;None 表示沒有在走
pending = None               # 腳還沒收回來時又按的按鈕,等站好再做
steps_left = 0               # 這個動作還剩幾個 20 毫秒小步
settle_left = 0              # 滑回站姿還剩幾小步
settle_from = [NEUTRAL] * 6  # 開始滑回時每隻腳的位置
phase = 0.0                  # 目前的步態相位(弧度)


# 開始做一個動作:從相位 0 開始,算出這個動作要走幾個 20 毫秒小步。
# 輸入:name 動作名稱,例如 "forward"。
# 例如前進:3 個週期 × 1000 毫秒 ÷ (每秒 1 個週期 × 每步 20 毫秒) = 150 小步。
def begin(name):
    # global 表示要改的是函式外面那個變數,不是在函式裡另外建一個新的。
    global motion, pending, steps_left, phase
    motion = name
    pending = None
    phase = 0.0
    cycles = MOTIONS[name][2]
    steps_left = int(round(cycles * 1000 / (FREQ_HZ * STEP_MS)))


# 停止步態,從腳現在的位置開始慢慢滑回站姿。
def settle():
    global motion, settle_left
    motion = None
    for leg in range(6):
        settle_from[leg] = last_pose[leg]
    settle_left = SETTLE_STEPS


# Remote+ 按鈕會呼叫這個函式。
# 輸入:name 動作名稱。
# 如果腳正在走或還沒收回來,就先記下這個按鈕,等腳回到站姿再開始,不會突然換動作。
def start(name):
    global pending
    show(MOTIONS[name][3])
    if motion is not None:
        # 正在走:記下新動作,先滑回站姿。
        pending = name
        settle()
    elif settle_left > 0:
        # 正在滑回站姿:記下新動作,滑完就開始。
        pending = name
    else:
        # 已經站好:直接開始。
        begin(name)


# A 鍵的緊急停止:不等待、不滑行,直接站好,並清掉所有排隊中的動作。
def stop():
    global motion, pending, steps_left, settle_left
    motion = None
    pending = None
    steps_left = 0
    settle_left = 0
    stand()
    show("READY")


# 一個 20 毫秒的節拍:如果正在走,就讓步態往前一小步;如果正在滑回站姿,就滑一小步。
# 主迴圈(積木版是 Loop 積木)每次呼叫這個函式,然後等 STEP_MS 毫秒。
def tick():
    global phase, steps_left, settle_left
    if motion is not None:
        amp_left = MOTIONS[motion][0]
        amp_right = MOTIONS[motion][1]
        # 相位每 20 毫秒前進一點:一秒走 FREQ_HZ 圈,一圈是 2π。
        phase += 2.0 * math.pi * FREQ_HZ * STEP_MS / 1000.0
        pose(phase, amp_left, amp_right)
        steps_left -= 1
        if steps_left <= 0:
            settle()
        return
    if settle_left > 0:
        settle_left -= 1
        # share 從 0.9 慢慢變到 0:腳離正中間的距離每一步縮小一點。
        share = settle_left / SETTLE_STEPS
        for leg in range(6):
            last_pose[leg] = NEUTRAL + (settle_from[leg] - NEUTRAL) * share
            write_leg(leg, last_pose[leg])
        if settle_left == 0:
            # 滑完了:有排隊的動作就開始做,沒有就顯示 READY。
            if pending is not None:
                begin(pending)
            else:
                show("READY")


# ---------------- Remote+ buttons 手機按鈕 ----------------
# 在 UIFlow 的 Remote+ 頁面加五個 Button,每個按鈕各有自己的 callback(被按下時執行的函式)。
# 如果 UIFlow 幫按鈕取了不同的函式名稱,保留 UIFlow 的名稱,把 start(...) 那一行搬進去就好。
def button_forward_callback():
    start("forward")


def button_backward_callback():
    start("backward")


def button_left_callback():
    start("left")


def button_right_callback():
    start("right")


def button_uturn_callback():
    start("uturn")


# ---------------- 開機後執行 ----------------
use_small_font()
btnA.wasPressed(stop)        # StickC 正面的大按鈕(A 鍵):緊急停止
remoteInit()                 # 啟動 Remote+ 連線,手機按鈕才會有反應
stop()                       # 一開機先站好,螢幕顯示 READY

# 主迴圈:永遠重複。每 20 毫秒做一個節拍。
# 迴圈裡沒有長時間的等待,所以按鈕隨時都按得進來。
while True:
    tick()
    wait_ms(STEP_MS)

第 34 行起・接線常數

LEFT_CHANNELS = [6, 7, 8]、RIGHT_CHANNELS = [2, 3, 4] 就是上面接線表。CHANNELS 把它們排成 L1 L2 L3 R1 R2 R3,和 FlyBrain 模擬裡腳的順序一樣。LEG_DIRECTION 說明每顆馬達哪一邊是「往前」:+1 代表角度越大腳越往前,−1 代表角度越小腳越往前。這組機身左邊的馬達 0° 是腳往前、180° 是腳往後,右邊剛好相反(180° 往前、0° 往後),所以是 [-1, -1, -1, 1, 1, 1];LEG_TRIM 是每隻腳的微調角度(見「校正」)。ANGLE_MIN/ANGLE_MAX 把角度限制在 10°~170°,避免馬達頂到極限卡死。

第 50 行起・步態參數

FREQ_HZ 每秒走幾步、AMPLITUDE_DEG 腳擺動的幅度、HARMONIC_RATIO 讓波形不對稱:往前收腳快、往後推地慢。PHASE_OFFSETS 把六隻腳分成兩組三腳架:A 組 {L1, L3, R2} 和 B 組 {L2, R1, R3} 剛好差半拍(π),所以永遠有三隻腳撐著地面。

第 70 行起・動作表 MOTIONS

每個動作是一列:(左邊幅度, 右邊幅度, 走幾個週期, 螢幕文字)。前進是兩邊都 1.0。幅度是負的,那一邊的步伐就倒著播放(波形是奇函數,−wave(p) 剛好等於 wave(−p)),腳變成往前推、往後收。所以後退是 (−1.0, −1.0);左轉是 (−1.0, 1.0):左腳往後、右腳往前,兩邊互相反著推,身體就原地旋轉,比只讓一邊走、另一邊停住快得多。右轉是 (1.0, −1.0),迴轉和右轉一樣,只是走得比較久。左轉、右轉走 TURN_CYCLES 個週期(約 45°),迴轉走 UTURN_CYCLES 個週期(約 180°),週期數是在實體機器人上量角度換算出來的(換算方法見「校正」)。

第 82 行起・波形 wave():和 flybrain/cpg.py 同一個公式

sin(相位) + 0.25 × sin(2 × 相位),再除以它的最大值,讓波形最高剛好是 1。這一行和 FlyBrain 的 flybrain/cpg.py 一模一樣,tests/test_m5_page.py 會把這支韌體載入電腦,逐一相位比對它和 cpg.angles() 算出的角度,只要有一邊改了公式或三腳架分組,測試就會失敗。

第 119 行起・伺服馬達

這支程式不用 UIFlow 的 hat.get(hat.SERVOS)(實機上它在 Hat 明明插好時也會報 Servos hat maybe not connect),而是自己用 machine.I2C 直接和 Hat 講話:Servos8Hat 在 G0(SDA)/G26(SCL)開 I²C、先 scan() 掃一遍。找到 0x36 就是 v1.1,寫 1 到電源暫存器 0x30 打開伺服電源(不寫的話馬達完全不動);找到 0x38 是舊的 v1.0,沒有電源暫存器;兩個都沒有就在螢幕顯示 NO HAT 和掃到的位址清單,然後停下來(OSError),不會假裝在走。write_servo_angle(通道, 角度) 的通道是 Hat 上印的 1~8,寫進暫存器「通道 − 1」。write_leg() 先套 LEG_DIRECTION 與 LEG_TRIM、再限制範圍,角度沒變就不送,讓 I²C 匯流排安靜。pose() 用波形算出六隻腳在這個相位的角度。

第 263 行起・開始與停止

start(名稱) 換成新動作、算出要走幾個 20 毫秒小步(例如前進:3 週期 × 1000 ÷ (1 Hz × 20 ms) = 150 步),螢幕顯示動作名稱,每次都從站姿、相位 0 開始,所以同一個按鈕每次轉的角度都一樣。動作走完時(例如 1.8 個週期的轉彎,停在步伐中間、腳還偏 30° 左右),settle() 會用 SETTLE_STEPS 個小步(200 毫秒)讓六隻腳慢慢滑回 90°,不會六顆馬達同時猛跳,避免電流太大讓 Hat 斷電。走到一半按別的鈕,也是先滑回站姿,再開始新動作。A 鍵的 stop() 是緊急停止:不等待,直接站好、清掉排隊的動作。

第 318 行起・Remote+ 按鈕

五個按鈕各對應一個 callback,裡面只有一行 start("…")。如果 UIFlow 幫你的按鈕取了不同的函式名稱,保留 UIFlow 的名稱,把 start(...) 那一行搬進去就好。

第 343 行起・緊急停止

StickC 正面的大按鈕(A 鍵)隨時按下都會呼叫 stop():機器人立刻站好。use_small_font() 把螢幕換成 UIFlow 的小字型,80 像素寬的螢幕才放得下動作名稱。remoteInit() 啟動 Remote+ 連線。

第 349 行起・主迴圈:不卡住的 20 毫秒節拍

每 20 毫秒呼叫一次 tick():如果正在動作,就把相位往前推一點點、擺一次姿勢、剩餘步數減 1,走完就 settle();滑回站姿的那 10 小步也是在 tick() 裡一步一步做。迴圈裡只有 wait_ms(20),沒有長時間的等待,所以 Remote+ 按鈕和 A 鍵隨時都按得進來。把這一步獨立成 tick(),積木版的 Loop 積木才能呼叫同一段程式。

07

UDP 版:電腦用 WiFi 控制多隻昆蟲

Remote+ 是一支手機控制一隻昆蟲。UDP 版改成由電腦當「控制站」:電腦在 WiFi 裡掃描,找到幾隻就列出幾隻,可以只控制其中一隻,也可以全部一起動。之後要接 FlyBrain 的果蠅神經系統時,就讓大腦代替人來按按鈕。

昆蟲那一端用 UIFlow 1.x 的 Advanced → Socket → UDP 積木收指令,走路的程式還是 06 的同一份程式庫;電腦那一端是一支 Python 程式 bug_server.py,打開後是一個本機網頁。

方向訊息意思
電腦 → 昆蟲HELLO,電腦IP,電腦port掃描:請回報你是誰(廣播到昆蟲所在網段,例如 192.168.0.255),並記住回覆要送到哪裡
電腦 → 昆蟲GO,動作做一個動作:forward、backward、left、right、uturn
電腦 → 昆蟲STOP緊急停止,馬上站好
電腦 → 昆蟲PING問目前狀態
昆蟲 → 電腦HERE,名字,IP/OK,動作/PONG,狀態/ERR,原因回覆;名字是 bug- 加 MAC 位址最後 4 碼,每隻都不一樣
為什麼掃描訊息要帶電腦的 IP?因為 UIFlow 的「接收」積木只拿得到資料,拿不到是誰送來的,所以電腦先告訴昆蟲自己的地址。另一個限制是「接收」積木會一直等到有資料才往下走,昆蟲就沒辦法每 20 毫秒走一步,所以 Loop 裡先用一行程式「看一眼有沒有資料」,有才接收。

昆蟲端:UIFlow 積木怎麼拉

Setup(開機執行一次)Execute code(06 的整段「積木版程式庫」)06Execute code(UDP 小工具:import network … handle_udp)07UDP server startset ip address 0.0.0.0 port 421007Execute codeudp_watch = uselect.poll() …register(…)07Execute codelcd.print(bug_name()…) lcd.print(my_ip()…)07Loop(一直重複)Execute codeready = udp_watch.poll(0)07if 〔ready〕07set 〔data〕 toReceive data by 64 bytes07Execute codehandle_udp(data)07if 〔reply〕≠ "" and 〔server_port〕≠ 007Send data〔reply〕to ip address〔server_ip〕port〔server_port〕07Execute codetick()06Wait20 ms06Button A wasPressedExecute codestop()06標籤是哪一節的積木0606 原本就有,照抄0707 新加的先在 Variables 按 Create variable建立 5 個:ready、data、reply、server_ip、server_port。〔 〕是拉進來的變數積木。青綠色:Advanced → Socket的 UDP server 那一組;Receive data、Send data 也用這一組,不要用 Read。藍色:Logic 的 if;粉紅色:Variables 的 set。不用拉 Remote+ 按鈕:改由電腦送 UDP 指令。Loop 每 20 毫秒:先看一眼有沒有資料,有才接收、處理、回覆;不管有沒有資料,都走一小步。
示意圖:06 的積木加上 07 新加的 Socket → UDP 積木;顏色和外形是畫出來的,實際以 UIFlow 1.x 畫面為準。
UIFlow 1.x 實際畫面:bug_client.m5f(UDP server start、Receive data、Send data 和 5 個變數)
UIFlow 1.x 實際畫面:bug_client.m5f(UDP server start、Receive data、Send data 和 5 個變數)
  1. StickC 先用 UIFlow 連上和電腦同一個 WiFi(UIFlow 線上模式時本來就連著)。這一版不用 Remote+ 按鈕。下面粗體英文是 UIFlow 1.x 畫面上積木的實際名字,可以對照下方的截圖。
  2. 先建立變數:在 Variables 分類按 Create variable,用一模一樣的名字建立 5 個:ready、data、reply、server_ip、server_port。積木的變數和 Execute code 裡同名的變數是同一個,所以 handle_udp() 在程式碼裡改了 reply、server_ip、server_port,積木讀得到;積木設定的 data,程式碼也拿得到。沒有先建立,後面的積木就沒有這些變數可以拉。
  3. Setup 下面接一個 Execute code 積木,貼 06 的「積木版程式庫」(同一段,按那裡的「複製」)。
  4. 再接一個 Execute code,貼下方程式裡「積木:Execute code(貼這一段 Setup 用的小工具)」那一段:從 import network 到 handle_udp 函式結束。
  5. 從 Advanced → Socket 拉 UDP server start,set ip address 填 0.0.0.0、port 填 4210。昆蟲是被控制的那一端,但要先「開門等訊息」,所以用的是 server 這一組。
  6. 接兩個 Execute code:一個寫 udp_watch = uselect.poll() 和 udp_watch.register(udpsocket, uselect.POLLIN),一個寫顯示名字和 IP 的兩行 lcd.print(...)。
  7. 拉 Button A wasPressed,裡面放 Execute code stop(),當作緊急停止。
  8. Loop 裡第一塊放 Execute code ready = udp_watch.poll(0)(看一眼有沒有資料)。
  9. 接一個 if(Logic),條件拉變數積木 ready。do 裡面依序放:set data to,值拉 Receive data by 64 bytes;Execute code handle_udp(data);再一個 if,條件用 and 接兩個比較:reply ≠ 空的文字積木("",裡面什麼都不填)、server_port ≠ 0;它的 do 裡放 Send data reply to ip address server_ip port server_port,三格都拉變數積木。
  10. Receive data 和 Send data 都要用和 UDP server start 同一組(server)的積木,它們操作的是同一個 udpsocket。不要用 Read:它會等到湊滿指定的位元組數才往下走,訊息長短不一就會卡住;Receive data 是有多少收多少。
  11. 在兩個 if 的外面(Loop 的最後)放 Execute code tick() 和 Wait 20 ms。
  12. 切到 Python 畫面對照下方程式,一樣就按 Run 試跑,螢幕會出現 bug-xxxx 和 IP,沒問題再按 Download。也可以直接按下方的「下載 bug_client.m5f」,在 UIFlow 打開已經拉好的版本。
積木名稱照 UIFlow 1.x 實際畫面(上方截圖)寫;下方程式已經用電腦模擬測過(掃描回覆、動作、停止、錯誤訊息、收資料時不會卡住步態)。
firmware/m5stickc_hexapod/bug_client.py:接在積木版程式庫後面的部分(115 行)下載 bug_client.py下載 bug_client.m5f
# ===== UDP 版:用電腦透過 WiFi 控制這隻昆蟲 =====
# 這份程式接在「積木版程式庫」後面執行(程式庫裡有 start()、stop()、tick() 這些動作函式)。
# 每一段前面的「積木:」寫的是 UIFlow 1.x 畫面上積木的實際名字;照著拉,UIFlow 產生的 Python 就和這裡一樣。
#
# 訊息格式(都是一行文字,用逗號分開):
#   電腦 -> 昆蟲  HELLO,電腦IP,電腦port   掃描:請回報你是誰,並記住回覆要送到哪裡
#   電腦 -> 昆蟲  GO,動作                  做一個動作:forward、backward、left、right、uturn
#   電腦 -> 昆蟲  STOP                     緊急停止,馬上站好
#   電腦 -> 昆蟲  PING                     問目前狀態
#   昆蟲 -> 電腦  HERE,名字,IP / OK,動作 / PONG,狀態 / ERR,原因
# UIFlow 的 Receive data 積木拿不到是誰傳來的,所以電腦在 HELLO 裡告訴昆蟲自己的地址。

# UIFlow 用了 Socket 積木就會自動在最上面加這一行,不用自己拉。
import socket

# 積木:Variables → Create variable,用一模一樣的名字建立這 5 個:ready、data、reply、server_ip、server_port。
# UIFlow 會在程式最上面替每個變數產生一行「名字 = None」,就是下面這 5 行。
# 積木的變數和 Execute code 裡同名的變數是同一個:handle_udp() 改了 reply,積木讀得到。
ready = None
data = None
reply = None
server_ip = None
server_port = None

# 積木:Execute code(貼這一段 Setup 用的小工具)
import network
import uselect

UDP_PORT = 4210              # 昆蟲在這個 port 等電腦的訊息;電腦端的 bug_server.py 也用 4210
server_ip = ""               # 電腦的 IP,收到 HELLO 之後才知道
server_port = 0              # 電腦接收回覆的 port
reply = ""                   # 這一次要回給電腦的文字;空字串表示不用回


# 用 WiFi 晶片的 MAC 位址最後 4 個十六進位數字,替這隻昆蟲取一個不會和別隻重複的名字。
# 輸出:例如 "bug-3f2a"。
def bug_name():
    mac = network.WLAN(network.STA_IF).config("mac")
    return "bug-" + "%02x%02x" % (mac[-2], mac[-1])


# 這隻昆蟲在 WiFi 網路上的 IP 位址,例如 "192.168.1.23"。
def my_ip():
    return network.WLAN(network.STA_IF).ifconfig()[0]


# 處理電腦傳來的一則訊息,決定要做什麼、要回什麼。
# 輸入:data 收到的 bytes。
# 結果:需要回覆時,把回覆文字放進全域變數 reply(Loop 裡的 Send data 積木會把它送出去)。
def handle_udp(data):
    global server_ip, server_port, reply
    reply = ""
    text = data.decode().strip()
    parts = text.split(",")
    if parts[0] == "HELLO" and len(parts) == 3:
        # 記住電腦的地址,之後的回覆都送到這裡。
        server_ip = parts[1]
        server_port = int(parts[2])
        reply = "HERE," + bug_name() + "," + my_ip()
    elif parts[0] == "GO" and len(parts) == 2:
        if parts[1] in MOTIONS:
            start(parts[1])
            reply = "OK," + parts[1]
        else:
            reply = "ERR,unknown motion " + parts[1]
    elif parts[0] == "STOP":
        stop()
        reply = "OK,STOP"
    elif parts[0] == "PING":
        # 狀態:正在做的動作,或是 ready(站著等指令)。
        if motion is not None:
            reply = "PONG," + motion
        else:
            reply = "PONG,ready"
    else:
        reply = "ERR,unknown message"


# 積木:UDP server start(set ip address "0.0.0.0",port 4210)
# 昆蟲是被電腦控制的那一端,但它要先「開門等訊息」,所以用的是 UDP server start 這塊積木。
udpsocket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udpsocket.bind(('0.0.0.0', UDP_PORT))

# 積木:Execute code
# UIFlow 的 Receive data 積木會一直卡住等資料,昆蟲就不能每 20 毫秒走一步了。
# 所以先準備一個「看一眼有沒有資料」的工具,有資料才去接收。
udp_watch = uselect.poll()
udp_watch.register(udpsocket, uselect.POLLIN)

# 積木:Execute code(在螢幕上顯示名字和 IP,方便對照電腦端的清單)
lcd.print(bug_name(), 4, 42, 0xffffff)
lcd.print(my_ip(), 4, 58, 0xffffff)

# 積木:Button A wasPressed,裡面放 Execute code: stop()(緊急停止)
btnA.wasPressed(stop)

# 積木:Loop(永遠重複)
while True:
    # 積木:Execute code: ready = udp_watch.poll(0)  看一眼有沒有資料:有的話 ready 是非空的清單,沒有就是空清單
    ready = udp_watch.poll(0)
    # 積木:if(Logic),條件放變數積木 ready
    if ready:
        # 積木:set data to,值放 Receive data by 64 bytes
        # 用 Receive data 不用 Read:Read 會等到湊滿指定的位元組數,訊息長短不一就會卡住。
        data = udpsocket.recv(64)
        # 積木:Execute code: handle_udp(data)
        handle_udp(data)
        # 積木:if(Logic),條件用 and 接兩個比較:reply ≠ ""(空文字)、server_port ≠ 0
        if reply != "" and server_port != 0:
            # 積木:Send data reply to ip address server_ip port server_port(三格都放變數積木)
            udpsocket.sendto(reply, (server_ip, server_port))
    # 積木:Execute code: tick()   讓步態往前走 20 毫秒
    tick()
    # 積木:Wait 20 ms
    wait_ms(STEP_MS)

電腦端:控制站 bug_server.py

  • 電腦和昆蟲連同一個 WiFi。學校或公共 WiFi 常常會擋住裝置之間的連線(AP 隔離),掃不到時改用手機熱點。
  • 在專案資料夾執行 python firmware/m5stickc_hexapod/bug_server.py,再用瀏覽器打開 http://127.0.0.1:8090。第一次執行時 macOS/Windows 可能會問要不要允許 Python 接受連線,選允許。
  • 按「掃描」:每找到一隻昆蟲就出現一張卡片,有前進、後退、左轉、右轉、迴轉、停止;找到兩隻以上會多一張「全部一起」。
  • 掃描廣播到哪裡:預設是 192.168.0.255(我們的昆蟲在 192.168.0.x),再加上電腦自己那一段。用 255.255.255.255 實測掃不到;昆蟲和電腦不在同一個網段時(例如昆蟲 192.168.0.x、電腦 192.168.1.x),一定要廣播到昆蟲那一段。網段不一樣時,在網頁上方的輸入框改(逗號分開可以填好幾個),或執行時加 --broadcast 192.168.0.255。昆蟲的 IP 會顯示在 StickC 螢幕上,把最後一段換成 255 就是它的廣播位址。
  • 這個網頁只開在你自己的電腦上(127.0.0.1),同一個 WiFi 的其他人打不開,也就不能亂動你的機器人。

之後接果蠅神經系統:控制站的核心是 BugNet 類別,網頁只是它的一個用法。大腦決定好動作後這樣呼叫就好:

net = BugNet()、net.scan()、net.go("bug-3f2a", "left")、net.stop("all")

firmware/m5stickc_hexapod/bug_server.py(306 行)下載 bug_server.py
# FlyBrain 昆蟲遙控站:在電腦上用 WiFi(UDP)掃描、控制好幾隻 StickC 六足昆蟲。
#
# 昆蟲那一端跑 bug_client.py(UIFlow 積木版)。這支程式有兩個部分:
#   1. BugNet 類別:掃描、記住每隻昆蟲、送動作指令。之後要接 FlyBrain 的果蠅神經系統時,
#      只要 import 這個類別,讓大腦決定動作後呼叫 go() 就好。
#   2. 一個本機網頁:每隻昆蟲一張卡片,按按鈕就能手動控制。
#
# 用法(電腦和昆蟲要連同一個 WiFi):
#     python firmware/m5stickc_hexapod/bug_server.py
# 然後用瀏覽器打開 http://127.0.0.1:8090 ,按「掃描」。
from __future__ import annotations

import argparse
import asyncio
import socket
import time

from aiohttp import web

BUG_PORT = 4210              # 昆蟲在這個 port 等訊息(和 bug_client.py 的 UDP_PORT 一樣)
LISTEN_PORT = 4211           # 電腦在這個 port 收昆蟲的回覆
# 昆蟲所在網段的廣播位址。我們的昆蟲連在 192.168.0.x,電腦在 192.168.1.x(不同網段),
# 要廣播到「昆蟲那一段」路由器才會轉送過去;網段不同時,在網頁上或用 --broadcast 改。
BUG_BROADCAST = "192.168.0.255"
MOTIONS = ["forward", "backward", "left", "right", "uturn"]


# 找出這台電腦在區域網路上的 IP。
# 做法:對外面開一個 UDP「連線」(UDP 的 connect 不會真的送出封包),
# 作業系統就會選好要走哪張網卡,再問它用的是哪個 IP。
# 輸出:例如 "192.168.1.10";沒有網路時回傳 "127.0.0.1"。
def lan_ip() -> str:
    probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        probe.connect(("10.255.255.255", 1))
        return probe.getsockname()[0]
    except OSError:
        return "127.0.0.1"
    finally:
        probe.close()


# 算出電腦自己那一段的「子網路廣播位址」:把 IP 最後一段換成 255,例如 192.168.1.102 -> 192.168.1.255。
# 為什麼不用 255.255.255.255:那是「全部廣播」,實測昆蟲收不到;子網路廣播會照路由送出去。
# 這裡假設常見的子網路遮罩 255.255.255.0(IP 前三段相同就在同一個網段)。
def subnet_broadcast(ip: str) -> str:
    parts = ip.split(".")
    return parts[0] + "." + parts[1] + "." + parts[2] + ".255"


class BugNet:
    # 管理所有昆蟲的物件。
    # 輸入:listen_port 電腦收回覆用的 port(0 表示讓系統隨便挑一個空的,測試時用);
    #       bug_port 昆蟲等訊息的 port;my_ip 告訴昆蟲回覆要送到哪個 IP(預設自動找);
    #       broadcasts 掃描時廣播到哪些位址(預設:昆蟲那一段 BUG_BROADCAST,加上電腦自己那一段)。
    def __init__(self, listen_port: int = LISTEN_PORT, bug_port: int = BUG_PORT, my_ip: str | None = None,
                 broadcasts: list[str] | None = None):
        self.bug_port = bug_port
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        # 允許送「廣播」:一次送給同一個 WiFi 裡的所有裝置,掃描時要用。
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        self.sock.bind(("0.0.0.0", listen_port))
        self.listen_port = self.sock.getsockname()[1]
        if my_ip is None:
            my_ip = lan_ip()
        self.my_ip = my_ip
        if broadcasts is None:
            broadcasts = [BUG_BROADCAST]
            own = subnet_broadcast(my_ip)
            if own not in broadcasts:
                broadcasts.append(own)
        self.broadcasts = broadcasts
        # 找到的昆蟲:名字 -> {"address": (ip, port), "state": 最後的狀態, "last_seen": 時間}
        self.bugs: dict[str, dict] = {}

    # 把一行文字送到某個地址。
    def _send_to(self, text: str, address: tuple[str, int]) -> None:
        self.sock.sendto(text.encode(), address)

    # 在 seconds 秒之內,把收到的回覆全部讀進來,更新每隻昆蟲的狀態。
    # 輸出:這段時間收到的回覆清單,每一筆是 (地址, 文字)。
    def collect_replies(self, seconds: float) -> list[tuple[tuple[str, int], str]]:
        replies = []
        deadline = time.monotonic() + seconds
        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                break
            self.sock.settimeout(remaining)
            try:
                data, address = self.sock.recvfrom(256)
            except (socket.timeout, OSError):
                break
            text = data.decode(errors="replace").strip()
            replies.append((address, text))
            self._remember(address, text)
        return replies

    # 根據一則回覆更新昆蟲清單。
    # HERE,名字,IP 是掃描的回覆:把這隻昆蟲加進清單(地址用封包實際的來源,最可靠)。
    # 其他回覆(OK/PONG/ERR)就更新那隻昆蟲最後的狀態。
    def _remember(self, address: tuple[str, int], text: str) -> None:
        parts = text.split(",")
        now = time.time()
        if parts[0] == "HERE" and len(parts) >= 2:
            name = parts[1]
            self.bugs[name] = {"address": address, "state": "ready", "last_seen": now}
            return
        for info in self.bugs.values():
            if info["address"] == address:
                info["state"] = text
                info["last_seen"] = now

    # 掃描:送出 HELLO,等昆蟲回報。
    # 輸入:seconds 等多久;targets 要問的地址清單,預設是 broadcasts 裡的每個廣播位址。
    # 輸出:目前認識的所有昆蟲名字(排好順序)。
    # UDP 可能掉封包,所以 HELLO 連送 3 次;同一隻回了好幾次也只記一筆。
    def scan(self, seconds: float = 1.0, targets: list[tuple[str, int]] | None = None) -> list[str]:
        if targets is None:
            targets = []
            for address in self.broadcasts:
                targets.append((address, self.bug_port))
        hello = "HELLO," + self.my_ip + "," + str(self.listen_port)
        for attempt in range(3):
            for target in targets:
                self._send_to(hello, target)
            self.collect_replies(seconds / 3)
        return sorted(self.bugs)

    # 把一行指令送給某一隻昆蟲;name 是 "all" 時送給每一隻。
    # 輸出:實際送到的昆蟲名字清單(名字不認識時是空清單)。
    def send(self, name: str, text: str) -> list[str]:
        sent = []
        for bug_name, info in self.bugs.items():
            if name == "all" or name == bug_name:
                self._send_to(text, info["address"])
                sent.append(bug_name)
        return sent

    # 叫昆蟲做一個動作,例如 go("bug-3f2a", "forward")。
    def go(self, name: str, motion: str) -> list[str]:
        if motion not in MOTIONS:
            raise ValueError("unknown motion: " + motion)
        return self.send(name, "GO," + motion)

    # 緊急停止。
    def stop(self, name: str = "all") -> list[str]:
        return self.send(name, "STOP")

    # 問狀態,回覆會在 collect_replies() 時更新到 bugs 裡。
    def ping(self, name: str = "all") -> list[str]:
        return self.send(name, "PING")

    # 給網頁用的清單:每隻昆蟲的名字、IP、狀態、幾秒前有消息。
    def summary(self) -> list[dict]:
        rows = []
        now = time.time()
        for name in sorted(self.bugs):
            info = self.bugs[name]
            rows.append({"name": name, "ip": info["address"][0], "state": info["state"],
                         "seen_s": round(now - info["last_seen"], 1)})
        return rows

    def close(self) -> None:
        self.sock.close()


# ---------------- 本機網頁 ----------------
PAGE = """<!DOCTYPE html>
<html lang="zh-Hant"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FlyBrain 昆蟲遙控站</title>
<style>
body{margin:0;padding:16px;background:#0b0e14;color:#e6e9ef;font:15px/1.6 -apple-system,"PingFang TC","Noto Sans TC",sans-serif}
h1{font-size:20px;margin:0 0 4px} p.sub{color:#8b95a7;margin:0 0 14px}
button{font:inherit;border:1px solid #2a3242;background:#171c26;color:#e6e9ef;border-radius:8px;padding:6px 12px;cursor:pointer}
button:hover{border-color:#84cc16} button.stop{border-color:#e74c3c;color:#fca5a5}
button.main{background:#84cc16;color:#0b0e14;border-color:#84cc16;font-weight:700}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}
.card{background:#171c26;border:1px solid #2a3242;border-left:3px solid #84cc16;border-radius:12px;padding:12px}
.card.all{border-left-color:#4cc9f0} .card h2{font-size:16px;margin:0} .meta{color:#8b95a7;font-size:13px;margin:2px 0 8px}
.pad{display:flex;flex-wrap:wrap;gap:6px} #msg{color:#8b95a7;font-size:13px;margin-left:8px}
#where{font:inherit;background:#0b0e14;color:#e6e9ef;border:1px solid #2a3242;border-radius:8px;padding:5px 8px;margin:0 8px 0 4px}
</style></head><body>
<h1>🪲 FlyBrain 昆蟲遙控站</h1>
<p class="sub">電腦和昆蟲連同一個 WiFi。先按「掃描」,找到的每一隻昆蟲會出現一張卡片。</p>
<label class="sub">掃描廣播到(用逗號分開):<input id="where" size="34"></label>
<button class="main" id="scan">掃描</button><span id="msg"></span>
<div class="grid" id="grid"></div>
<script>
var MOTIONS = [["forward","前進"],["backward","後退"],["left","左轉"],["right","右轉"],["uturn","迴轉"]];
function api(path, body) {
  return fetch(path, {method: "POST", headers: {"Content-Type": "application/json"},
                      body: JSON.stringify(body || {})}).then(function (r) { return r.json(); });
}
function card(title, meta, target, cls) {
  var box = document.createElement("div");
  box.className = "card" + (cls ? " " + cls : "");
  var h = document.createElement("h2"); h.textContent = title; box.appendChild(h);
  var m = document.createElement("div"); m.className = "meta"; m.textContent = meta; box.appendChild(m);
  var pad = document.createElement("div"); pad.className = "pad";
  MOTIONS.forEach(function (pair) {
    var b = document.createElement("button"); b.textContent = pair[1];
    b.onclick = function () { api("/api/go", {bug: target, motion: pair[0]}).then(show); };
    pad.appendChild(b);
  });
  var s = document.createElement("button"); s.textContent = "停止"; s.className = "stop";
  s.onclick = function () { api("/api/stop", {bug: target}).then(show); };
  pad.appendChild(s);
  box.appendChild(pad);
  return box;
}
function show(result) {
  var grid = document.getElementById("grid");
  grid.textContent = "";
  var bugs = result.bugs || [];
  document.getElementById("msg").textContent = result.message || ("找到 " + bugs.length + " 隻");
  var where = document.getElementById("where");
  if (result.broadcasts && document.activeElement !== where) { where.value = result.broadcasts.join(", "); }
  if (bugs.length > 1) { grid.appendChild(card("全部一起", bugs.length + " 隻", "all", "all")); }
  bugs.forEach(function (b) {
    grid.appendChild(card(b.name, b.ip + "・" + b.state + "・" + b.seen_s + " 秒前", b.name));
  });
}
document.getElementById("scan").onclick = function () {
  document.getElementById("msg").textContent = "掃描中…";
  api("/api/scan", {broadcasts: document.getElementById("where").value}).then(show);
};
api("/api/bugs").then(show);
</script></body></html>
"""


# 檢查一段文字是不是 IPv4 位址,例如 "192.168.0.255":四段、每段是 0~255 的數字。
def is_ipv4(text: str) -> bool:
    parts = text.split(".")
    if len(parts) != 4:
        return False
    for part in parts:
        if not part.isdigit() or int(part) > 255:
            return False
    return True


# 建立網頁伺服器。BugNet 的方法會等網路回覆,所以丟到背景執行緒跑,網頁才不會卡住。
def make_web_app(net: BugNet) -> web.Application:
    async def page(request: web.Request) -> web.Response:
        return web.Response(text=PAGE, content_type="text/html", charset="utf-8")

    async def bugs(request: web.Request) -> web.Response:
        return web.json_response({"bugs": net.summary(), "broadcasts": net.broadcasts, "my_ip": net.my_ip})

    async def scan(request: web.Request) -> web.Response:
        body = await request.json()
        # 網頁輸入框裡的廣播位址(逗號分開);格式不對的就不收,全部不對時沿用原本的設定。
        typed = []
        for piece in str(body.get("broadcasts", "")).split(","):
            piece = piece.strip()
            if is_ipv4(piece):
                typed.append(piece)
        if typed:
            net.broadcasts = typed
        names = await asyncio.to_thread(net.scan)
        return web.json_response({"bugs": net.summary(), "message": "找到 " + str(len(names)) + " 隻",
                                  "broadcasts": net.broadcasts, "my_ip": net.my_ip})

    async def go(request: web.Request) -> web.Response:
        body = await request.json()
        motion = str(body.get("motion", ""))
        if motion not in MOTIONS:
            return web.json_response({"bugs": net.summary(), "message": "不認識的動作"}, status=400)
        sent = net.go(str(body.get("bug", "all")), motion)
        await asyncio.to_thread(net.collect_replies, 0.3)
        return web.json_response({"bugs": net.summary(), "message": "已送給 " + str(len(sent)) + " 隻"})

    async def stop(request: web.Request) -> web.Response:
        body = await request.json()
        sent = net.stop(str(body.get("bug", "all")))
        await asyncio.to_thread(net.collect_replies, 0.3)
        return web.json_response({"bugs": net.summary(), "message": "停止:" + str(len(sent)) + " 隻"})

    app = web.Application()
    app.router.add_get("/", page)
    app.router.add_get("/api/bugs", bugs)
    app.router.add_post("/api/bugs", bugs)
    app.router.add_post("/api/scan", scan)
    app.router.add_post("/api/go", go)
    app.router.add_post("/api/stop", stop)
    return app


def main() -> None:
    parser = argparse.ArgumentParser(description="FlyBrain 昆蟲遙控站")
    parser.add_argument("--port", type=int, default=8090, help="網頁的 port")
    parser.add_argument("--broadcast", action="append", default=None,
                        help="掃描的廣播位址,可以給好幾次,例如 --broadcast 192.168.0.255")
    args = parser.parse_args()
    net = BugNet(broadcasts=args.broadcast)
    print("電腦 IP:" + net.my_ip + ",收回覆的 port:" + str(net.listen_port))
    print("掃描廣播到:" + ", ".join(net.broadcasts))
    print("用瀏覽器打開 http://127.0.0.1:" + str(args.port))
    web.run_app(make_web_app(net), host="127.0.0.1", port=args.port, print=None)


if __name__ == "__main__":
    main()
08

校正

  • 先把每隻腳的角度歸零:把 LEG_TRIM 全部設成 0,程式一開始會 stop(),六顆馬達都轉到 90°。這時把每隻腳的舵角(白色塑膠臂)拔起來,重新插在「腳垂直於身體」的位置。
  • 插不準的那幾度用 LEG_TRIM 微調,例如 L2 偏前 5°,就把第二個數字改成 -5。順序是 L1 L2 L3 R1 R2 R3。
  • 確認方向:左邊 CH6/CH7/CH8 的馬達 0° 是腳往前、180° 是腳往後;右邊 CH2/CH3/CH4 相反,180° 往前、0° 往後。架高後按「前進」,第一拍是三腳架 A(L1、L3、R2)先往前擺:CH6、CH8 的角度往 0° 變小、CH3 往 180° 變大,同時三腳架 B(L2、R1、R3)往後推。
  • 某一隻腳轉的方向相反(其他腳往前它往後):把 LEG_DIRECTION 裡那隻腳的 1 改成 -1(或 -1 改成 1)。如果是整邊都反了,就是馬達裝的方向和這份程式假設的不同,把那一邊三個數字一起反過來。
  • 校正轉彎角度:在你要表演的地板上按「右轉」和「迴轉」,用量角器或地上的膠帶量身體實際轉了幾度,再用新的週期數 = 目前的週期數 × 想要的角度 ÷ 量到的角度 改 TURN_CYCLES 和 UTURN_CYCLES。我們的機器人原本走 3 個週期轉了約 75°,所以左右轉改成 3 × 45 ÷ 75 = 1.8;迴轉原本 6 個週期轉了約 135°,所以改成 6 × 180 ÷ 135 = 8。可以用小數。換一種地板或電池沒電時要重新量。
  • FREQ_HZ 調走路的快慢:太快 SG-90 跟不上,會發抖、步伐變小;AMPLITUDE_DEG 調步幅:太大前後腳會互撞。一次只改一個數字,改完記錄走 1 公尺花幾秒,就是一個小實驗。
09

已知限制

下面這些不是 bug,是這個設計本來就做不到的地方,請照實寫進報告。
  • 每隻腳只有一個自由度:只會前後擺,不會抬腳。和 FlyBrain 的實體機器人一樣,要靠單向腳(腳底往後推時抓地、往前收時滑過去,例如斜貼的毛刷或倒刺)才會往前走。
  • 後退走不太動:「後退」只是把同一段步伐倒著播放。裝了單向腳時,腳底還是只在往後推的時候抓地,所以機器人幾乎不會真的往後走(可能原地踏步);只有換成沒有方向性的腳(例如普通橡膠腳墊)才會往後退。
  • 轉彎是原地旋轉:左轉、右轉是一邊往前、一邊往後,身體在原地打轉,不是邊走邊彎。裝了單向腳時,往後播放的那一邊抓不到地,就會退化成只有一邊在推、比較慢的轉法。
  • 迴轉的 180 度靠校正:程式只能控制走幾個週期,每個週期轉幾度取決於地板的摩擦力和電池電量,一定要在現場地板上量過、調整 TURN_CYCLES 和 UTURN_CYCLES,轉彎才會準。
  • 實機驗證到哪裡:Python 版已經在真的 M5StickC 和 Hat 上跑過,轉彎角度是在實體機器人上量的;步態、接線和動作表另外有電腦上的自動測試(用假的 UIFlow 模組)。積木版還沒有在實機上跑過,小字型和後退的實際效果也還沒確認。第一次上機請架高測試,有問題就按 A 鍵停止。
10

常見問題

狀況最可能的原因怎麼處理
馬達一直抖、StickC 自己重開電池電量不夠或只插 USB-C把 Hat 的鋰電池充飽;不要只靠 USB 供電
完全沒有任何一顆馬達會動伺服電源沒打開,或電池開關沒開確認用的是這一頁的程式(它會寫 0x30 = 1 打開電源);打開 Hat 電池開關、確認電池有電
出現 Servos hat maybe not connect那是 UIFlow 內建 Hat 模組的訊息,Hat 插好了也可能出現這支程式不用那個模組,改用 I²C 直接控制,照這一頁貼上就好
螢幕顯示 NO HAT,後面的清單是空的 []I²C 上什麼都沒找到把 Hat 拔起來重新插到底(排針要完全插進去)、打開 Hat 的電池開關,再重新開機
螢幕顯示 NO HAT,清單裡有別的位址接到的不是 Hat 8Servos,或 Hat 版本不同對照清單:v1.1 應該是 0x36、v1.0 是 0x38
只有一隻腳方向相反那顆馬達裝的方向不一樣把 LEG_DIRECTION 裡那隻腳的正負號反過來
某隻腳不會動插錯通道或插反對照接線表:左 CH6/7/8、右 CH2/3/4;棕線對 GND
手機 Remote+ 按了沒反應StickC 沒連上網,或 API KEY 不對確認 WiFi 是 2.4 GHz、StickC 螢幕顯示已連線、UIFlow 填的是同一組 API KEY;再檢查 callback 名稱
前進時原地踏步腳底不是單向腳腳底加上斜向毛刷或倒刺,讓往後推時抓地、往前收時打滑