LoginSignup
4

More than 3 years have passed since last update.

【Raspberry Piで作る】外出先で簡単に使える!画像バックアップ用デバイス

Posted at

こんにちは!

みなさん,こんな悩みはないですか?
「もうそろそろSDカードがいっぱいになる…,でもパソコンにファイルを移動させてる時間はないな」,「運転しながらパソコンは使えないし…」

というわけで,信号待ちの間にできる画像バックアップ用のデバイスを作ります!

…実現可能か調査してから買ってほしいものです

この記事は Muroran Institute of Technology Advent Calendar 2018 10日目の記事です.

用意するもの

  • Raspberry Pi: 「たまたまセールだった&持っていない」ため「Raspberry Pi 3B+」で作業を進めますが,そんなに性能はいらないと思います
  • SDカードリーダー
  • 画像ファイルを保存しておくための外部デバイス
  • シャットダウン制御用のUSBメモリ

出来上がるもの

以下のファイルを自動で吸い出すデバイスを作ります:

  • SDカード上の画像

実際に作る

Raspberry Piのセットアップ

  1. 手前味噌ではありますが,よろしければこちらを参照ください: 【ヘッドレス】Raspberry Pi 3 セットアップ for macOS
  2. 上に追加して以下を実行:
  $ 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メモリを抜いたら,シャットダウンコマンドを実行するようにします.

まずはスクリプトを管理するディレクトリを作成:

$ mkdir ~/scripts

次にシャットダウンを実行するスクリプトファイルを作成:

$ nano ~/scripts/shutdown.sh
~/scripts/shutdown.sh
#!/bin/bash
sudo shutdown -h now

次に,特定のUSBメモリを抜いたらシャットダウンするrulesファイルを作成:

$ sudo nano /etc/udev/rules.d/30-shutdown.rules
/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のリストアップ:

~/scripts/source_sd
YYYY-YYYY

次に監視と mount するpythonスクリプトを作ります.
mount は subprocess を使ってコマンド実行でもいいですが,shというpythonライブラリがあるようなので利用します(標準ライブラリではないので注意です).

shライブラリのインストール:

$ sudo pip3 install sh

そして,マウント・アンマウント周りのスクリプト.
アンマウントはもっといい感じにしようと思っているので,とりあえずremoveを検知したらumountを実行するようにしました.
まぁ,ブチ抜きすると,sd*にはremoveが発行されるけど,sd*1には発行されない都合で,思った通りには動いていないですが…

~/scripts/main.py
#!/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()

英語の間違いは編集リクエストください,勉強します.

というわけで,もう少し頑張りましょう…

保存先USBデバイス周り

保存先には外付けのSDDを用います(余り物).
先にPCでexFATにフォーマットしました.

とりあえずマウントしようとするも,ラズパイ(Raspbian)はexFATにデフォルトでは対応していない模様.
なので,まずは必要なパッケージをインストール:

$ sudo apt install exfat-fuse exfat-utils

インストールしたら再起動します.

あとは取得元SDカード周りと同じ要領です.
まずは保存先デバイスの一覧を作成:

~/scripts/destination_device
ZZZZ-ZZZZ

次にスクリプト.
先ほどのコードを次のように編集します:

  1. 取得元SDと同様に保存先デバイスのリストの読み込みを追加
  2. リストアップされてるデバイスか判定するif文に保存先デバイスの判定も入れる


クリックで展開します
main.py
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からでも保存先デバイスからでも,どちらが先でも正常に実行できる

があります.
なので,

  1. 取得元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についても対応予定ですのでよろしくお願いします.

リモコンでの操作とかもできるようにしたいですね!

参考文献など

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
4