EV3 ev3dev、M5Stack、UnitV2-M12をMQTTで扱う設計メモ
Posted:
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から始める
stop と estop は、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 は wlan0 の 00:e0:99:84:99:25。
wlan1 は本体 AP 用で、固定 lease には使わない。
2026-05-23 に外部 inventory の openwrt_pxe_hosts へ unitv2-01 として追加し、OpenWrt へ dhcp tag で反映した。
固定 IP は 10.10.254.250。
PXE boot 対象ではないため、filename、tags、DHCP Option 224 は付けない。
同日に UnitV2 を SSH 経由で reboot して確認したところ、USB Ethernet、recognition service、camera stream は復帰したが、OpenWrt 側の /tmp/dhcp.leases に unitv2-01 は出なかった。
本体の wlan0 は 00: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 で反映した。
反映後は wlan0 に 10.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 へ改名し、展開済みの upgradeTemp も upgradeTemp.disabled として退避した。
この状態で reboot すると /etc/init.d/S85runpayload の payload server が残り、recognition_service として起動した。
ただし reboot 直後の /get_last_func は null で、camera stream function は自動選択されない。
controller host 側から /func へ camera_stream を POST すると /video_feed から frame を取得できる。
このため scripts/unitv2_probe.py start-camera-stream と scripts/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.py は robot/+/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/enable と cmd/drive が来るまで動かない。
これは、EV3 起動後に heartbeat と command 受付だけを復旧させるための変更である。
テスト順序
- node script の syntax と import
- MQTT connect と heartbeat
- 起動直後の
enabled=false - disabled 状態で drive が無視されること
- enable 後の低 speed / 短 TTL drive
- TTL 後に stop すること
- repeated drive の停止確認
- stop / estop の即時反映
- broker restart 時に stop すること
- 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 を見る。