## 付録：Tapo C520WS内蔵AIの検知結果をFrigateへ連携する

### 1. 概要

Tapo C520WSは、カメラ本体で人物・車両・ペット等の検知を行っている。この検知結果の一部はONVIF EventとしてLAN内から取得できる。

そこで、C520WSが出力するONVIF EventをPythonで購読し、検知結果をFrigateのManual Event APIへ送信する小さなbridgeを作成した。

構成は以下の通りである。

```text
Tapo C520WS
│
├─ RTSP stream1
│      ↓
│   Frigate
│   高画質録画
│
├─ RTSP stream2
│      ↓
│   Frigate OpenVINO
│   物体検出
│
└─ ONVIF Event
       ↓
    bridge.py
       ↓
    Frigate Manual Event API
       ↓
    Frigate Review
    「Tapo AI」
```

この構成では、Tapo内蔵AIとFrigateのOpenVINO物体検出を同時に動作させることもできる。

Tapo側だけを物体検出に使用する場合は、Frigate側の物体検出を無効化しても、RTSPによる連続録画とTapo AI由来のイベント登録は継続できる。

---

## 2. 検証環境

使用した主な環境は以下の通り。

```text
Camera:
  TP-Link Tapo C520WS

Server:
  Intel N100
  Proxmox VE
  unprivileged LXC
  Docker
  Frigate 0.17系

Video decode:
  Intel iGPU
  VAAPI

Frigate object detection:
  Intel iGPU
  OpenVINO

Camera IP:
  192.168.100.190

ONVIF Port:
  2020

Frigate camera name:
  tapo_gate
```

LXC内でDockerを動かし、そのDocker上でFrigateを実行している。

Intel N100のiGPUはProxmoxホストからLXCへ `/dev/dri/renderD128` を渡し、さらにDockerコンテナへ渡している。

---

## 3. Tapo側の設定

Tapoアプリから、RTSP/ONVIF用の「カメラのアカウント」を作成する。

これはTP-Linkアカウントとは別のローカルアカウントである。

本構成では、

```text
RTSP:
  TCP 554

ONVIF:
  TCP 2020
```

を使用する。

RTSP URLは以下の形式になる。

```text
高画質:
rtsp://USER:PASSWORD@192.168.100.190:554/stream1

低画質:
rtsp://USER:PASSWORD@192.168.100.190:554/stream2
```

Tapoアプリ側では、使用したいAI検知機能も有効にしておく。

---

## 4. FrigateのRTSP設定

Frigateではgo2rtcを使用し、高画質stream1を録画、低画質stream2を物体検出に使用した。

`config.yml` の主要部分は以下のようになる。

```yaml
mqtt:
  enabled: false

ffmpeg:
  hwaccel_args: preset-vaapi

detectors:
  ov:
    type: openvino
    device: GPU

model:
  width: 300
  height: 300
  input_tensor: nhwc
  input_pixel_format: bgr
  path: /openvino-model/ssdlite_mobilenet_v2.xml
  labelmap_path: /openvino-model/coco_91cl_bkgr.txt

objects:
  track:
    - person
    - car
    - bicycle
    - motorcycle
    - cat
    - dog

go2rtc:
  streams:
    tapo_gate:
      - rtsp://USER:PASSWORD@192.168.100.190:554/stream1

    tapo_gate_sub:
      - rtsp://USER:PASSWORD@192.168.100.190:554/stream2

cameras:
  tapo_gate:
    ffmpeg:
      inputs:
        - path: rtsp://127.0.0.1:8554/tapo_gate
          input_args: preset-rtsp-restream
          roles:
            - record

        - path: rtsp://127.0.0.1:8554/tapo_gate_sub
          input_args: preset-rtsp-restream
          roles:
            - detect

    detect:
      enabled: true
      fps: 5

    record:
      enabled: true
      continuous:
        days: 7
      alerts:
        retain:
          days: 30
          mode: motion
      detections:
        retain:
          days: 30
          mode: motion

version: 0.17-0
```

`detect.enabled: true` がない場合、`roles: detect` を指定していてもFrigateの物体検出自体は実行されない。

Tapo AIのみを使用し、Frigate側のOpenVINO検出を停止する場合は、

```yaml
detect:
  enabled: false
```

とする。

---

## 5. C520WSから取得できたONVIF Event

PythonからONVIF PullPoint Subscriptionを作成してイベントを監視したところ、C520WS実機では以下の6種類のイベントを確認できた。

### 人物検知

```text
topic=tns1:RuleEngine/PeopleDetector/People
IsPeople=true
```

### 車両検知

```text
topic=tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent
IsVehicle=true
```

### ペット検知

```text
topic=tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent
IsPet=true
```

車両とペットは同じ`TPSmartEventDetector/TPSmartEvent` Topicで通知され、`IsVehicle`と`IsPet`というData fieldの違いで判別できる。

### 通常の動体検知

```text
topic=tns1:RuleEngine/CellMotionDetector/Motion
IsMotion=true
```

### ライン通過検知

```text
topic=tns1:RuleEngine/LineCrossDetector/LineCross
IsLineCross=true
```

### カメラ妨害・タンパリング検知

```text
topic=tns1:RuleEngine/TamperDetector/Tamper
IsTamper=true
```

したがって、今回使用したC520WSでは少なくとも、

```text
IsPeople
IsVehicle
IsPet
IsMotion
IsLineCross
IsTamper
```

の6種類をONVIF経由でリアルタイムに取得できた。

また、C520WSは対象が検出されている間、同一の`true`イベントを繰り返し送信する。

例えば人物が画角内にいる間は、

```text
IsPeople=true
IsPeople=true
IsPeople=true
...
```

というイベントが大量に届く。

そのため、ONVIF Eventを受信するたびにFrigate Eventを生成すると、同じ人物に対して大量のイベントが作られてしまう。

bridge側では同じイベントが継続している間は新規イベントを作成せず、状態変化のみを処理する。

```text
false → true
    イベント開始

true → true
    同一イベントを継続

true → false
    イベント終了
```

さらに、イベントによっては`false`が確実に届かない場合も考慮し、一定時間同じ`true`が届かなくなった場合にもイベントを終了するタイムアウトを設けた。

なお、本構成では`IsMotion`はFrigateへ転送していない。通常の動体検知は非常に頻繁に発生するため、すべてManual Eventとして登録するとFrigate Reviewが大量のイベントで埋まってしまうためである。

---

## 6. Python環境

Frigate LXC内に専用venvを作成した。

```bash
apt install -y python3-venv

python3 -m venv /opt/tapo-onvif-test

source /opt/tapo-onvif-test/bin/activate

pip install -U pip
pip install -U onvif-zeep-async aiohttp
```

---

## 7. Frigate内部APIの公開

bridgeはFrigateのManual Event APIを利用する。

FrigateのDockerコンテナが持つ内部HTTP APIの5000番ポートを、LXC内部のlocalhostだけに公開した。

`docker-compose.yml` の例：

```yaml
ports:
  - "8971:8971"
  - "127.0.0.1:5000:5000"
  - "8554:8554"
  - "8555:8555/tcp"
  - "8555:8555/udp"
```

反映する。

```bash
cd /opt/frigate
docker compose up -d
```

5000番は外部LANへ公開せず、

```text
127.0.0.1:5000
```

に限定している。

---

## 8. bridge.py

以下のPythonプログラムで、TapoのONVIF EventをFrigate Manual Eventへ変換する。

```python
#!/usr/bin/env python3

import asyncio
import os
import signal
import time
from datetime import timedelta

import aiohttp
from onvif import ONVIFCamera


# ----------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------

TAPO_HOST = os.environ["TAPO_HOST"]
TAPO_PORT = int(os.environ.get("TAPO_PORT", "2020"))
TAPO_USER = os.environ["TAPO_USER"]
TAPO_PASSWORD = os.environ["TAPO_PASSWORD"]

FRIGATE_URL = os.environ.get(
    "FRIGATE_URL",
    "http://127.0.0.1:5000",
).rstrip("/")

FRIGATE_CAMERA = os.environ.get(
    "FRIGATE_CAMERA",
    "tapo_gate",
)

EVENT_IDLE_TIMEOUT = float(
    os.environ.get("EVENT_IDLE_TIMEOUT", "15")
)

PULL_TIMEOUT = 5
SUBSCRIPTION_TIME = timedelta(seconds=600)


# Tapo ONVIF field -> Frigate event label
#
# Motionはイベント数が多すぎるため、
# Frigateへは送信しない。
EVENT_MAP = {
    "IsPeople": "person",
    "IsVehicle": "car",
    "IsPet": "pet",
    "IsLineCross": "line_crossing",
    "IsTamper": "tamper",
}


# source -> {
#     "event_id": "...",
#     "label": "...",
#     "last_seen": monotonic timestamp,
# }
active_events = {}

stop_event = asyncio.Event()


def log(message):
    print(message, flush=True)


def subscription_lost():
    log("WARNING: ONVIF subscription lost")


async def frigate_start(session, source, label):

    url = (
        f"{FRIGATE_URL}/api/events/"
        f"{FRIGATE_CAMERA}/{label}/create"
    )

    payload = {
        "sub_label": "Tapo AI",
        "score": 1.0,
        "duration": None,
        "include_recording": True,
    }

    try:
        async with session.post(
            url,
            json=payload,
        ) as response:

            text = await response.text()

            if response.status != 200:
                log(
                    f"ERROR: Frigate create {label}: "
                    f"HTTP {response.status}: {text}"
                )
                return

            data = await response.json()

            event_id = data["event_id"]

            active_events[source] = {
                "event_id": event_id,
                "label": label,
                "last_seen": time.monotonic(),
            }

            log(
                f"START {label} "
                f"[{source}] "
                f"event={event_id}"
            )

    except Exception as exc:
        log(
            f"ERROR: Frigate create {label}: "
            f"{exc!r}"
        )


async def frigate_end(session, source, reason="ONVIF false"):

    event = active_events.pop(source, None)

    if event is None:
        return

    event_id = event["event_id"]
    label = event["label"]

    url = f"{FRIGATE_URL}/api/events/{event_id}/end"

    try:
        async with session.put(
            url,
            json={"end_time": None},
        ) as response:

            text = await response.text()

            if response.status != 200:
                log(
                    f"ERROR: Frigate end {label}: "
                    f"HTTP {response.status}: {text}"
                )
                return

            log(
                f"END   {label} "
                f"[{source}] "
                f"reason={reason}"
            )

    except Exception as exc:
        log(
            f"ERROR: Frigate end {label}: "
            f"{exc!r}"
        )


async def process_event(session, name, value):

    if name not in EVENT_MAP:
        return

    state = str(value).lower() == "true"
    label = EVENT_MAP[name]

    if state:
        existing = active_events.get(name)

        if existing is not None:
            # 同じtrueが大量に送られてくるため、
            # 新規イベントは作らず最終受信時刻のみ更新する。
            existing["last_seen"] = time.monotonic()
            return

        await frigate_start(
            session,
            name,
            label,
        )

    else:
        await frigate_end(
            session,
            name,
            reason="ONVIF false",
        )


async def idle_reaper(session):

    while not stop_event.is_set():

        await asyncio.sleep(2)

        now = time.monotonic()

        expired = []

        for source, event in list(active_events.items()):
            if now - event["last_seen"] > EVENT_IDLE_TIMEOUT:
                expired.append(source)

        for source in expired:
            await frigate_end(
                session,
                source,
                reason=f"idle>{EVENT_IDLE_TIMEOUT:g}s",
            )


async def main():

    loop = asyncio.get_running_loop()

    for sig in (signal.SIGTERM, signal.SIGINT):
        try:
            loop.add_signal_handler(
                sig,
                stop_event.set,
            )
        except NotImplementedError:
            pass

    cam = None
    manager = None
    reaper_task = None

    timeout = aiohttp.ClientTimeout(total=10)

    async with aiohttp.ClientSession(
        timeout=timeout
    ) as session:

        try:
            log(
                f"Connecting to Tapo "
                f"{TAPO_HOST}:{TAPO_PORT}"
            )

            cam = ONVIFCamera(
                TAPO_HOST,
                TAPO_PORT,
                TAPO_USER,
                TAPO_PASSWORD,
            )

            await cam.update_xaddrs()

            # PullPoint Subscriptionを作成。
            # ライブラリ側のmanagerにRenewを任せる。
            manager = await cam.create_pullpoint_manager(
                SUBSCRIPTION_TIME,
                subscription_lost,
            )

            await manager.set_synchronization_point()

            pullpoint = manager.get_service()

            log(
                "Tapo -> Frigate bridge started"
            )

            log(
                "Watching: "
                + ", ".join(EVENT_MAP.keys())
            )

            reaper_task = asyncio.create_task(
                idle_reaper(session)
            )

            while not stop_event.is_set():

                response = await pullpoint.PullMessages(
                    {
                        "MessageLimit": 100,
                        "Timeout": timedelta(
                            seconds=PULL_TIMEOUT
                        ),
                    }
                )

                messages = (
                    getattr(
                        response,
                        "NotificationMessage",
                        None,
                    )
                    or []
                )

                for msg in messages:

                    try:
                        element = msg.Message._value_1
                        data = element.Data
                    except Exception:
                        continue

                    items = (
                        getattr(
                            data,
                            "SimpleItem",
                            None,
                        )
                        or []
                    )

                    for item in items:

                        await process_event(
                            session,
                            item.Name,
                            item.Value,
                        )

        finally:

            log("Bridge shutting down")

            stop_event.set()

            if reaper_task is not None:
                reaper_task.cancel()

                try:
                    await reaper_task
                except asyncio.CancelledError:
                    pass

            # 開いたままのFrigate Eventを終了する。
            for source in list(active_events):
                await frigate_end(
                    session,
                    source,
                    reason="bridge shutdown",
                )

            if manager is not None:
                try:
                    await manager.shutdown()
                except Exception as exc:
                    log(
                        "WARNING: subscription shutdown: "
                        f"{exc!r}"
                    )

            if cam is not None:
                try:
                    await cam.close()
                except Exception:
                    pass

            log("Bridge stopped")


if __name__ == "__main__":
    asyncio.run(main())
```

保存場所：

```text
/opt/tapo-onvif-test/bridge.py
```

---

## 9. Tapo EventとFrigate labelの対応

bridgeでは以下のように変換している。

```text
Tapo ONVIF             Frigate

IsPeople        →      person

IsVehicle       →      car

IsPet           →      pet

IsLineCross     →      line_crossing

IsTamper        →      tamper

IsMotion        →      無視
```

`IsMotion`を無視しているのは、通常の動体検知が非常に頻繁に発生し、Frigate Reviewが大量のManual Eventで埋まるためである。

---

## 10. Frigate Manual Event

Tapo AIで人物を検出した場合、bridgeは概念的には以下のAPIを呼び出している。

```text
POST /api/events/tapo_gate/person/create
```

送信内容：

```json
{
  "sub_label": "Tapo AI",
  "score": 1.0,
  "duration": null,
  "include_recording": true
}
```

これによりFrigate Reviewに、

```text
Tapo AI
```

というsub-label付きのManual Eventが作成される。

イベント終了時は、

```text
PUT /api/events/{event_id}/end
```

を呼び出す。

---

## 11. 「オブジェクトの詳細データがありません」と表示される理由

Tapo AI由来のイベントをFrigate Reviewで開くと、

```text
オブジェクトの詳細データがありません。
```

と表示される。

これは異常ではない。

Frigate自身の物体検出では、

```text
物体ラベル
検出スコア
Bounding Box
追跡位置
ゾーン
```

等のObject Tracker情報を持っている。

一方、C520WSからONVIF Eventとして取得できるのは、

```text
IsPeople=true
IsVehicle=true
IsPet=true
```

等の判定結果であり、人物の画像上の座標やBounding Boxは含まれていない。

また、本bridgeが使用しているのはFrigate Object Trackerへの検出結果注入ではなく、Manual Event APIである。

そのため、Frigate上ではイベントと録画は関連付けられるものの、通常の物体検出と同じ詳細なObject情報は存在しない。

---

## 12. 認証情報の保存

TapoのカメラアカウントのパスワードをPythonファイルへ直接記述せず、systemdのEnvironmentFileから読み込む。

```text
/etc/tapo-frigate-bridge.env
```

内容：

```bash
TAPO_HOST=192.168.100.190
TAPO_PORT=2020

TAPO_USER=tapo-gate
TAPO_PASSWORD=ここにTapoカメラアカウントのパスワード

FRIGATE_URL=http://127.0.0.1:5000
FRIGATE_CAMERA=tapo_gate

EVENT_IDLE_TIMEOUT=15
```

専用ユーザーを作成する。

```bash
useradd \
  --system \
  --no-create-home \
  --shell /usr/sbin/nologin \
  tapo-bridge
```

EnvironmentFileの権限を制限する。

```bash
chown root:tapo-bridge /etc/tapo-frigate-bridge.env
chmod 640 /etc/tapo-frigate-bridge.env
```

---

## 13. systemdによる常駐化

サービスファイル：

```text
/etc/systemd/system/tapo-frigate-bridge.service
```

内容：

```ini
[Unit]
Description=Tapo ONVIF to Frigate Event Bridge

After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service


[Service]
Type=simple

User=tapo-bridge
Group=tapo-bridge

EnvironmentFile=/etc/tapo-frigate-bridge.env

# onvif-zeep-async等がHOME/cacheを必要とするため、
# 専用の書き込み可能ディレクトリを与える。
Environment=HOME=/var/lib/tapo-frigate-bridge
Environment=XDG_CACHE_HOME=/var/lib/tapo-frigate-bridge/.cache

StateDirectory=tapo-frigate-bridge

ExecStart=/opt/tapo-onvif-test/bin/python /opt/tapo-onvif-test/bridge.py

Restart=on-failure
RestartSec=5

TimeoutStopSec=20

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full


[Install]
WantedBy=multi-user.target
```

当初、

```text
PermissionError:
Permission denied: '/home/tapo-bridge'
```

が発生した。

これは、ホームディレクトリを持たないsystem userで実行している一方、PythonライブラリがHOME配下へファイルを作成しようとしたためである。

そこで、

```ini
Environment=HOME=/var/lib/tapo-frigate-bridge
Environment=XDG_CACHE_HOME=/var/lib/tapo-frigate-bridge/.cache
StateDirectory=tapo-frigate-bridge
```

を指定し、systemdに専用の書き込み領域を作成させた。

サービスを有効化する。

```bash
systemctl daemon-reload

systemctl enable --now tapo-frigate-bridge
```

状態確認：

```bash
systemctl status tapo-frigate-bridge --no-pager
```

リアルタイムログ：

```bash
journalctl -u tapo-frigate-bridge -f
```

正常時には、

```text
Connecting to Tapo 192.168.100.190:2020
Tapo -> Frigate bridge started
Watching: IsPeople, IsVehicle, IsPet, IsLineCross, IsTamper
```

等が表示される。

人物検知時には、

```text
START person [IsPeople] event=...
```

イベント終了時には、

```text
END person [IsPeople] reason=...
```

等が表示される。

---

## 14. PullPoint Subscription

ONVIF EventはPullPoint Subscriptionを使用して取得する。

Tapoでは購読に有効期限があるため、長時間常駐させる場合はSubscriptionを更新する必要がある。

本bridgeでは、

```python
manager = await cam.create_pullpoint_manager(...)
```

を使用し、PullPoint managerに購読更新を任せている。

サービス終了時には、

```python
await manager.shutdown()
```

を実行し、可能な限りSubscriptionを正常に終了する。

これは、プログラムを強制終了した際にカメラ側へ古いSubscriptionが残ることを避ける目的もある。

---

## 15. イベント終了タイムアウト

C520WSは、対象を検出している間に同じ`true`イベントを連続して送信する。

そのため、bridgeでは最終受信時刻を記録する。

例えば、

```text
IsPeople=true
IsPeople=true
IsPeople=true
```

と届いている間は、同一のFrigate Eventを継続する。

その後、

```text
EVENT_IDLE_TIMEOUT=15
```

秒間新しい`true`が届かなければ、Frigate Eventを自動終了する。

これにより、ONVIF側から明示的な`false`が届かなかった場合でも、Manual Eventが永久に開いた状態になることを防止している。

---

## 16. Frigateのpre-capture

Frigate ReviewでTapo AIイベントを再生すると、人物が映像上に現れる数秒前から再生されることがある。

これはTapo AIが未来の人物を予測しているわけではなく、Frigate側がイベント発生前の映像を含めるpre-captureを持っているためである。

防犯用途では、検知直前の状況も確認できるため、この動作はそのまま利用している。

---

## 17. Tapo AIとFrigate OpenVINOの違い

本構成では2種類の物体検出器を同時に使用できる。

```text
Tapo C520WS内蔵AI
    ↓
ONVIF
    ↓
bridge.py
    ↓
Frigate Manual Event


RTSP stream2
    ↓
Frigate
    ↓
OpenVINO
    ↓
Frigate Object Tracker
```

C520WS内蔵AIは、防犯用途を想定した比較的粗い分類を行う。

代表的には、

```text
person
vehicle
pet
```

等である。

一方、Frigateで使用する一般的な物体検出モデルでは、

```text
person
car
bicycle
motorcycle
bus
truck
cat
dog
```

等、より細かい分類を利用できる。

したがって、

```text
Tapo AI
    カメラ内で推論
    サーバ側AI負荷が不要
    分類は比較的粗い

Frigate OpenVINO
    サーバ側で推論
    分類が細かい
    Bounding Box等の詳細情報が得られる
```

という違いがある。

---

## 18. 注意点

この方法は、Tapo C520WSのONVIF Eventを利用した非公式な連携である。

ファームウェア更新によって、

```text
ONVIF Topic
イベント名
Subscriptionの挙動
認証方法
```

等が変更される可能性がある。

また、本bridgeはFrigateのObject Trackerへ外部検出結果を直接注入しているわけではない。

あくまで、

```text
Tapo AI検出
      ↓
Frigate Manual Event
```

という連携である。

そのため、Frigate自身のOpenVINO検出イベントとTapo AI由来のManual Eventでは、Frigate内部で保持される情報量やUI上の挙動が異なる。

---

## 19. 最終構成

完成後の構成は以下となった。

```text
                        ┌─────────────────────┐
                        │    Tapo C520WS      │
                        │                     │
                        │  Camera internal AI │
                        └───┬───────────┬─────┘
                            │           │
                         RTSP        ONVIF Event
                            │           │
                            │           ▼
                            │      bridge.py
                            │           │
                            │           │ Manual Event API
                            │           ▼
                            │       Frigate
                            │
             ┌──────────────┴──────────────┐
             │                             │
          stream1                       stream2
             │                             │
             ▼                             ▼
       Continuous Record               OpenVINO
                                          │
                                          ▼
                                    Object Detection
```

この構成によって、

```text
・Tapo C520WSの高画質RTSPをFrigateで連続録画
・低画質RTSPをFrigate OpenVINOで物体検出
・Tapo本体AIの検知結果をONVIFから取得
・Tapo AIの検知結果をFrigate Reviewへ登録
・両検出器を同一カメラ・同一録画上で比較
```

できるようになった。