EV3 ev3dev、M5Stack、UnitV2-M12をMQTTで扱う設計メモ

EV3 ev3dev、M5Stack、UnitV2-M12をMQTTで扱う設計メモ

目的

LEGO Mindstorms EV3、ev3dev、M5Stack Core2、BaseX、UnitV2-M12 を再利用し、中央の controller host から MQTT で制御・観測する。 家庭内の実験環境でも、motor を扱う以上、通信遅延や controller 停止時に安全側へ倒れる設計を先に決めておく。

初期構成では、controller host に MQTT broker を置き、EV3 と M5Stack を motor device node、UnitV2-M12 を vision node として扱う。

controller host
  -> MQTT broker
      -> EV3 ev3dev node
      -> M5Stack Core2 + BaseX node
      -> UnitV2-M12 vision node bridge

EV3はlegacy embedded Linux targetとして扱う

ev3dev-stretch の EV3 は、CPU、RAM、Debian version、Python version が古い。 新しい Ansible core の通常 module payload をそのまま動かす前提にしない方がよい。

方針:

  • EV3 は通常の managed node ではなく legacy embedded Linux target と見る
  • Ansible を使う場合も raw / script / scp 相当の低依存 bootstrap に限定する
  • facts、template module、service module、複雑な role を前提にしない
  • EV3 側には最小の node script と systemd unit だけを置く
  • 依存 package はできるだけ増やさない

古い target Python を新しい controller に合わせて更新するより、EV3 用 bootstrap を薄く保つ方が実験速度と再現性のバランスがよい。

MQTT topic設計

device ごとに command と status を分ける。

robot/<device-id>/cmd/enable
robot/<device-id>/cmd/drive
robot/<device-id>/cmd/stop
robot/<device-id>/cmd/estop
robot/<device-id>/status/heartbeat
robot/<device-id>/status/online
robot/<device-id>/status/vision
robot/<device-id>/event/detection

robot/group/all/cmd/stop
robot/group/all/cmd/estop

group topic は全体停止や emergency stop 用に使う。 個別 device の drive command と混ぜない。

安全仕様

device node は起動直後に motor を動かさない。

  • 起動直後は enabled=false
  • enable を受けるまで drive は無視する
  • stop は motor を停止する
  • estop は latch し、明示解除まで drive を無視する
  • drive には TTL を必須にする
  • TTL 期限切れで motor を停止する
  • MQTT disconnect / reconnect 中は motor を停止する
  • speed / duty は device 側で clamp する
  • retained command は読まない
  • EV3 service は自動起動するが、node は起動直後 enabled=false から始める

stopestop は、sequence number が古くても安全側として受理する。 drive の順序制御は重要だが、停止 command を順序理由で捨てない。

Command JSON

例。

{
  "seq": 42,
  "issuer_id": "controller",
  "epoch": 1,
  "left": 20,
  "right": 20,
  "ttl_ms": 500
}

device 側で見るもの。

  • seq: 同じ issuer 内の順序制御
  • issuer_id: controller の識別
  • epoch: controller 再起動後の古い command 排除
  • ttl_ms: command の有効期限
  • EV3: left / right で左右 motor speed を渡す
  • M5Stack BaseX: motor / duty で対象 port と duty を渡す

TTL は短くしすぎると通信 jitter で止まりやすく、長くしすぎると controller 消失時に走り続ける。 最初は 500-1000 ms 程度に clamp し、controller から 100-200 ms 間隔で再送する形が扱いやすい。

EV3側のMQTT client

当初の暫定実装では、EV3 側に既に入っている mosquitto_sub / mosquitto_pub を subprocess として使った。 ただし long-lived pipe は stdout buffering で command 反映が遅れることがあり、mosquitto_sub -C 1 方式では 1 message ごとに再 subscribe の隙間ができる。

短周期 drive command ではこの隙間で取りこぼしが出たため、EV3 側は paho-mqtt 1.4.0 を vendoring した常駐 MQTT client へ移行した。

現行方針:

vendored MQTT client:
  - paho-mqtt 1.4.0
  - EV3側にpip installしない
  - controller側で vendor/ に固定し、raw bootstrapで配置する
  - Python 3.5.3 で import / connect を確認済み
  - clean_session=True
  - MQTT v3.1.1
  - LWT は status/online=false、retain=false
  - on_disconnect で stop_motors()

EV3 の ansible-core 通常 module 対応を目指して Python を更新するより、EV3 専用の薄い raw bootstrap と vendored pure Python package に寄せる方が、この環境では単純で再現性が高い。

M5Stack Core2 + BaseX

M5Stack Core2 は USB serial で firmware upload と monitor を行い、通常通信は Wi-Fi 経由の MQTT にする。 USB over network を初期実装にしないことで、firmware 開発経路と runtime 通信経路を分けられる。

BaseX の motor 制御は低 duty、短時間、1 系統から始める。

  • I2C scan で BaseX address が見えることを確認する
  • motor register へ書く前に read-only firmware で観測する
  • motor test 後は read-only firmware または safe firmware に戻す
  • 複数 motor は低 duty / 10 秒まで確認済み
  • 急反転、高 duty、車体搭載後の長時間連続駆動は後段にする

UnitV2-M12 vision node

UnitV2-M12 は motor node ではなく、USB Ethernet 接続の vision appliance として扱う。 UnitV2 本体の stock firmware は Web UI、MJPEG video、SSH を持つため、初期実装では本体へ daemon を置かず、controller host 側の bridge が read-only に観測する。

確認済みの接続:

USB:
  - 0fe6:9900 ICS Advent USB 10/100 LAN
  - driver: cdc_ether
  - interface: enx00e0994ebb9c

Network:
  - host address: 10.254.239.96/24
  - UnitV2 address: 10.254.239.1
  - unitv2.py -> 10.254.239.1

HTTP:
  - http://10.254.239.1/ : 200 OK
  - http://10.254.239.1/video : multipart/x-mixed-replace

SSH:
  - 10.254.239.1:22 open

映像 frame 本体は MQTT に流さない。 MQTT には heartbeat、認識状態、検出 event の metadata だけを流す。

robot/unitv2-01/status/heartbeat
robot/unitv2-01/status/vision
robot/unitv2-01/event/detection

UnitV2 の DHCP は default route と DNS を配るため、NetworkManager profile は ipv4.never-default yes / ipv4.ignore-auto-dns yes にする。 bus-powered USB hub 配下では reset error が出たため、PC 直結または powered hub を優先する。

UnitV2 の Wi-Fi STA 側 MAC は wlan000:e0:99:84:99:25wlan1 は本体 AP 用で、固定 lease には使わない。 2026-05-23 に外部 inventory の openwrt_pxe_hostsunitv2-01 として追加し、OpenWrt へ dhcp tag で反映した。 固定 IP は 10.10.254.250。 PXE boot 対象ではないため、filenametags、DHCP Option 224 は付けない。 同日に UnitV2 を SSH 経由で reboot して確認したところ、USB Ethernet、recognition service、camera stream は復帰したが、OpenWrt 側の /tmp/dhcp.leasesunitv2-01 は出なかった。 本体の wlan000:e0:99:84:99:25 のまま IPv4 address がなく、/etc/wpa_supplicant.conf に network block が無かった。 したがって固定 lease は準備済みだが、UnitV2 側に家庭用 Wi-Fi credentials を設定するまでは 10.10.254.250 は取得されない。

UnitV2 は robot の vision node なので、Wi-Fi 反映用 Ansible は robot/ansible/unitv2 に置く。 Wi-Fi password 実値は robot repository へ置かず、親 inventory の home-router.openwrt_wireless_interfaces から実行時に参照する。 2026-05-23 に ansible-playbook site.yml --ask-pass --ask-become-pass --tags unitv2_wifi で反映した。 反映後は wlan010.10.254.250/24 が付き、SSID OpenWrt2Ghz へ接続した。 OpenWrt 側では DHCPACK 10.10.254.250 00:e0:99:84:99:25 unitv2-01 を確認し、controller host から 10.10.254.250 へ ping できた。 USB Ethernet 側の 10.254.239.1 と camera stream も継続して正常だった。

初期観測時の本体は factory_test mode で動いていた。 HTTP /video は使えるが、recognition service 用の /data_from_device/video_feed/meta は 404 になる。 一方、本体内には /home/m5stack/payload/server_core.py があり、recognition service の endpoint 実装自体は存在する。 そのため次の分岐は、factory test app を止めて recognition service へ切り替えるかどうかである。 非永続の手動切り替えでは、recognition service の /video_feed から frame を取得できた。 ただし /data_from_device は idle 状態では {'running':'null'} を返す。 恒久化の観点では、/etc/init.d/S85runpayload は標準で /home/m5stack/payload/server.py を起動する構成だった。 factory test が起動していた主因は、SD カード上に残っている M5UnitV2SwUpdPackage.img である。 この package の deploy.py は payload server を止め、factory test app を foreground 起動する。 したがってまずは SD カード上の factory test update package と FAILED_TO_UPGRADE を退避し、reboot 後に payload server がそのまま立ち上がるか確認するのが最小変更になる。

公式の UnitV2 firmware update page で確認できる recovery package は M5UnitV2RootfsRecoveryPackage-09072021 のみで、download object の Last-Modified は 2021-09-07 である。 M5Stack の M5Unit-V2-AICamera GitHub repository は demonstration library で、release asset は公開されていない。 現物は payload directory や init script が 2021-09-06 世代であり、公式 recovery package は同世代またはわずかに新しい rootfs reset と見るのが妥当である。 このため、今回の事象では firmware recovery を先に実施する必要性は低く、SD カード上の factory test package を退避して再起動検証する方を優先する。 recovery package は rootfs 破損、payload server 起動不能、または公式初期状態へ戻したい場合の second option とする。

実機では automount.sh が SD カード配下を再帰的に M5UnitV2SwUpdPackage*.img で探すことが分かった。 そのため退避 directory 内でも同じ file name のままだと再度 factory test package として拾われる。 最終的には factory-test-package.img.disabled へ改名し、展開済みの upgradeTempupgradeTemp.disabled として退避した。 この状態で reboot すると /etc/init.d/S85runpayload の payload server が残り、recognition_service として起動した。 ただし reboot 直後の /get_last_funcnull で、camera stream function は自動選択されない。 controller host 側から /funccamera_stream を POST すると /video_feed から frame を取得できる。 このため scripts/unitv2_probe.py start-camera-streamscripts/unitv2_mqtt_bridge.py --ensure-camera-stream を追加し、UnitV2 rootfs を触らずに起動時補正できるようにした。

Web UI は最初から操作系を持たせず、観測専用として追加した。 FastAPI は未導入だったため、追加 dependency なしで Python 標準 library の http.server、SSE、mosquitto_sub subprocess を使う。 scripts/robot_web_ui.pyrobot/+/status/heartbeat を subscribe し、device card と latest UnitV2 heartbeat JSON を表示する。 UnitV2 の MJPEG は /unitv2/video_feed で controller host 経由に proxy する。 これにより browser 側が 10.254.239.1 への route を持たない場合でも controller host の UI から camera stream を確認できる。 UnitV2 は CPU / memory に余裕が少なく、MJPEG stream や object recognition を重ねると止まって見えやすい。 常用 heartbeat bridge は --frames--poll-recognition を付けず、--ensure-camera-stream だけで camera_stream を維持する。 QR、line tracking、object recognition は当面 off とし、カメラ preview 専用にする。 この仕様でしばらく運用し、object recognition は video、heartbeat、給電、USB topology が安定してから低優先の将来検証として扱う。 Web UI proxy は UnitV2 への同時 MJPEG upstream 接続を既定 3 本に制限し、4 本目以降は HTTP 429 を返す。 heartbeat bridge は user systemd service robot-unitv2-mqtt-bridge.service として自動起動する。

Controller helper

手動 mosquitto_pub だけでは、sequence、epoch、timing、cleanup がばらける。 controller helper を作り、終了時に必ず stop または estop を出す。

scripts/ev3_mqtt_drive.py enable
scripts/ev3_mqtt_drive.py drive --left 15 --right 15 --ttl-ms 500
scripts/ev3_mqtt_drive.py repeat --left 15 --right 15 --duration 2 --interval 0.2 --cleanup stop
scripts/ev3_mqtt_drive.py stop
scripts/ev3_mqtt_drive.py estop
scripts/ev3_mqtt_drive.py watch --count 1 --timeout 5

scripts/m5stack_mqtt_drive.py enable
scripts/m5stack_mqtt_drive.py drive --duty 25 --ttl-ms 800
scripts/m5stack_mqtt_drive.py repeat --duty 25 --duration 5 --interval 0.4 --cleanup stop
scripts/m5stack_mqtt_drive.py stop
scripts/m5stack_mqtt_drive.py estop
scripts/m5stack_mqtt_drive.py watch --count 1 --timeout 5

scripts/unitv2_probe.py probe --frames
scripts/unitv2_probe.py watch --count 5 --timeout 5
scripts/unitv2_probe.py snapshot --output /tmp/unitv2-snapshot.jpg
scripts/unitv2_probe.py start-camera-stream
scripts/unitv2_mqtt_bridge.py --count 1 --frames --print-payload
scripts/unitv2_mqtt_bridge.py --interval 5 --ensure-camera-stream
scripts/robot_web_ui.py --listen-host 127.0.0.1 --listen-port 8088 --unitv2-max-video-clients 3

scripts/legoctl.py watch --count 1 --timeout 5
scripts/legoctl.py camera unitv2-01
scripts/legoctl.py stop --all
scripts/legoctl.py estop --all
scripts/legoctl.py drive ev3-01 --left 15 --right 15 --ttl-ms 500
scripts/legoctl.py drive m5x-01 --motor 1 --duty 25 --ttl-ms 800
scripts/legoctl.py tank m5x-01 --left 15 --right 15 --ttl-ms 500 --duration 10 --interval 0.2 --cleanup stop

helper は SIGINT / SIGTERM でも cleanup を出す。 motor 実験では、操作性より停止の確実性を優先する。

EV3 と M5Stack の helper は subcommand 体系を揃えている。 device 固有の drive option だけが異なる。

EV3:
  --left / --right

M5Stack:
  --motor / --duty
  tank --left / --right

UnitV2:
  read-only probe / video watch / snapshot / heartbeat bridge

legoctl.py は横断入口であり、既存 helper と同じ topic / payload を publish する。 当面は EV3 / M5Stack の motor 操作と UnitV2 の camera probe を同じ操作体系で扱うための薄い wrapper として使う。

現在の実装・検証状況

2026-05-23 時点の確認済み事項。

EV3:

- EV3 は ev3dev-stretch / Python 3.5.3
- ansible-core の通常 module は target Python が古くて動かない
- raw bootstrap で /opt/robot/ev3-node へ配置
- paho-mqtt 1.4.0 を vendor/ 配置
- ev3-mqtt-node.service は enabled / active へ切り替え
- service 起動直後の node state は enabled=false
- heartbeat publish 成功
- disabled drive は無視
- enable 後、200ms 間隔 drive 10 回 + stop を取りこぼさず処理
- group/all/cmd/stop で停止
- estop で enabled=false / estop_latched=true
- stopped 状態で bootstrap idempotence changed=0 を確認済み

M5Stack Core2 + BaseX:

- Core2 K010 + BaseX の物理干渉なし
- BaseX I2C address 0x22 を確認
- BaseX port 1 の EV3 motor を duty +25 / 5 秒で動作確認
- BaseX port 2 の EV3 motor を duty +15 / 1 秒で動作確認
- BaseX port 1 / 2 の EV3 motor を duty +15 / 10 秒で同時動作確認
- `legoctl.py tank m5x-01 --left 15 --right 15` は upload 後の 2 秒検証で Motor 1 encoder 0 -> 153、Motor 2 encoder 0 -> 90
- MQTT command + watchdog + TTL firmware を upload 済み
- firmware は Motor 1 / 2 に対応
- heartbeat は Motor 1 互換 field に加えて motors array で Motor 1 / 2 の duty / ttl / encoder を返す
- disabled / stop / estop / TTL / MQTT disconnect 停止を確認済み
- helper は EV3 helper と同じ subcommand 体系
- `legoctl.py tank m5x-01` は left=Motor 1 / right=Motor 2 を既定にして、単一 loop から両 motor の TTL を更新する
- M5Stack firmware の `duty=0` drive は指定 motor stop とし、明示 stop / estop / disconnect は全 motor stop とする
- 次の候補は `legoctl.py tank-closed m5x-01` による controller 側 P 制御で、heartbeat の encoder 差分から左右 duty を補正する
- closed-loop tank は浮かせた状態で 2 秒、5 秒、10 秒の順に確認し、補正値と安全条件が見えてから firmware 側への移植を検討する

UnitV2-M12:

- USB Ethernet として Linux から認識
- PC 直結後は root hub 配下で安定
- DHCP で 10.254.239.96/24 を取得
- Web UI / MJPEG video / SSH port を確認済み
- NetworkManager profile は default route / DNS を受け取らない設定へ寄せる
- scripts/unitv2_probe.py を追加
- scripts/unitv2_mqtt_bridge.py を追加
- 初期状態は factory_test mode で、recognition service 用 endpoint は 404 だった
- 非永続の手動切り替えで recognition_service mode と /video_feed frame 取得を確認
- SD カード上の factory test package 退避後は reboot 後も recognition_service として起動
- 常用は recognition_service + camera_stream
- Web UI で UnitV2 heartbeat と controller-host 経由の video proxy を表示
- heartbeat bridge は軽量設定で user systemd 自動起動
- QR / line tracking / object recognition は当面 off、camera_stream 専用でしばらく運用
- object recognition は安定後に低優先で将来検証
- S85runpayload は payload server を起動するため、rootfs init script 変更より SD カード上の factory test update package 退避を優先

EV3 service は自動起動するが、motor は cmd/enablecmd/drive が来るまで動かない。 これは、EV3 起動後に heartbeat と command 受付だけを復旧させるための変更である。

テスト順序

  1. node script の syntax と import
  2. MQTT connect と heartbeat
  3. 起動直後の enabled=false
  4. disabled 状態で drive が無視されること
  5. enable 後の低 speed / 短 TTL drive
  6. TTL 後に stop すること
  7. repeated drive の停止確認
  8. stop / estop の即時反映
  9. broker restart 時に stop すること
  10. controller helper の cleanup

USB 抜線や高負荷試験は、低 speed / 短 TTL の基本動作が安定してからにする。

既存EV3 USB SSHメモとの関係

EV3 を ev3dev で USB 接続する層と、MQTT robot node として動かす層は分ける。 USB SSH は bootstrap transport、MQTT は runtime control plane である。

SSH が通らないときは USB NIC、NetworkManager、EV3 側 IP 表示、host key を見る。 MQTT が通らないときは broker listen address、topic、retain、TTL、device node の heartbeat を見る。

UnitV2 が通らないときは USB topology、給電、cdc_ether、NetworkManager route、10.254.239.1 の HTTP/video endpoint を見る。