10.13. トリガーされたフレームをクラウドにアップロードする

動きが発生すると、カメラはダッシュボードを点灯させるようになりました。ライブ用途にはこれで十分ですが、所有者はトリガーされたすべてのフレームの恒久的なアーカイブを、カメラの外部のどこかに保存したいとも考えています。これは外向きのHTTP呼び出しであり、カメラがクライアントとして動作することになります。

10.13.1. クライアントとしてのカメラ

requests モジュールは、カメラの外向きHTTPクライアントです。そのインターフェースはCPythonの requests を意図的に模倣したもので、同じ動詞名のモジュール関数、同じ files=json=headers=auth= キーワード引数を備えています。CPythonからHTTP呼び出しを行ったことがあれば、すでにこのAPIを知っているはずです。

import requests
import io

ARCHIVE_URL = 'https://api.backyard-cloud.com/frames'
ARCHIVE_TOKEN = load_archive_token()

async def archive_frame(jpeg, ts):
    try:
        r = requests.post(
            ARCHIVE_URL,
            files={'image': (
                'frame-{}.jpg'.format(ts),
                io.BytesIO(jpeg),
            )},
            headers={'Authorization': 'Bearer ' + ARCHIVE_TOKEN},
        )
    except OSError as e:
        print('upload failed:', e)
        return False
    if r.status_code >= 400:
        print('archive rejected:', r.status_code, r.reason)
        return False
    return True

requests.post() はTCP接続を開いてリクエストを送信し、status_codereasonheaderscontentjson() といったおなじみのプロパティを備えた Response を返します。

files={...}multipart/form-data ボディを構築します。値は (filename, file-like) のタプルです。requests.post() はファイルライクオブジェクトをチャンク単位で読み込むため、JPEG全体を最初に文字列へ再バッファリングする必要がありません。io.BytesIO はすでにメモリ上にあるJPEGバイト列をラップし、ファイルとして読み取れるインターフェースを公開します。

headers={...} はリクエストヘッダーとして送信される単純な辞書です。ここでは、標準の Authorization の位置にベアラートークンを置いています。アーカイブプロバイダーは、必要とするトークン形式を文書化しています。この例は最も一般的な形式です。

10.13.2. 動き検出器への組み込み

前に紹介した動き検出コルーチンは、すでに新しいフレームごとに実行され、change > state['threshold'] のときに発火します。そこにアップロードを追加しますが、アップロードの実行中も検出器が監視を止めないように、バックグラウンドタスクとして発火させます。

async def motion_detector():
    global last_motion
    prev = None
    while True:
        await new_frame.wait()
        change = compute_change(prev, latest_jpeg)
        if change > state['threshold']:
            state['trigger_count'] += 1
            ts = int(time.time())
            last_motion = {'ts': ts,
                           'count': state['trigger_count'],
                           'change': change}
            motion_event.set()
            asyncio.create_task(archive_frame(latest_jpeg, ts))
        prev = latest_jpeg
        await asyncio.sleep_ms(50)

asyncio.create_task() はアップロードコルーチンをスケジュールし、すぐに戻ります。検出器はフレームの取得を続け、アップロードはそれと並行して実行され、カメラが停止することはありません。

10.13.3. 障害モード

ネットワークコードは失敗します。カメラがオフラインかもしれませんし、アーカイブがダウンしているかもしれませんし、ベアラートークンが失効しているかもしれません。捕捉する価値のあるカテゴリは次のとおりです。

  • OSError -- TCP接続を開けなかったか、転送の途中で閉じられました。DNS障害、ルートなし、接続リセットなどです。requests はまさにこの例外を送出します。

  • status_code >= 400 -- サーバーはリクエストを受信しましたが、拒否しました。失効したトークンには401、取り消されたトークンには403、ボディが大きすぎる場合は413、アーカイブが不健全な場合は5xxです。

  • サイレントタイムアウト -- requests はデフォルトのソケットタイムアウト(数秒)を使用します。それを超えると、errno.ETIMEDOUT を伴う OSError を送出します。

本当に重要なアーカイブの場合は、拒否されたフレームを /sdcard/pending/ にキューイングし、より遅いループで再試行することになります。これは、ここに示したものに加えて、各ケースにつき数行を追加するだけです。

10.13.4. requests が行わないこと

MicroPython版は意図的に小さく作られています。CPythonの requests が行うことのうち、こちらでは行わないものをいくつか挙げます。

  • コネクションプーリング。呼び出しごとに新しいTCP接続を開きます。

  • 一時的なエラーに対する自動再試行。呼び出しを自分でラップしてください。

  • ストリーミングレスポンス。r.content はすべてRAMに読み込まれます。stream=True に相当するものはありません。

  • gzip圧縮されたレスポンスの自動展開。サーバーがそのように設定されている場合にのみ、Accept-Encoding ヘッダーを明示的に設定してください。

メソッドの全リストと、対象範囲に含まれるもの・含まれないものについては、requests --- HTTPクライアント を参照してください。

HTTPSはそのまま動作します。URLスキームがそれを駆動し、デフォルトのSSLコンテキストはその場で作成されます。カメラに読み込んだCAバンドルに対してアーカイブの証明書を検証する方法については、公開サーバーの検証(クライアントとしてのカメラ)クライアントとしてのセクションを参照してください。

アプリは完全に出荷可能な状態になりました。ライブプレビュー、動き検出、ログイン付きダッシュボード、HTTPS、CORS/CSRF、そしてクラウドアーカイブを備えています。