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_code, reason, headers, content, json() 및 익숙한 나머지 속성들을 갖춘 Response를 반환합니다.

files={...}multipart/form-data 본문을 구성합니다. 값은 (filename, file-like) 튜플입니다. requests.post()는 file-like 객체를 청크 단위로 읽으므로 전체 JPEG를 먼저 문자열로 다시 버퍼링할 필요가 없습니다. io.BytesIO는 이미 메모리에 있는 JPEG 바이트를 감싸서 파일처럼 읽을 수 있는 인터페이스를 제공합니다.

headers={...}는 요청 헤더로 전송되는 단순한 dict로, 여기서는 표준 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, 클라우드 아카이브.