🐍

MicroPython のファイルを気軽に一括同期する

2021/10/24に公開

はじめに

前回に引き続き ESP32 + MicroPython で遊んでいます.PC から MicroPython ボードへのファイル転送には ampy が便利ですが,ファイルが多いと少し不便を感じるようになりました.
そこで,ampy を少し便利にしようというのが,今回のお題です.

以下の方針を立てました:

  1. PC と MicroPython デバイスが持つファイルの SHA1 を双方で計算して比較する
  2. 比較した結果に基づいて ampy のコマンド列を作成する
  3. ampy のコマンド列を sh に与えて実際の転送をする
    • PC → デバイス (push)
    • デバイス → PC (backup)

これらを実現する pinot-mirror というスクリプトを作成しました.

pinot-mirror スクリプト

こんなコマンドです.

git clone https://github.com/yoshinari-nomura/pinot.git
./scripts/pinot-mirror
Mirror local directory to MicroPython board and vice versa.

Usage: pinot-mirror [-p PORT] [-d] SRC DEST
  -d        Show SHA1 digests for each file (for debug).
  -h        Show this message.
  -p PORT   Set name of serial PORT for connected board.
  SRC, DEST: Existing local directory or "ampy:"

CAVEAT: pinot-mirror removes extraneous files from DEST.

例えば,PC で追加/変更/削除した src ディレクトリの内容をボードに反映 (mirror) するには以下のようにします:

./scripts/pinot-mirror -p /dev/ttyUSB0 src ampy:

実行結果:

ampy rm /fonts/shnmk16u.pfn
ampy put src/lib/ili9341.py /lib/ili9341.py
ampy put src/main.py /main.py

これで何が起こるか確認したら,sh にパイプで渡してやりましょう:

./scripts/pinot-mirror -p /dev/ttyUSB0 src ampy: | sh -v

ボードの差分バックアップを取りたい場合には,以下のようにすればよいでしょう:

mkdir backup
./scripts/pinot-mirror -p /dev/ttyUSB0 ampy: backup | sh -v

pinot リポジトリの Makefile には,上記のことをするターゲットがあります.

pinot-mirror のファイル同期方法

その前に ampy とは

pinot-mirror は,ampy を使っているので,ampy について少し説明しておきます.

ampy は, USB (UART) 経由で MicroPython ボード上のファイルを読み書きするツールです.例えば PC 上にある file.py をボードに転送する場合は, ampy put file.py /lib/file.py みたいな感じで使えて便利です.

ただ,開発の過程で複数のファイルを編集しつつ実験している場合,手元で変更のあったファイルを特定して 1つ1つ転送するのがちょっと面倒です.

  1. PC 上のファイルの内,どれが転送すべき更新済ファイルなのか分からない
    • 面倒になってフォルダまるごと一括で上書きしてしまうことがよくあります.フラッシュの寿命も心配.
  2. バックアップが面倒
    • MicroPython ボードを何枚も持っていると,どのボードのファイルが PC上のどのフォルダにバックアップしているのかよく分からなくなります.

双方で SHA1 を取る

ESP32 ボードだからなのかもしれませんが,MicroPython でファイル群の SHA1 を取るのは思った以上に簡単です.以下のようなコードで取得できます.

digest-files.py
#!/usr/bin/env python3

import binascii
import hashlib
import re
import os
import sys

class DigestDirtree:
    def isdir(self, path):
	return os.stat(path)[0] & 0x4000 != 0

    def digest(self, path, echo = True):
	hash = hashlib.sha1()

	if self.isdir(path):
	    path = re.sub('/$', '', path) + '/'
	    for child in sorted(os.listdir(path)):
		cpath = path + child
		hash.update(self.digest(cpath))
		hash.update(child.encode())
	else:
	    with open(path, 'rb') as file:
		while True:
		    s = file.read(512)
		    if len(s) <= 0:
			break
		    hash.update(s)
	sha1 = hash.digest()
	if echo:
	    print(str(binascii.hexlify(sha1), 'ascii'), path)
	return sha1

if __name__ == '__main__':
    dir = sys.argv[1] if len(sys.argv) > 1 else '/'
    DigestDirtree().digest(dir)

実行方法と実行結果は,例えば,こんな感じです.後で比較しやすいように sort しておきます.(本当は,CRLF → LF の変更も必要でした)

git clone https://github.com/yoshinari-nomura/pinot.git
export AMPY_PORT=/dev/ttyUSB0
ampy run ./scripts/digest-files.py | sort -k 2
digest-remote.log
12596e1ee44ed14ff56a7706585b2f76209bf0bb /
3947b36ed837906d45bee3e0d8a0befa17d9052c /boot.py
a09cc03731adfdc9053523544d0538902be6f024 /fonts/
87bebcaa5b7158a7f6193aa89a31f87f87b732cb /fonts/shnmk12u.pfn
5135ca0fdf22092df3cfa4cbc31de5db9f93a11e /fonts/shnmk16u.pfn
a0ce8d58b9fbbba0ced3516e825a6353da412145 /lib/
d402b5efd76cabcee2b6753a37e6e902a716b43e /lib/configserver.py
ea5aeccb7b4d8045ff504e190817b35883f76082 /lib/jsonconfig.py
79d8bbc24e338b87385477ceecd8d710c76ee32b /lib/mqtt.py
2899e94ec033003df7a0437a39e82eef92ba39d5 /lib/pnfont.py
8e6ead2602c64ae571aeebbc5a95024aea4ffb1e /main.py

このスクリプトは,ローカルの PC (Python3) でも同様に動作します.例えば, src/ 以下にボードと同期しているファイルがあるとすると:

(cd src; ../scripts/digest-files.py .) | sort -k 2 | sed 's! [.]/! /!'

ボードからの出力と同じ / からの結果に見えるように sed で調整しています.

こちらは,こんな感じです:

digest-local.log
c2a2bd20281d333fafabd6a0ce94052a43a3cab9 /
3947b36ed837906d45bee3e0d8a0befa17d9052c /boot.py
cc54df5e7fe76ab95f71ae56d4be3a0ed50f0831 /fonts/
87bebcaa5b7158a7f6193aa89a31f87f87b732cb /fonts/shnmk12u.pfn
fdec0d3d3b74a0b6702c1c84e330ddabf2abb07a /lib/
d402b5efd76cabcee2b6753a37e6e902a716b43e /lib/configserver.py
b0c8fadf2463267c53f8c9f6ae8098f6ca84cd31 /lib/ili9341.py
ea5aeccb7b4d8045ff504e190817b35883f76082 /lib/jsonconfig.py
79d8bbc24e338b87385477ceecd8d710c76ee32b /lib/mqtt.py
2899e94ec033003df7a0437a39e82eef92ba39d5 /lib/pnfont.py
ef923b09e3d1dbd829f5ce1fafb0cc7562048f22 /main.py

この2つを比較してやるだけです.

SHA1 を比較する

2つの SHA1 テーブルができてしまえば,あとは簡単ですね.2つのテーブルを比較するには join というコマンドが便利です.

NONE='0000000000000000000000000000000000000000'
join  -j 2 -a 1 -a 2 -e "$NONE" -o '1.1 2.1 0' digest-remote.log digest-local.log

実行結果は,こんな感じ.

digest-diff.log
12596e1ee44ed14ff56a7706585b2f76209bf0bb c2a2bd20281d333fafabd6a0ce94052a43a3cab9 /
3947b36ed837906d45bee3e0d8a0befa17d9052c 3947b36ed837906d45bee3e0d8a0befa17d9052c /boot.py
a09cc03731adfdc9053523544d0538902be6f024 cc54df5e7fe76ab95f71ae56d4be3a0ed50f0831 /fonts/
87bebcaa5b7158a7f6193aa89a31f87f87b732cb 87bebcaa5b7158a7f6193aa89a31f87f87b732cb /fonts/shnmk12u.pfn
5135ca0fdf22092df3cfa4cbc31de5db9f93a11e 0000000000000000000000000000000000000000 /fonts/shnmk16u.pfn
a0ce8d58b9fbbba0ced3516e825a6353da412145 fdec0d3d3b74a0b6702c1c84e330ddabf2abb07a /lib/
d402b5efd76cabcee2b6753a37e6e902a716b43e d402b5efd76cabcee2b6753a37e6e902a716b43e /lib/configserver.py
0000000000000000000000000000000000000000 b0c8fadf2463267c53f8c9f6ae8098f6ca84cd31 /lib/ili9341.py
ea5aeccb7b4d8045ff504e190817b35883f76082 ea5aeccb7b4d8045ff504e190817b35883f76082 /lib/jsonconfig.py
79d8bbc24e338b87385477ceecd8d710c76ee32b 79d8bbc24e338b87385477ceecd8d710c76ee32b /lib/mqtt.py
2899e94ec033003df7a0437a39e82eef92ba39d5 2899e94ec033003df7a0437a39e82eef92ba39d5 /lib/pnfont.py
8e6ead2602c64ae571aeebbc5a95024aea4ffb1e ef923b09e3d1dbd829f5ce1fafb0cc7562048f22 /main.py

左がボード側,右がローカルPC側の SHA1 です. 0000000000000000000000000000000000000000 となっているのは,同じ名前のファイルが一方にないという意味です.

ちょっと見にくいですが,このテーブルから以下の差が分かります:

  1. /fonts/shnmk16u.pfn は,PCにない
  2. /lib/ili9341.py は,ボードにない
  3. /main.py は,双方で異なる

ディレクトリの SHA1 は,配下のファイルが異なる場合は,異なってますね.

pinot-mirror は,この情報に基づいて,ampy のコマンド列を出力するというわけです.

おわりに

今日は疲れたので,このへんで.

お察っしのように pinot-mirror は,シェルスクリプトです.コマンドラインで試行錯誤していたらいつのまにかできていました.

僕は,スクリプトを書くときは,今回の pinot-mirror のようにシェルにパイプで喰わせるコマンド列を出力するコマンドをよく作ります.quote するのが多少面倒ですが,そのほうが便利じゃないですか? 例えば同期で無視したいファイルがある場合は,grep -v してからシェルに渡せばいいので.

Discussion