Repurposing a Xiaomi Smart Camera C302 (indoor pan-tilt, China-cloud only) into a low-latency FaceTime / Zoom / Meet camera over the Tailscale tailnet via go2rtc + WebRTC, without adding it to Frigate / the NVR, and while keeping the existing Tapo NVR cams fully air-gapped. Built on telep-mainframe over 2026-08-08/09. This note captures the full pipeline plus the long gotcha gauntlet that cost the most time.

SUPERSEDED (2026-08-15) — the C302 has been REMOVED; 192.168.30.168 is now a DEAD IP

The Xiaomi C302 is no longer deployed. 192.168.30.168 no longer hosts any camera — treat any reference to it as historical. The C302 was the only camera that ever used the separate go2rtc-cam path; the Tapo NVR cams (.119 DÉL / .139 ÉSZAK) go direct RTSP into Frigate, not through go2rtc-cam. Any downstream config that still pings .168 is stale — e.g. the OliveTin “Kamerák” action was corrected on 2026-08-15 to ping .119/.139 instead. See 2026-08-15-camera-alerts-dead-tailscale-magicdns-docker-dns. This note is kept for the go2rtc/WebRTC pipeline and gotchas, which remain reusable.

For Agents

  • Container: go2rtc-cam at /home/levander/go2rtc-cam/ on telep-mainframe (separate from Frigate’s own go2rtc).
  • Ports: api :1984, rtsp :8555, webrtc :8556 (Frigate’s go2rtc already holds :8554).
  • Tailnet URL: https://telep-mainframe.taild4189d.ts.net:8450 → WebUI. Winning stream page: …:8450/webrtc.html?src=c302.
  • Cam: C302 at 192.168.30.168 on the cams VLAN; H265; Xiaomi P2P source, cloud-brokered key = NOT air-gappable.
  • Client: OBS on the Mac → macOS Window Capture of a chromeless Chrome app-window rendering the WebRTC page → OBS Virtual Camera → FaceTime. Sub-second latency.
  • Privacy: C302 is on a hardware relay (physically powered off when unused). A red “BELSŐ KAMERA AKTÍV” banner lights the camwall TV whenever the cam pings.
  • Secrets live OUTSIDE this vault — see Where the secrets live. Nothing sensitive is recorded here.

Goal & privacy stance

Use the C302 as an on-demand meeting camera with the lowest possible latency, but:

  • Do NOT add it to Frigate / the NVR (it’s a personal meeting cam, not surveillance).
  • Keep the Tapo NVR cams (.119 DÉL / .139 ÉSZAK) air-gapped — the C302’s needs must not reopen the whole camera VLAN to the internet.
  • The C302 cannot be network-air-gapped and still stream (its go2rtc source requires cloud login for a per-connection key — see gotcha #3). Since we can’t isolate it at the network layer, isolation is physical: the C302 is powered through a hardware relay and is only energized during a call. A persistent red banner on the camwall TV makes “the indoor cam is live” impossible to miss.

Architecture

End-to-end pipeline

graph LR
    C302["Xiaomi C302<br/>192.168.30.168<br/><i>H265, P2P</i>"] -->|Xiaomi P2P<br/>cloud-brokered key| G2["go2rtc-cam<br/>on telep-mainframe<br/>:1984 / :8555 / :8556"]
    MiCloud["Mi Home cloud<br/>region <b>de</b>"] -.->|per-conn key| G2
    G2 -->|tailscale serve<br/>--https=8450| TS["tailnet<br/>telep-mainframe.taild4189d.ts.net:8450"]
    TS -->|WebRTC<br/>H265 native| CH["Chrome app-window<br/>webrtc.html?src=c302"]
    CH -->|macOS Window Capture| OBS["OBS + Virtual Camera"]
    OBS -->|Camera Extension| FT["FaceTime / Zoom / Meet"]
    style C302 fill:#3d2020,stroke:#d00000,color:#fff
    style G2 fill:#264653,stroke:#2a9d8f,color:#fff
    style MiCloud fill:#2d2d2d,stroke:#888,color:#fff
    style OBS fill:#264653,stroke:#2a9d8f,color:#fff

go2rtc-cam container

At /home/levander/go2rtc-cam/ on telep-mainframe:

  • Image alexxit/go2rtc, network_mode: host, restart: unless-stopped.
  • Config at config/go2rtc.yaml:
    • api: { listen: ":1984" }
    • rtsp: { listen: ":8555" }not :8554, which Frigate’s own go2rtc restream (127.0.0.1:8554) already holds. This is a second, independent go2rtc instance living alongside Frigate’s.
    • webrtc: { listen: ":8556", candidates: [ "192.168.1.123:8556", "100.115.209.87:8556" ] }
  • Streams:
    • c302: xiaomi://<account>:de@192.168.30.168?did=1183965641&model=xiaomi.camera.c302n — the native Xiaomi P2P source (H265). de is the account region (see gotcha #1).
    • c302_h264: ffmpeg:c302#video=h264#audio=aac — a transcode fallback for players that can’t decode H265 (see gotcha #5). go2rtc streams are LAZY — zero CPU until something actually consumes them, so keeping this defined costs nothing when unused.

Explicit WebRTC candidates are mandatory here

Because the container is network_mode: host, go2rtc binds all interfaces — including the docker0 and Incus/veth bridges (172.17.x, 172.18.x, 172.19.x). Left to auto-discovery it advertises junk bridge IPs as ICE candidates and WebRTC fails to connect. The explicit candidates: list pins the two that actually work: the LAN IP 192.168.1.123:8556 and the tailnet IP 100.115.209.87:8556. (Same class of “host-network binds everything” bug that produced bogus mDNS records in 2026-08-04-telep-mainframe-airplay-receiver-uxplay.)

Tailnet exposure (interim)

Exposed with a shared-node tailscale serve on the telep-mainframe node:

sudo tailscale serve --bg --https=8450 http://127.0.0.1:1984

https://telep-mainframe.taild4189d.ts.net:8450.

Follow-up — should get its own tailnet node

Per tailnet-service-exposure-convention, new services should get their own dedicated tag:telep node + hostname (e.g. c302.taild4189d.ts.net) rather than a shared serve port. This deployment used the interim shared-serve pattern to get running; migrating it to a dedicated node is a noted follow-up.

Client side (Mac): OBS → Virtual Camera → FaceTime

The winning capture pipeline is WebRTC-in-a-real-browser + OBS macOS Window Capture (the why is gotchas #6–#8):

  1. Open the WebRTC page in a chromeless Chrome app-window:
    open -na "Google Chrome" --args --app="https://telep-mainframe.taild4189d.ts.net:8450/webrtc.html?src=c302"
    
  2. In OBS, add a macOS Window Capture source targeting that Chrome window (NOT OBS’s built-in browser source — see gotcha #7).
  3. Start OBS Virtual Camera (OBS 28+ registers as a system Camera Extension), then pick “OBS Virtual Camera” inside FaceTime / Zoom / Meet.

Trade-off: keep the Chrome window visible during calls

macOS Window Capture of a minimized window goes blank. The Chrome app-window must stay open and un-minimized (it can be off to the side / on another Space) for the duration of the call.

Router firewall scoping — the C302 is NOT air-gappable, so scope it per-IP

The C302’s Xiaomi P2P needs both directions on the cams VLAN (telep-router):

  • cams → wan — to reach the Mi Home cloud for the per-connection key (gotcha #3).
  • cams → lan — so the cam’s P2P return packets can reach go2rtc on the LAN (gotcha #4).

Because the cam genuinely needs internet + LAN return traffic, go2rtc cannot air-gap it. The fix is to scope the exposure to that one device rather than the whole VLAN:

  • The temporary zone-wide cams→wan / cams→lan forwardings (which had opened the whole camera VLAN, including the Tapo cams) were replaced with per-IP rules:
    • c302-wan and c302-lan, both with src_ip=192.168.30.168.
  • This restores full air-gap for the Tapo NVR cams (.119 / .139) — only 192.168.30.168 can talk to WAN/LAN; the surveillance cams stay isolated with no internet.
  • Firewall backups are on the router.

Relationship to prior camera-VLAN posture

This is a per-IP refinement of the VLAN-wide cams→wan opened in Changes Made (2026-08-03). That change gave all cams internet; this note narrows it back down to just the C302, re-air-gapping the Tapo cams.

Presence indicator — internal-cam-alert.service

A persistent red banner on the camwall TV whenever the indoor cam is powered/online, so it’s obvious the C302 is live:

  • Unit: internal-cam-alert.service/usr/local/bin/internal-cam-alert.py (root, DISPLAY=:0, After=camwall-x).
  • Pings 192.168.30.168 every 3s. State machine: ACTIVE after 1 successful ping, INACTIVE after 3 consecutive failures.
  • When ACTIVE, draws a full-width override-redirect banner BELSŐ KAMERA AKTÍV — 120px white text on #d00000, geometry 3840x200+0+0, no window manager, -topmost. Clears when the hardware relay cuts power to the cam.
  • Enabled, survives reboot. Rides the same :0 X display as the camwall / uxplay overlays (2026-08-04-telep-mainframe-airplay-receiver-uxplay, 2026-07-28-camwall-4-substream-composite).

The gotcha gauntlet

The parts that cost the most time — read before touching any of this.

1. Xiaomi login 70016 "登录验证失败" = wrong region, not wrong password

The C302’s Mi account lives on server de. go2rtc’s WebUI login flow does handle captcha + email 2FA — but with the wrong region the request fails with 70016 before the challenge is ever presented. Once the region was set to de, go2rtc passed both the captcha and the 2FA and logged in. If you see 70016, fix the region first; don’t chase the credentials.

2. Getting did / model / account / region — the tokens extractor

Use PiotrMachowski’s Xiaomi Cloud Tokens Extractor (it handles captcha + 2FA). Installed at ~/xiaomi-tokens/ on the Mac.

  • Its console output is NOT written to a file by default — pipe it: ... | tee out.txt to keep the values.
  • Values obtained: account 6426258469, region de, did 1183965641, model xiaomi.camera.c302n, cam IP 192.168.30.168, home id 600001097108.

3. The xiaomi source needs a cloud LOGIN — you can't feed it pre-obtained tokens

go2rtc’s xiaomi:// source authenticates to the Mi cloud to obtain per-connection keys (“local video, cloud-brokered key”). You cannot hand it the extractor’s token and skip login — it must log in itself (credential stored by go2rtc as <account>: V1:***). Consequence: this route is inherently not air-gappable — hence the physical relay + per-IP firewall scoping instead of network isolation.

4. P2P across the VLAN needs cams → lan

Without the LAN return path, go2rtc logs read udp i/o timeout and delivers 0-byte frames — the cam’s P2P return packets get silently dropped at the VLAN boundary. Adding cams→lan (scoped to .168) fixed it. This is why the cam can’t be pure cams→wan only.

5. The C302 streams H265 — RTSP into OBS shows black

OBS’s RTSP Media Source cannot display H.265 (renders black). That’s what the c302_h264 ffmpeg transcode was for. But the better answer is WebRTC: a real browser decodes H265 natively, so the WebRTC path needs no transcode at all.

6. OBS Media Source black even when VLC plays the same stream → disable hardware decoding

On macOS, uncheck “Use hardware decoding” on the OBS Media Source. That single toggle was the fix for the RTSP path being black while VLC showed it fine.

7. OBS's built-in browser (CEF) renders WebRTC gray — use a real browser + Window Capture

The exact same webrtc.html?src=c302 is snappy and sub-second in Safari/Chrome, but OBS’s built-in browser source (CEF) renders it gray — a known OBS limitation. The workaround that won: open the page in a chromeless Chrome app-window and use OBS macOS Window Capture of it. Sub-second into FaceTime. (See the client-side section for the exact open -na command and the keep-it-visible trade-off.)

8. The latency floor is the TRANSPORT, not the camera

WebRTC in a real browser is sub-second, which proves the C302’s P2P path is fine. The laggy variants were the transports: MSE was ~1s, and RTSP + H264 transcode was the worst. Reach for WebRTC first; only fall back to the transcode for players that can’t do WebRTC/H265.

9. No local PTZ — the C302 has no miio endpoint

The C302 does not expose the local miio endpoint (UDP 54321 unreachable; TCP 554/6467 closed). Motor / pan-tilt control is Mi Home cloud only — there is no LAN PTZ. For “Center Stage”-style auto-framing, the path is an OBS face-tracking plugin (digital PTZ / crop-follow; not installed).

10. Setup-time router gotchas

  • The cams SSID telep-cc (WPA2/psk2, client isolation on) is normally hidden; it was temporarily unhidden for Mi Home onboarding, then re-hidden.
  • telep-router’s SSH host key changed again (lingering effect of the 2026-08-03 factory reset) — clear + re-accept known_hosts; this is expected, not an intrusion.

Where the secrets live

No secrets are stored in this vault

  • Mi Home account password — not recorded anywhere in the vault; entered interactively into the tokens extractor / go2rtc login.
  • go2rtc-stored credential (V1:*** auth blob) — lives only inside go2rtc-cam’s config/state on telep-mainframe at /home/levander/go2rtc-cam/.
  • Camera token / per-connection keys — brokered live by the Mi cloud per connection; never persisted to the vault. The non-sensitive identifiers (did, model, region, home id, LAN IP) are safe and are recorded above because they’re needed to reconstruct the config.