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 位置的一个 bearer token。存档服务商会说明他们需要什么样的 token 格式;此处的示例是最常见的形式。

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 token 可能已经过期。值得捕获的几类情况:

  • OSError——TCP 连接无法打开,或者在传输途中被关闭。DNS 解析失败、无路由、连接被重置。requests 抛出的正是这个异常。

  • status_code >= 400——服务器收到了请求并拒绝了它。401 表示 token 已过期,403 表示 token 已被吊销,413 表示主体过大,5xx 表示存档服务不健康。

  • 静默超时——requests 使用一个默认的套接字超时(几秒钟);超过这个时间它会抛出带有 errno.ETIMEDOUTOSError

对于一个真正重要的存档来说,你会把被拒绝的帧排入 /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、云端存档。