src/tracking_parking/api/ の送信機能が、実物の
tracking-parking-api と
実際にHTTPで話せることを確認する手順。tests/api/ の単体テストはすべて手書きの
フェイク相手に検証しており、ヘッダ名・URL・フィールド名・応答の形が実物と一致して
いることは検証していない。ここでの検証はそのギャップを埋める。
1〜8はローカル開発スタック(tracking-parking-center)
上で行い、本番環境には触れない。9だけが本番を対象とする(request_id の
べき等は、受信側が本番へデプロイされるまで確定しないため)。カメラ実機を
使った通し確認(Jetson設置時)は対象外。
tracking-parking-centerとtracking-parking-apiを同じ親ディレクトリへclone済みであること- Docker Desktopが起動していること
cd tracking-parking-center
mkdir -p services
ln -s ../../tracking-parking-api services/api
cp .env.develop.example .env.develop
docker compose --env-file .env.develop up --build db apiapi の起動コマンドに alembic upgrade head が含まれるため、request_id カラムを
追加するマイグレーションは自動で当たる。
curl -s http://localhost:8000/api/v1/healthデバイスAPIキーの取得(Googleログイン不要。既存の駐車場・デバイスを全削除する 破壊的スクリプトだが、ローカル専用なので問題ない):
docker compose --env-file .env.develop exec api python scripts/seed_demo_data.py
# → Device API key (shown once, save it now): xxxxリポジトリルートの .env(Git管理外)へ追記する。
API_ENABLED=true
API_BASE_URL=http://localhost:8000/api/v1
DEVICE_API_KEY=<seedが出したキー>
API_CONNECT_TIMEOUT_SEC=3.0
API_READ_TIMEOUT_SEC=5.0
HEARTBEAT_INTERVAL_SEC=30
SHUTDOWN_FLUSH_SEC=10
SPOOL_PATH=data/outputs/unsent_events.jsonlrun_detection.py を経由せず、ApiClient / EventSender / HeartbeatAgent を
実物のAPI相手に直接動かす検証ドライバ。カメラも動画もモデル重みも要らない。
uv run python scripts/check_api_connection.py health
uv run python scripts/check_api_connection.py event
uv run python scripts/check_api_connection.py idempotency
uv run python scripts/check_api_connection.py heartbeat
uv run python scripts/check_api_connection.py sender --count 5API_BASE_URL がローカル/LAN以外を指しているときは安全のため実行を拒否する
(--i-know-this-is-not-local で解除可能。本番を対象にする第9節でのみ使う)。
判定は is_local_network_url()(src/tracking_parking/api/settings.py)で、
localhost / *.local / ドットを含まない裸のホスト名 / プライベートIP を
ローカル扱いにする。エッジ機からLAN上の開発機スタックを指す場合も通る。
-
event: 202 Acceptedが返り、応答bodyのrequest_idが送った値と一致すること。EventCreateにmodel_configが無く Pydantic の既定extra='ignore'が効くため、 フィールド名が1文字でもずれていれば黙って無視される。応答に返ってくるかどうかが 唯一の検出手段 -
DEVICE_API_KEYを壊すと401→DROPに分類され、再送されないこと -
heartbeat:devices.last_seen_atが更新されること -
コマンド往復はDBへ直接INSERTして確認する:
INSERT INTO device_commands (device_id, command_type, status, requested_by, created_at) VALUES (1, 'stop_counting', 'pending', 'verification', NOW());
直後に
heartbeatを1回実行し、コマンドがpending→delivered→completed(restartはfailed+理由)まで進むことを確認する。device_commands.command_typeはMySQLのENUM('restart','start_counting', 'stop_counting')で、SQLAlchemy側(app/models/command.py)も同じ3値の Pythonレベルenumで固定されている。 ENUM外の値を直接INSERTすることは通常 できず、無理にENUMを拡張してから投入すると、そのデバイスの以後のハートビート 全てが500になる(list_pending_for_deviceがその行を読もうとしてLookupErrorを出す)。エージェント側はこの500をDisposition.UNKNOWNとして ログするだけでスレッドは落ちない(実地確認済み)ので、これはエッジ側の頑健性を 損なわないが、API側の頑健性の課題として別途報告する。
uv run python scripts/check_api_connection.py idempotencyPOST /events は202 Acceptedを返し、system_count への反映はバックグラウンド
キュー処理に回る(同期しない)。 2回のPOSTが返った直後に system_count を読むと
まだ反映前で「動いていない」ように見えることがある。次で processed になるまで
ポーリングしてから判定すること(キュー処理間隔30秒・stale判定60秒なので、
上限90秒程度を見ておく)。
watch -n3 "curl -s localhost:8000/api/v1/parking-lots | jq '.[] | select(.name==\"講義棟北2\")'"uuid4().hex(ダッシュ無し32桁)で送ると、DBには36桁ダッシュ付きへ正規化されて
保存される。2回目も同じhexで正しく一致判定される。
# 接続拒否(RETRY)を作る
docker compose --env-file .env.develop stop api
uv run python scripts/check_api_connection.py sender --count 3
docker compose --env-file .env.develop start api
uv run python scripts/check_api_connection.py sender --count 0 # 起動時replay
# 応答なし(UNKNOWN)を作る
docker compose --env-file .env.develop pause api
uv run python scripts/check_api_connection.py sender --count 2
docker compose --env-file .env.develop unpause api
uv run python scripts/check_api_connection.py sender --count 0sender --count 0 はenqueueせずに起動時の replay_spool() だけを走らせる。
SHUTDOWN_FLUSH_SEC(既定10秒)は複数イベントが同時に詰まった場合の
バックオフ合計(最大 1+2+4=7秒/件)に対して余裕が少ない。 3件連続で
接続拒否させると、1件目だけが3回のバックオフを使い切って retry として
スプールへ落ち、残り2件はflush期限に間に合わず unknown(shutdown: flush期限 までに決着しなかった)として落ちる。動作としては設計通り(キューに残った
ものを結果不明として退避する)だが、実機で複数イベントが同時に滞留する運用が
多いなら SHUTDOWN_FLUSH_SEC を長めに見直す余地がある。
いずれの場合も request_id が保持されたままスプールへ落ち、復帰後の
sender --count 0 で全件再送され、system_count は送った件数の増減分だけ動く
(二重計上しない)ことを確認する。
run_detection.py 本体を使う唯一の検証。GTが in=55 の動画を使い、失敗が
一目でわかる構成にする。
--env はそのファイルだけを読む(Config.from_env(args.env))ため、
API_ENABLED を含まない .env を --env で渡してもガードは検証にならない
(設定が存在せず、ガードは自明に無効側で通ってしまう)。 load_dotenv は
既存の環境変数を上書きしないので、インラインの環境変数で与える。
API_ENABLED=true \
API_BASE_URL=http://localhost:8000/api/v1 \
DEVICE_API_KEY=<key> \
uv run python scripts/run_detection.py \
--input data/inputs/1787008160.558032.mp4 --env newcam.env事前に seed_demo_data.py を実行して system_count を0へ戻しておく。
確認項目:
- 起動ログに
✓ API送信を有効化が出ないこと - 検知ログ(
[Frame ...] ID:... IN)が実際に出ていること(ガードが「そもそも 何も検知していないだけ」ではなく、検知は起きた上で送信だけ止まっていることを 確認するため) - 処理後(または
Ctrl+Cで途中終了しても)parking_eventsの行数とsystem_countが変化していないこと - スプールファイルが作られていないこと
Ctrl+C(SIGINT)は finally 節を通るため、動画全体を処理し終えなくても
早期に打ち切って確認できる。
上の1〜7で確認した範囲には抜けがある。check_api_connection.py は検知ループを
迂回するため契約(HTTPレベルの応答)しか確認できず、第7節(送信ガード)は送信
ゼロであることの確認にすぎない。「実際の検知ループが検出したイベントが、
run_detection.py の api.enqueue_event(...) 呼び出しを通って本物のAPIへ着地する」
という肯定側の経路は、この節で初めて通す。
カメラがまだ使えない段階(エッジ機の検証は動画→カメラの順で進める)でこれを
確認するため --simulate-camera-input を使う。動画ファイル入力をガード判定上
だけカメラ扱いにする、送信経路検証専用のフラグ。API_BASE_URL が
ローカル/LAN(is_local_network_url())以外を指していると起動そのものを拒否する
(解除する手段は無い)ため、本番/stagingの system_count を動かす心配なく
何度でも実行できる。詳細は docs/decisions/0002-api-event-delivery.md の追記を参照。
1787014266.421887.mp4(GT: in=3, out=3)を使う。件数が少ない上、entry/exit
両方を1回の実行で確認できるため。
docker compose --env-file .env.develop exec api python scripts/seed_demo_data.py
# --envは指定ファイルだけを読むため、API設定はインラインの環境変数で与える
API_ENABLED=true \
API_BASE_URL=http://localhost:8000/api/v1 \
DEVICE_API_KEY=<key> \
uv run python scripts/run_detection.py \
--input data/inputs/1787014266.421887.mp4 --env newcam.env --simulate-camera-input確認項目:
- 起動ログに
✓ API送信を有効化が出ること(第7節の逆側) parking_eventsに6行(entry×3、exit×3)増えること。"IN"/"OUT"から"entry"/"exit"への変換が両方向とも正しいことの確認になるrequest_idがローカルのevents_*.jsonと DB とで一致すること(ダッシュの 有無を除く)statusがprocessedになった後、system_countがGTどおりに動くことrun_config(W&B manifest)のinput_typeが"file"のままであること (ガード判定にだけ"camera"として扱われ、記録は嘘をつかない設計の確認)
安全側の確認も行う。
# 非ローカル宛先では起動を拒否する(YOLOの重み読み込み前に即終了、逃げ道なし)
API_ENABLED=true API_BASE_URL=https://api-trapa.nutfes.net/api/v1 DEVICE_API_KEY=x \
uv run python scripts/run_detection.py \
--input data/inputs/1787014266.421887.mp4 --env newcam.env --simulate-camera-input
# --no-apiが優先されること(何があっても送らない)
... --simulate-camera-input --no-api # → 送信ゼロこれが通れば、送信経路そのものの検証はローカルで完結する。エッジ機での実機 検証(第6層・カメラ実機)は、画角・Issue #103(Jetson Orin NXでのGUI起動)・ 実機負荷といったカメラ固有の問題だけにスコープが絞られる。
1〜8はローカル開発スタック(tracking-parking-center)相手の検証で、送信経路そのものは
これで確認しきれる。だが ADR 0002 が挙げていた「べき等が実際に効いているか」は、
受信側が本番へデプロイされるまで確定しなかった。EventCreate に model_config が
無く Pydantic の既定 extra="ignore" が効くため、受信側が未対応のうちは request_id
を送っても黙って無視され、二重計上が起きて初めて発覚するという穴があったため。
2026-09-16 に本番(api-trapa.nutfes.net)で確認し、決着した。
ドメインはハイフン区切りである点に注意(api.trapa... ではない)。
| 用途 | ホスト |
|---|---|
| API | api-trapa.nutfes.net |
| 公開ビューア | app-trapa.nutfes.net |
| manager | manager-trapa.nutfes.net |
| admin | admin-trapa.nutfes.net |
parking_lots のカウントは役割が分かれている(app/models/parking_lot.py)。
| カラム | 更新する主体 | 公開ビューアでの表示 |
|---|---|---|
current_count |
manager の手動増減 / admin のリセットのみ | 表示される |
system_count |
デバイスのイベント | 表示されない(manager画面で比較用に併記) |
デバイスのイベントは current_count を動かさない。 tracking-parking-web は
current_count しか読まないため、検証中も一般利用者が見る画面は変化しない。
APIキーはコマンドラインに書かず、Git管理外の production.env に置く。
ファイル名に注意。 .gitignore のパターンは *.env(末尾が .env)と .env
だけなので、.env.production という名前はどちらにも一致せず追跡対象になり、
本番APIキーがコミットされる。newcam.env / img2787.env と同じ <名前>.env
の形にすること。
API_ENABLED=true
API_BASE_URL=https://api-trapa.nutfes.net/api/v1
DEVICE_API_KEY=<admin-webでデバイス登録時に一度だけ表示される平文キー>
API_CONNECT_TIMEOUT_SEC=5.0
API_READ_TIMEOUT_SEC=10.0
タイムアウトを既定(3秒/5秒)より伸ばしているのは、Cloudflare Tunnel 経由で往復が
長く、実際には届いているのに ReadTimeout → UNKNOWN と分類されるのを避けるため。
デバイスは admin-web から登録する。平文キーは登録時の1回しか表示されず再取得 できない(サーバーはSHA-256ハッシュしか保存しない。キー再発行のエンドポイントも無い)。
--i-know-this-is-not-local は is_local_network_url() の判定を解除する。
run_detection.py の --simulate-camera-input にこの逃げ道は無い(本番へ
動画の繰り返し検証を流し込めてしまうため)。
# 送信前の system_count を記録
curl -s https://api-trapa.nutfes.net/api/v1/parking-lots | jq
uv run python scripts/check_api_connection.py --env production.env \
--i-know-this-is-not-local health
# request_id は毎回新規
uv run python scripts/check_api_connection.py --env production.env \
--i-know-this-is-not-local event --track-id "VERIFY-20260916"
# 本命:同じ request_id で2回送る
uv run python scripts/check_api_connection.py --env production.env \
--i-know-this-is-not-local idempotency --track-id "VERIFY-20260916"--track-id の値は vehicle_track_id(自由文字列)に入るので検証データの識別子に
なる。request_id は UUID 型なのでマーカーを埋められない。
3回 POST して request_id は2種類なので、system_count は 2 で止まる。
| 項目 | 期待値 | 2026-09-16 の結果 |
|---|---|---|
system_count の変化 |
+2(3回POSTしたが重複分が弾かれる) | 0 → 2 ✓ |
idempotency 2回の応答の id |
一致 | 両方 id=2 ✓ |
応答の request_id |
送った値が返る(無視されていない証拠) | 一致 ✓ |
current_count |
変化しない | 0 のまま ✓ |
| 他の駐車場 | 変化しない | 全て 0 のまま ✓ |
反映はバックグラウンド処理なので即座ではない。sweep 間隔30秒・stale 判定60秒のため 上限90秒まで待つ。遅れて +1 されないことも確認する(今回は65秒追加で待って 2 のまま)。
system_count が 3 になったらべき等が効いていない。追加の送信を止めて原因を切り分ける。
admin-web で対象駐車場の system_count を 0 へリセットする(target: system)。
検証デバイスは削除しない運用にしている(今後も使うため)。そのため
parking_events の検証行も残るが、vehicle_track_id が VERIFY- で始まる行として
後から識別できる。デバイスを削除すれば parking_events と device_commands は
CASCADE で消えるが、parking_activities は parking_lots.id に紐づくので残る。