こんにちは!
みなさん,こんな悩みはないですか?
「もうそろそろSDカードがいっぱいになる…,でもパソコンにファイルを移動させてる時間はないな」,「運転しながらパソコンは使えないし…」
というわけで,信号待ちの間にできる画像バックアップ用のデバイスを作ります!
…実現可能か調査してから買ってほしいものです旅先でのSDカードバックアップ用端末づくりをしようとラズパイ買ったので実現可能か調査せねば
— y_k@7/6ICPC予選,7/7osc18do (@YetAnother_yk) September 3, 2018
この記事は Muroran Institute of Technology Advent Calendar 2018 10日目の記事です.
用意するもの
- Raspberry Pi: 「たまたまセールだった&持っていない」ため「Raspberry Pi 3B+」で作業を進めますが,そんなに性能はいらないと思います
- SDカードリーダー
- 画像ファイルを保存しておくための外部デバイス
- シャットダウン制御用のUSBメモリ
出来上がるもの
以下のファイルを自動で吸い出すデバイスを作ります:
- SDカード上の画像
実際に作る
Raspberry Piのセットアップ
- 手前味噌ではありますが,よろしければこちらを参照ください: 【ヘッドレス】Raspberry Pi 3 セットアップ for macOS
- 上に追加して以下を実行:
$ sudo apt install python3-pip
必要なモジュールのインストール
$ sudo pip3 install pyudev # USBデバイスのUUIDを使うため
下準備
今回はデバイスのUUIDにより色々と制御を行おうと思うので事前に調べておきます.
以下のコマンドで,UUIDを調べられます:
$ ls -l /dev/disk/by-uuid
その他色々な情報を見る:
$ udevadm info /dev/sd*1
ファイルの移動元のSDカード,移動先のUSBメモリ,Raspberry Piの制御用のUSBメモリのUUIDを調べてメモしておきましょう.
シャットダウン制御用USBメモリ周り
USBメモリを抜いたら,シャットダウンコマンドを実行するようにします.
起動認証USBを作って,そのUSBが刺さっていなければ起動しない(=シャットダウンする)ようにすれば外出先でも使いやすい,コンソールのいらないシャットダウンを提供できそう
— y_k@7/6ICPC予選,7/7osc18do (@YetAnother_yk) September 3, 2018
まずはスクリプトを管理するディレクトリを作成:
$ mkdir ~/scripts
次にシャットダウンを実行するスクリプトファイルを作成:
$ nano ~/scripts/shutdown.sh
#!/bin/bash
sudo shutdown -h now
次に,特定のUSBメモリを抜いたらシャットダウンするrulesファイルを作成:
$ sudo nano /etc/udev/rules.d/30-shutdown.rules
ACTION=="remove", \
SUBSYSTEMS=="usb", \
ENV{ID_FS_UUID}=="XXXX-XXXX", \
RUN+="/bin/bash /home/yk/scripts/shutdown.sh"
XXXX-XXXX
はメモしたUSBメモリのUUIDを入れてください.
ファイルを書き込んだら設定を反映するため再起動して,実際にUSBメモリを抜いてみてください.
少しすると無事シャットダウンが完了します.
刺さっていなくても起動するので,認証とは呼べないですが,あまり困らないのでいいことにしましょう.
取得元SDカード周り
取得元となるSDカードについては可能な限りその後の拡張性やメンテナンス性も考慮したいものです.
シャットダウン制御用USBメモリ周りと同じ仕様にしたいのですが,SDカード購入する度にrulesファイルを書き換えるのは怠いですし,SDカードの枚数だけファイルの行数が増えたりするのもいただけないです.
そこで今回はpyudevライブラリを用います.
インストール:
$ sudo apt install python3-pyudev
$ sudo pip3 install pyudev
udevを監視しつつ,リストアップされてるUUIDのSDカードが接続されたら,mount
するように処理します.
まずはUUIDのリストアップ:
YYYY-YYYY
次に監視と mount するpythonスクリプトを作ります.
mount は subprocess を使ってコマンド実行でもいいですが,shというpythonライブラリがあるようなので利用します(標準ライブラリではないので注意です).
sh
ライブラリのインストール:
$ sudo pip3 install sh
そして,マウント・アンマウント周りのスクリプト.
アンマウントはもっといい感じにしようと思っているので,とりあえずremove
を検知したらumount
を実行するようにしました.
まぁ,ブチ抜きすると,sd*
にはremove
が発行されるけど,sd*1
には発行されない都合で,思った通りには動いていないですが…
#!/usr/bin/python3
import os, sys, pyudev, sh
source_sd = '/home/yk/scripts/source_sd'
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by('block')
if not os.path.isfile(source_sd):
not_exist_source_sd_file()
try:
for device in iter(monitor.poll, None):
if 'ID_FS_UUID' in device:
if not os.path.isfile(source_sd):
not_exist_source_sd_file()
with open(source_sd, 'r') as f:
source_sd_list = [v.strip() for v in f.readlines()]
if device.get('ID_FS_UUID') in source_sd_list:
print('{0} is terget!'.format(device.get('ID_FS_UUID')))
if device.action == 'add':
if not os.path.isdir(os.path.join('/media', device.get('ID_FS_UUID'))):
os.mkdir(os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is mounting now...'.format(device.get('ID_FS_UUID')))
sh.mount(device.device_node, os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is mounted!'.format(device.get('ID_FS_UUID')))
if device.action == 'remove':
# 抜いてからunmountは気持ち悪い仕様ですが,少しお付き合いください (しかも思うように動作しない)
try:
sh.mountpoint('-q', os.path.join('/media', device.get('ID_FS_UUID'))).exit_code
except sh.ErrorReturnCode_1:
print('{0} is already umounted.'.format(device.get('ID_FS_UUID')))
else:
print('{0} is umounting now...'.format(device.get('ID_FS_UUID')))
sh.umount(os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is umounted!'.format(device.get('ID_FS_UUID')))
print()
else:
print('{0} is not terget!'.format(device.get('ID_FS_UUID')))
else:
print(device)
print('--', list(device.children))
except KeyboardInterrupt:
print('終了します...')
pass
def not_exist_source_sd_file():
print('取得元SDカードのリストが存在しません')
sys.exit()
英語の間違いは編集リクエストください,勉強します.
次のラズパイはαバージョンまで20%進みました
— y_k@7/6ICPC予選,7/7osc18do (@YetAnother_yk) September 14, 2018
というわけで,もう少し頑張りましょう…
保存先USBデバイス周り
保存先には外付けのSDDを用います(余り物).
先にPCでexFATにフォーマットしました.
とりあえずマウントしようとするも,ラズパイ(Raspbian)はexFATにデフォルトでは対応していない模様.
なので,まずは必要なパッケージをインストール:
$ sudo apt install exfat-fuse exfat-utils
インストールしたら再起動します.
あとは取得元SDカード周りと同じ要領です.
まずは保存先デバイスの一覧を作成:
ZZZZ-ZZZZ
次にスクリプト.
先ほどのコードを次のように編集します:
- 取得元SDと同様に保存先デバイスのリストの読み込みを追加
- リストアップされてるデバイスか判定するif文に保存先デバイスの判定も入れる
**クリックで展開します**
import os, sys, pyudev, sh
source_sd = '/home/yk/scripts/source_sd'
+destination_device = '/home/yk/scripts/destination_device'
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by('block')
if not os.path.isfile(source_sd):
not_exist_source_sd_file()
if not os.path.isfile(destination_device):
not_exist_destination_device_file()
try:
for device in iter(monitor.poll, None):
if 'ID_FS_UUID' in device:
if not os.path.isfile(source_sd):
not_exist_source_sd_file()
+ if not os.path.isfile(destination_device):
+ not_exist_destination_device_file()
with open(source_sd, 'r') as f:
source_sd_list = [v.strip() for v in f.readlines()]
+ with open(destination_device, 'r') as f:
+ destination_device_list = [v.strip() for v in f.readlines()]
- if device.get('ID_FS_UUID') in source_sd_list:
+ if device.get('ID_FS_UUID') in source_sd_list or device.get('ID_FS_UUID') in destination_device_list:
print('{0} is terget!'.format(device.get('ID_FS_UUID')))
if device.action == 'add':
if not os.path.isdir(os.path.join('/media', device.get('ID_FS_UUID'))):
os.mkdir(os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is mounting now...'.format(device.get('ID_FS_UUID')))
sh.mount(device.device_node, os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is mounted!'.format(device.get('ID_FS_UUID')))
if device.action == 'remove':
# 抜いてからunmountは気持ち悪い仕様ですが,少しお付き合いください (しかも思うように動作しない)
try:
sh.mountpoint('-q', os.path.join('/media', device.get('ID_FS_UUID'))).exit_code
except sh.ErrorReturnCode_1:
print('{0} is already umounted.'.format(device.get('ID_FS_UUID')))
else:
print('{0} is umounting now...'.format(device.get('ID_FS_UUID')))
sh.umount(os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is umounted!'.format(device.get('ID_FS_UUID')))
print()
else:
print('{0} is not terget!'.format(device.get('ID_FS_UUID')))
else:
print(device.action, device)
print('--', list(device.children))
except KeyboardInterrupt:
print('終了します...')
sys.exit()
def not_exist_source_sd_file():
print('取得元SDカードのリストが存在しません')
sys.exit()
+ def not_exist_destination_device_file():
+ print('保存先デバイスのリストが存在しません')
+ sys.exit()
無事に,取得元SDも保存先デバイスも自動でマウントされるようになりました😆.
実際にファイルを移動
要件は以下の通りです:
- 取得元SDからでも保存先デバイスからでも,どちらが先でも正常に実行できる
- 保存先デバイスは優先順位つき
- 保存先デバイスの空き容量が足りなければ実行しない
- (cpコマンドでファイルが破損するかは知らないけど)コピー時にファイルが破損したらやり直す
- 問題なくコピーに成功したらSDカードからファイルを削除
これらを満たすように作っていきます.
私が無能すぎて,ミスがありそうなので要注意です
前提
要件として
取得元SDからでも保存先デバイスからでも,どちらが先でも正常に実行できる
があります.
なので,
- 取得元SDカードが挿入されたら,キューに追加
- 保存先デバイスがあれば,コピー開始
- 保存先デバイスがなければ,次のデバイスが挿入されるまで待機
という風に作っていきます.
from queue import Queue
source_device_queue = Queue()
# add source SD
if device.get('ID_FS_UUID') in source_sd_list:
source_device_queue.put(device.get('ID_FS_UUID'))
check_device(destination_device_list)
class NotEnoughDiskSpaceException(IOError):
pass
def check_device(destination_device_list):
print('called check_device().')
print('destination_device_list: {0}'.format(destination_device_list))
for destination_uuid in destination_device_list:
if not os.path.ismount(os.path.join('/media', destination_uuid)):
print('{0} はマウントされていません'.format(destination_uuid))
continue
else:
print('保存先候補が見つかりました: {0}'.format(destination_uuid))
print(type(source_device_queue), source_device_queue)
if not source_device_queue.empty():
source_uuid = source_device_queue.get_nowait()
print('移動開始: {0} -> {1}'.format(source_uuid, destination_uuid))
try:
move(source_uuid, destination_uuid)
except NotEnoughDiskSpaceException:
print('{0} は十分な空き容量がありません.'.format(destination_uuid))
continue
else:
print('移動完了: {0}'.format(source_uuid))
source_device_queue.task_done()
if not source_device_queue.empty():
check_device(destination_device_list)
else:
print('待ち取得元はありません.')
break
print('finished check_device().')
移動
from datetime import datetime
import shutil
def move(source, destination):
print('called move().')
print('move: {0}->{1}'.format(source, destination))
if os.path.isfile(os.path.join('/media', source, '.backup_target_dir')):
with open(os.path.join('/media', source, '.backup_target_dir')) as f1:
for line in f1:
time = datetime.now().strftime("%Y%m%d_%H%M%S")
from_path = os.path.join('/media', source, line.strip(), '')
to_path = os.path.join('/media', destination)
if shutil.disk_usage(from_path).used >= shutil.disk_usage(to_path).free:
raise NotEnoughDiskSpaceException()
to_path = os.path.join(to_path, '{0}-{1}-{2}'.format(source, time, line.strip().replace('/', '_')))
sh.cp('-a', from_path, to_path)
check_md5(from_path, to_path)
# ファイルの削除処理を入れる
print('finished move().')
ミス確認
def check_md5(from_path, to_path):
print('called check_md5().')
s_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(from_path, '-type', 'f', '-exec', 'md5sum', '{}', ';').splitlines()))}
t_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(to_path, '-type', 'f', '-exec', 'md5sum', '{}', ';').splitlines()))}
for k in s_md5.keys():
print('md5sum({0}): {1}<->{2}, {3}'.format(k, s_md5.get(k), t_md5.get(k), s_md5.get(k) == t_md5.get(k)))
while not t_md5.get(k) or not s_md5[k] == t_md5[k]:
if os.path.isfile(os.path.join(to_path, k)):
os.remove(os.path.join(to_path, k))
sh.cp('-p', os.path.join(from_path, k), to_path)
t_md5[k] = sh.md5sum(os.path.join(to_path, k)).splitlines()[0].split()[0]
print('finished check_md5().')
終わるときには全てのファイルが破損なく移動完了しています.
削除
ソースコードにミスがあり,間違って大事なファイルが消えたとしても責任は負えません.
まずは保存先に残したくないファイルの削除から.
if os.path.isfile(os.path.join(from_path, '.backup_ignore')):
with open(os.path.join(from_path, '.backup_ignore')) as f2:
for rm_file in f2:
os.remove(os.path.join(to_path, rm_file.strip()))
os.remove(os.path.join(to_path, '.backup_ignore'))
次はバックアップ済みのファイルの削除.
for f_name in Path(to_path).glob('*'):
print('rm', os.path.join(from_path, f_name.name))
os.remove(os.path.join(from_path, f_name.name))
ファイル移動機能まとめ
以上で除外ファイルも考慮した,ファイル移動機能が完成しました.
**クリックでプログラム全文を展開します**
#!/usr/bin/python3
import os, sys, pyudev, sh
import shutil
from pathlib import Path
from datetime import datetime
from queue import Queue
source_sd = '/home/yk/scripts/source_sd'
destination_device = '/home/yk/scripts/destination_device'
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by('block')
source_device_queue = Queue()
class NotEnoughDiskSpaceException(IOError):
pass
def main():
if not os.path.isfile(source_sd):
not_exist_source_sd_file()
if not os.path.isfile(destination_device):
not_exist_destination_device_file()
try:
for device in iter(monitor.poll, None):
if 'ID_FS_UUID' in device:
if not os.path.isfile(source_sd):
not_exist_source_sd_file()
if not os.path.isfile(destination_device):
not_exist_destination_device_file()
with open(source_sd, 'r') as f:
source_sd_list = [v.strip() for v in f.readlines()]
with open(destination_device, 'r') as f:
destination_device_list = [v.strip() for v in f.readlines()]
if device.get('ID_FS_UUID') in source_sd_list or device.get('ID_FS_UUID') in destination_device_list:
print('{0} is terget!'.format(device.get('ID_FS_UUID')))
if device.action == 'add':
if not os.path.isdir(os.path.join('/media', device.get('ID_FS_UUID'))):
os.mkdir(os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is mounting now...'.format(device.get('ID_FS_UUID')))
sh.mount(device.device_node, os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is mounted!'.format(device.get('ID_FS_UUID')))
if device.get('ID_FS_UUID') in source_sd_list:
source_device_queue.put(device.get('ID_FS_UUID'))
check_device(destination_device_list)
if device.action == 'remove':
# 抜いてからunmountは気持ち悪い仕様ですが,少しお付き合いください
if not os.path.ismount(os.path.join('/media', device.get('ID_FS_UUID'))):
print('{0} is already umounted.'.format(device.get('ID_FS_UUID')))
else:
print('{0} is umounting now...'.format(device.get('ID_FS_UUID')))
sh.umount(os.path.join('/media', device.get('ID_FS_UUID')))
print('{0} is umounted!'.format(device.get('ID_FS_UUID')))
print()
else:
print('{0} is not terget!'.format(device.get('ID_FS_UUID')))
else:
print(device.action, device)
print('--', list(device.children))
except KeyboardInterrupt:
print('終了します...')
sys.exit()
def not_exist_source_sd_file():
print('取得元SDカードのリストが存在しません')
sys.exit()
def not_exist_destination_device_file():
print('保存先デバイスのリストが存在しません')
sys.exit()
def check_device(destination_device_list):
print('called check_device().')
print('destination_device_list: {0}'.format(destination_device_list))
for destination_uuid in destination_device_list:
if not os.path.ismount(os.path.join('/media', destination_uuid)):
print('{0} はマウントされていません'.format(destination_uuid))
continue
else:
print('保存先候補が見つかりました: {0}'.format(destination_uuid))
print(type(source_device_queue), source_device_queue)
if not source_device_queue.empty():
source_uuid = source_device_queue.get_nowait()
print('移動開始: {0} -> {1}'.format(source_uuid, destination_uuid))
try:
move(source_uuid, destination_uuid)
except NotEnoughDiskSpaceException:
print('{0} は十分な空き容量がありません.'.format(destination_uuid))
continue
else:
print('移動完了: {0}'.format(source_uuid))
source_device_queue.task_done()
if not source_device_queue.empty():
check_device(destination_device_list)
else:
print('待ち取得元はありません.')
break
print('finished check_device().')
def move(source, destination):
print('called move().')
print('move: {0}->{1}'.format(source, destination))
if os.path.isfile(os.path.join('/media', source, '.backup_target_dir')):
with open(os.path.join('/media', source, '.backup_target_dir')) as f1:
for line in f1:
time = datetime.now().strftime("%Y%m%d_%H%M%S")
from_path = os.path.join('/media', source, line.strip(), '')
to_path = os.path.join('/media', destination)
if shutil.disk_usage(from_path).used >= shutil.disk_usage(to_path).free:
raise NotEnoughDiskSpaceException()
to_path = os.path.join(to_path, '{0}-{1}-{2}'.format(source, time, line.strip().replace('/', '_')))
sh.cp('-a', from_path, to_path)
check_md5(from_path, to_path)
if os.path.isfile(os.path.join(from_path, '.backup_ignore')):
with open(os.path.join(from_path, '.backup_ignore')) as f2:
for rm_file in f2:
os.remove(os.path.join(to_path, rm_file.strip()))
os.remove(os.path.join(to_path, '.backup_ignore'))
for f_name in Path(to_path).glob('*'):
print(f_name)
print('rm', os.path.join(from_path, f_name.name))
os.remove(os.path.join(from_path, f_name.name))
print('finished move().')
def check_md5(from_path, to_path):
print('called check_md5().')
s_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(from_path, '-type', 'f', '-exec', 'md5sum', '{}', ';').splitlines()))}
t_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(to_path, '-type', 'f', '-exec', 'md5sum', '{}', ';').splitlines()))}
for k in s_md5.keys():
print('md5sum({0}): {1}<->{2}, {3}'.format(k, s_md5.get(k), t_md5.get(k), s_md5.get(k) == t_md5.get(k)))
while not t_md5.get(k) or not s_md5[k] == t_md5[k]:
if os.path.isfile(os.path.join(to_path, k)):
os.remove(os.path.join(to_path, k))
sh.cp('-p', os.path.join(from_path, k), to_path)
t_md5[k] = sh.md5sum(os.path.join(to_path, k)).splitlines()[0].split()[0]
print('finished check_md5().')
if __name__ == "__main__":
main()
最後に
ひとまず,SDカードのバックアップができるようになりました!
今後,Go Proについても対応予定ですのでよろしくお願いします.
リモコンでの操作とかもできるようにしたいですね!
参考文献など
- Stack Overflow: python: how to get uuid of a device using udev
- Raspberry Pi 3 & Python 開発ブログ☆彡: USB挿入でプログラムを実行する方法
- pyudev
- sh
- もた日記: Pythonメモ : 「sh」パッケージでコマンド実行
- Qiita: Raspberry Piで快適コンピューターライフ(NAS構築編)
- Python 3.6.5 ドキュメント: 17.7. queue — 同期キュークラス
- エンジニアの入り口: md5sumコマンドについて詳しくまとめました 【Linuxコマンド集】
- yuyunko's blog: lsでディレクトリのみ,ファイルのみ,表示する
- Python 3.6.5 ドキュメント: 11.10. shutil — 高水準のファイル操作