10.13. Загрузка кадров по триггеру в облако

Теперь при срабатывании движения камера подсвечивает панель управления. Этого достаточно для живого использования, но владельцу также нужен постоянный архив каждого кадра, снятого по триггеру, который хранится где-то за пределами камеры. Это исходящий HTTP-вызов – камера выступает в роли клиента.

10.13.1. Камера как клиент

Модуль requests – это исходящий HTTP-клиент камеры. Его интерфейс намеренно повторяет requests из CPython – те же функции модуля, названные по HTTP-глаголам, те же именованные аргументы files=, json=, headers=, auth=. Если вы уже делали HTTP-вызовы из CPython, этот 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-соединение, отправляет запрос и возвращает Response со свойствами status_code, reason, headers, content, json() и прочими привычными.

files={...} формирует тело multipart/form-data. Значение – это кортеж (filename, file-like); requests.post() читает файлоподобный объект порциями, так что весь JPEG не приходится заранее перезагружать в строку. io.BytesIO оборачивает уже находящиеся в памяти байты JPEG, чтобы они предоставляли интерфейс чтения как у файла.

headers={...} – это обычный словарь, который отправляется как заголовки запроса; здесь это bearer-токен в стандартной позиции 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. Режимы отказа

Сетевой код даёт сбои. Камера может быть офлайн, архив может быть недоступен, bearer-токен может истечь. Категории, которые стоит перехватывать:

  • OSError – TCP-соединение не удалось открыть или оно закрылось в середине передачи. Сбой DNS, нет маршрута, сброс соединения. requests возбуждает именно это исключение.

  • status_code >= 400 – сервер получил запрос и отклонил его. 401 для истёкшего токена, 403 для отозванного, 413 для слишком большого тела, 5xx если архив нездоров.

  • Тихий тайм-аут – requests использует тайм-аут сокета по умолчанию (несколько секунд); по его истечении возбуждается OSError с errno.ETIMEDOUT.

Для архива, который действительно важен, вы поставили бы отклонённые кадры в очередь в /sdcard/pending/ и повторяли бы попытки в более медленном цикле – это ещё несколько строк на каждый случай, помимо показанного.

10.13.4. Чего requests не делает

Порт для MicroPython намеренно сделан небольшим. Несколько вещей, которые CPython-овский requests делает, а этот – нет:

  • Пул соединений. Каждый вызов открывает новое TCP-соединение.

  • Автоматические повторы при временных ошибках. Оберните вызов самостоятельно.

  • Потоковые ответы. r.content целиком читается в ОЗУ; эквивалента stream=True нет.

  • Автоматическая распаковка ответов, сжатых gzip. Устанавливайте заголовок Accept-Encoding явно только если сервер настроен на это.

Полный список методов и того, что входит и не входит в область применения, см. в requests — HTTP-клиент.

HTTPS работает из коробки – его задаёт схема URL, а SSL-контекст по умолчанию создаётся на лету. О проверке сертификата архива по набору CA, загруженному на камеру, см. раздел как клиент в Проверка публичного сервера (камера в роли клиента).

Приложение полностью готово: живой предпросмотр, обнаружение движения, панель управления с авторизацией, HTTPS, CORS/CSRF, облачный архив.