全国オンライン対応受付 平日 9:00-18:00
お問い合わせ

開発記録 / simpleRec制作記 / Vol.22 simpleRec制作記 Vol.1

記事 02

TP-Link VIGIカメラからWebhookでスナップショットを受け取る——ファイル名なしmultipartの罠

422 Unprocessable Entity。

2026-06-07 公開

422 Unprocessable Entity。

FastAPIが返したエラーだった。カメラはちゃんとサーバーにPOSTを送っている。しかしサーバーが受け取れない。TP-Link VIGIとFastAPIの間に、想定外の壁があった。


カメラの設定

TP-Link VIGI C240Iの管理画面(Webブラウザから192.168.150.33で開く)に「アラームサーバー」という設定項目がある。

URL: https://app.simplerec.jp/webhook/{camera_id}
プロトコル: HTTPS
ポート: 443
添付画像: ON

動体を検知するたびに、このURLにPOSTリクエストが届く。bodyにはJPEG画像が含まれる。シンプルな仕組みだ。


filenameがなかった

最初の実装はUploadFileを使ったファイルアップロード処理だった。

@app.post('/webhook/{camera_id}')
async def receive_snapshot(camera_id: str, image: UploadFile = File(...)):
    image_data = await image.read()
    # ...

しかしVIGIからのリクエストを受け取ると、FastAPIが422エラーを返した。

UploadFileはmultipart/form-dataのContent-Dispositionヘッダにfilenameが含まれることを前提としている。VIGIのリクエストにはfilenameがなかった。

# VIGIが送るContent-Disposition(簡略化)
Content-Disposition: form-data; name="image"
# ← filenameが存在しない

FastAPIのUploadFileはfilenameを必須として扱う。だから422を返す。カメラの仕様とFastAPIの前提の間にずれがあった。


Request直接受け取りに変更

解決策はUploadFileを使わずにRequestオブジェクトで直接リクエストbodyを受け取ることだ。

from fastapi import Request, BackgroundTasks

@app.post('/webhook/{camera_id}')
async def receive_snapshot(
    camera_id: str,
    request: Request,
    background_tasks: BackgroundTasks
):
    content_type = request.headers.get('content-type', '')
    body = await request.body()

    if 'multipart/form-data' in content_type:
        boundary = content_type.split('boundary=')[1]
        parts = parse_multipart(body, boundary.encode())
        image_bytes = None
        for part in parts:
            if b'image' in part.get('name', b''):
                image_bytes = part.get('data')
                break
    else:
        image_bytes = body

    background_tasks.add_task(process_detection, camera_id, image_bytes)
    return {'status': 'ok'}

multipart/form-dataを自前でパースしてimage部分を取り出す。filenameがあってもなくても、nameだけで判断できる。


fire-and-forget問題

もう一つの問題があった。

TP-Link VIGIはPOSTを送信した後、即座に接続を切断する(fire-and-forget)。サーバーの処理が終わるのを待たない。

FastAPIがRekognitionの呼び出しやスプレッドシート書き込み(数秒以上かかる)を処理中に、接続が切断される。nginxが「クライアントが切断した」と判断して接続エラーを記録する。

2つの対策を取った。

1つはBackgroundTasksで即座に200を返す設計。Rekognitionの呼び出しはバックグラウンドで行う。

もう1つはnginxの設定にproxy_ignore_client_abort on;を追加する。クライアントが切断してもnginxはバックエンドへのプロキシを継続する。

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_ignore_client_abort on;
}

検知クールダウン

カメラは動体を連続検知すると、数秒ごとにWebhookを送り続ける。同じ人物が歩いているだけで、数十件の通知が送信される。

通知頻度を制限するクールダウンを実装した。

last_notified = {}

COOLDOWN = {
    'danger': 60,
    'vip': 300,
    'unknown': 600,
}

def should_notify(tenant_id, person_id, person_type):
    key = (tenant_id, person_id)
    now = datetime.utcnow()
    last = last_notified.get(key)
    if last and (now - last).seconds < COOLDOWN.get(person_type, 60):
        return False
    last_notified[key] = now
    return True

in-memory(dict)での管理のため、サーバー再起動でリセットされる。通知頻度制限は再起動後にリセットされても実用上問題がないため、シンプルな実装を選んだ。


動作確認

カメラを設置してWebhookの動作確認を行った。VIGIがローカルIPからHTTPSでアクセスするため、ルーターの設定(ポートフォワーディング)が必要だった。

動作確認の結果、開発者本人の顔が99%の信頼度で認識された。AWS Rekognitionの精度は高い。照明条件が多少悪い室内でも安定して動作した。


*次回は「Docker + Nginx + Let's Encrypt——EC2上の本番環境構築」*

*シンプルシステム株式会社 代表 伊藤勝彦*