Skip to content

Add Tuya LAN transport: local two-way audio without cloud credentials (tuya-lan: source) - #2511

Open
RomuloGatto wants to merge 5 commits into
AlexxIT:masterfrom
RomuloGatto:tuya-lan
Open

RomuloGatto wants to merge 5 commits into
AlexxIT:masterfrom
RomuloGatto:tuya-lan

Conversation

@RomuloGatto

@RomuloGatto RomuloGatto commented Sep 20, 2026 •

Copy link
Copy Markdown

What

Adds a second transport to the Tuya integration: the camera's local signaling protocol
(TCP 6668, authenticated with the 16-byte device local key), next to the existing Smart API and
Cloud API paths. Media still flows over the same Pion WebRTC session; only the signaling
transport differs, so this is a new tuya-lan: source rather than a second media implementation.

The intended source layout separates the two audio directions explicitly:

streams:
  camera:
    - rtsp://user:pass@192.168.1.10:554/live/ch0  # video + camera microphone
    - tuya-lan://192.168.1.10?device_id=XXX&local_key=XXXXXXXXXXXXXXXX  # client mic -> camera speaker

All LAN parameters can instead live in a JSON file, keeping the local key out of the go2rtc
configuration:

streams:
  camera:
    - tuya-lan:/etc/go2rtc/tuya-lan.json

Why

  • No Tuya account, cloud project, subscription or internet access at runtime. The device
    local key is enough for standard cameras. Cameras that validate the app handshake also need
    the static WebRTC session metadata that the apps fetch once from the cloud.
  • Cameras whose cloud path does not work can still be used. The camera this was developed
    against (VDS CIPTZW-3M PTZ, Tuya-based Happytimesoft firmware) never played outgoing audio
    through the cloud path; over the LAN the speaker works.
  • The RTSP source remains responsible for video and the camera's own microphone. tuya-lan:
    supplies the talkback direction used by a browser/Frigate microphone or another go2rtc
    consumer.

How it works

  • pkg/tuya/lan.go implements protocol 3.4: AES-ECB/PKCS7 payloads, HMAC-SHA256 frames, the
    0x000055AA framing over TCP, and the signaling client (offer, answer, candidate and Protocol
    312 speaker command). Only protocol 3.4 is implemented; this camera rejects 3.5.
  • The signaling client is selected per transport at Dial time (tuya-lan: -> local TCP;
    otherwise the existing Smart/Cloud paths). A small interface (GetSignal / SetHandlers)
    lets all three transports share the existing WebRTC media client.
  • The WebRTC answer is normalized for cameras that tunnel video through Tuya's private KCP
    section (m=application 9 tuya): that unusable section is replaced with an inactive video
    placeholder so Pion can negotiate the bidirectional G.711 audio track. Answers with a regular
    H264 section pass through unchanged.
  • Protocol 312 opens the camera speaker when a backchannel track is attached. A signaling write
    failure is returned instead of being discarded in a detached goroutine.
  • LAN microphone audio is forwarded as received. The 240-byte reframing on the cloud path exists
    to reduce relay delay and made this camera drop audio; LAN keeps the browser's standard 20 ms
    G.711 frames.
  • G.711/8000 is advertised for the LAN backchannel: browsers offer G.711/Opus, while Tuya reports
    this camera's audio as codec type 101 (mapped to PCML by the cloud dialect).
  • A completed session that sends no RTP is treated as a failed LAN dial. Setup is bounded and
    retries use a per-device exponential backoff (2 seconds to 2 minutes), allowing that camera's
    small session table to drain without blocking other cameras. Smart and Cloud transports retain
    their previous setup behavior.

Verified on hardware

Camera: VDS CIPTZW-3M PTZ (Tuya / Happytimesoft), with the go2rtc host on another VLAN.

  • handshake: protocol=3.4 handshake_response cmd=4 status=0 payload_len=48
  • session: streamType=2, answer m=audio 9 UDP/TLS/RTP/SAVPF 0 /
    a=rtpmap:0 PCMU/8000, Protocol 312 speaker command accepted by the camera
  • two-way audio: an 880 Hz on/off tone (400 PCMU packets) was driven into the talkback track; an
    independent recording of the camera microphone contains the pattern with peak/quiet band-power
    ratios of approximately 3e3 and 4e5 in two runs, proving that the speaker played it
  • the same source is used in production for spoken event acknowledgement through a normal go2rtc
    consumer; the human in the room confirmed the phrase was audible repeatedly

Tests

go test ./pkg/tuya/...
go test -race ./pkg/tuya/...
go vet ./pkg/tuya/...
go build .

The package tests cover protocol 3.4 framing and authentication failures, LAN answer
normalization, URL/file configuration, G.711 codec negotiation, retry bounds and retry isolation
between cameras.

go test ./... also reaches the Tuya package successfully. The repository currently has unrelated
baseline failures on the PR base (platform-dependent FFmpeg snapshots, stale/missing symbols and
existing vet findings); the same failing package set reproduces on base commit c245815.

Notes / limitations

  • A camera on another subnet/VLAN may need an ICE server reachable by the camera, for example
    stun=stun:192.168.100.5:3478. Without it, signaling can succeed while ICE never carries media.
  • Most cameras accept very few concurrent media sessions and retain closed attempts for a drain
    window. The per-device backoff prevents failed retries from keeping that session table full.
  • LAN video carried in Tuya's private KCP section is not decoded. Keep RTSP/ONVIF first for video
    and camera audio, and use tuya-lan: as the talkback source.
  • The exact LAN answer normalization and Protocol 312 behavior are hardware-verified on the camera
    above. Other Tuya firmware families may use a different SDP layout or signaling dialect.

Related issues: #2478 (outgoing audio delivered but never played, Protocol 312 unanswered) and
#2467 (audio negotiated as PCML/8000 without a receiver) are cloud-path symptoms. This PR leaves
those paths unchanged; its findings are consistent with them: the speaker command is accepted on
the LAN channel and the camera's audio is G.711.

Tuya cameras speak their signaling protocol on the local network (TCP 6668) as
well as through the cloud. That local channel is authenticated with the device's
16-byte local key and reproduces what the mobile apps do: an offer/answer/candidate
exchange that ends in the same Pion WebRTC session, so the existing media path is
reused and only the transport differs.

This adds a second transport next to the existing Smart/Cloud API ones:

  streams:
    camera:
      - rtsp://user:pass@192.168.1.10:554/live/ch0   # video + camera microphone
      - tuya-lan://192.168.1.10?device_id=XXX&local_key=XXXXXXXXXXXXXXXX

No Tuya account, cloud project or internet access is involved at runtime - the
local key is read from the query string or from a JSON file
(`tuya-lan:/etc/go2rtc/tuya-lan.json`), which keeps it out of the config.

Implementation:
- pkg/tuya/lan.go: the 3.4 protocol (AES-ECB/PKCS7 payloads, HMAC-SHA256 frames,
  framing over a TCP connection) plus the signaling client. The WebRTC answer is
  normalized: cameras that tunnel video over Tuya's private KCP section get that
  section replaced with an inactive placeholder, so Pion can negotiate the
  bidirectional G.711 audio track.
- The signal client is now an interface (GetSignal/SetHandlers) so the MQTT and
  LAN transports share the client code, and Protocol 312 (speaker) is sent on the
  LAN transport, where the camera acknowledges it.
- The LAN audio is forwarded as received (G.711 20 ms frames): the cloud path's
  240-byte reframing only exists to cut delay behind the Tuya relay and makes this
  camera drop the audio completely.
- G.711/8000 is advertised for the LAN microphone track - browsers only offer
  G.711/Opus, and Tuya reports the camera audio as codecType 101 (PCML).

Also fixes two non-constant format string vet warnings in cloud_api.go, without
which `go test ./pkg/tuya` cannot run.
Unit tests for the parts that do not need a camera: 3.4 frame encoding/decoding
(including tampering and wrong key), answer normalization for cameras that tunnel
video through Tuya's KCP section, source parsing from the query string and from a
JSON file (defaults and validation), and the G.711 codec list used for the
microphone track.

The README gains a `Tuya LAN API` section with both config forms, the parameters
and the two caveats found on real hardware: cameras that can only be reached
across a subnet need an ICE server (`stun=`), and most cameras hold a single
audio session, so the LAN session can silence the camera's RTSP audio.
go vet (and therefore the default `go test`) refuses to build the package with
fmt.Errorf(dynamic). Use errors.New for the API message, no behaviour change.
The camera hands out only a couple of concurrent media sessions and keeps each one
occupied for minutes after the client goes away. When a session is refused the
handshake, offer and ICE negotiation still complete - the camera just never sends
media - and a producer whose Start() fails is retried immediately (its retry counter
restarts at zero), so the retry loop itself keeps the camera saturated and the source
stays dead until go2rtc is restarted.

- fail the dial (not Start) when a completed session carries no media
- bound handshake+offer+ICE so a saturated camera cannot leave Dial blocked forever
- space failed sessions out (2s doubling, capped at 2 min) and reject further dials
  without touching the camera while the window is active
@AlexxIT AlexxIT self-assigned this Oct 1, 2026
@AlexxIT AlexxIT added brand/tuya enhancement New feature or request labels Oct 1, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

brand/tuya enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants