Cleanup and update comments

This commit is contained in:
seydx
2025-10-28 11:12:44 +01:00
parent 0f27bb1124
commit 25e7ac531e
3 changed files with 73 additions and 38 deletions
+27 -12
View File
@@ -17,25 +17,28 @@ import (
) )
type Client struct { type Client struct {
api TuyaAPI api TuyaAPI
conn *webrtc.Conn conn *webrtc.Conn
pc *pion.PeerConnection pc *pion.PeerConnection
connected core.Waiter
closed bool
// HEVC only:
dc *pion.DataChannel dc *pion.DataChannel
videoSSRC *uint32 videoSSRC *uint32
audioSSRC *uint32 audioSSRC *uint32
streamType int streamType int
isHEVC bool isHEVC bool
connected core.Waiter
closed bool
handlersMu sync.RWMutex handlersMu sync.RWMutex
handlers map[uint32]func(*rtp.Packet) handlers map[uint32]func(*rtp.Packet)
} }
type DataChannelMessage struct { type DataChannelMessage struct {
Type string `json:"type"` Type string `json:"type"` // "codec", "start", "recv", "complete"
Msg string `json:"msg"` Msg string `json:"msg"`
} }
// RecvMessage contains SSRC values for video/audio streams
type RecvMessage struct { type RecvMessage struct {
Video struct { Video struct {
SSRC uint32 `json:"ssrc"` SSRC uint32 `json:"ssrc"`
@@ -159,7 +162,8 @@ func Dial(rawURL string) (core.Producer, error) {
} }
if client.isHEVC { if client.isHEVC {
// Tuya seems to answers always with H264 and PCMU/8000 and PCMA/8000 codecs, replace with real codecs // We need to replace the SDP codecs with the real ones from Skill.
// The actual media comes via DataChannel, not RTP tracks.
for _, media := range client.conn.Medias { for _, media := range client.conn.Medias {
if media.Kind == core.KindVideo { if media.Kind == core.KindVideo {
@@ -202,9 +206,7 @@ func Dial(rawURL string) (core.Producer, error) {
client.Close(err) client.Close(err)
} }
// On HEVC, use DataChannel to receive video/audio
if client.isHEVC { if client.isHEVC {
// Create a new DataChannel
maxRetransmits := uint16(5) maxRetransmits := uint16(5)
ordered := true ordered := true
client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{
@@ -212,18 +214,22 @@ func Dial(rawURL string) (core.Producer, error) {
Ordered: &ordered, Ordered: &ordered,
}) })
// Set up data channel handler // DataChannel receives two types of messages:
// 1. String messages: Control messages (codec, recv)
// 2. Binary messages: RTP packets with video/audio
client.dc.OnMessage(func(msg pion.DataChannelMessage) { client.dc.OnMessage(func(msg pion.DataChannelMessage) {
if msg.IsString { if msg.IsString {
// Handle control messages (codec, recv, etc.)
if connected, err := client.probe(msg); err != nil { if connected, err := client.probe(msg); err != nil {
client.Close(err) client.Close(err)
} else if connected { } else if connected {
client.connected.Done(nil) client.connected.Done(nil)
} }
} else { } else {
// Handle RTP packets - Route by SSRC retrieved from "recv" message
packet := &rtp.Packet{} packet := &rtp.Packet{}
if err := packet.Unmarshal(msg.Data); err != nil { if err := packet.Unmarshal(msg.Data); err != nil {
// skip // Skip invalid packets
return return
} }
@@ -339,6 +345,9 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece
return errors.New("webrtc: can't get track") return errors.New("webrtc: can't get track")
} }
// DISABLED: Speaker Protocol 312 command
// JavaScript client doesn't send this on first call either
// Only subsequent calls (when speakerChloron is set) send Protocol 312
// mqttClient := c.api.GetMqtt() // mqttClient := c.api.GetMqtt()
// if mqttClient != nil { // if mqttClient != nil {
// _ = mqttClient.SendSpeaker(1) // _ = mqttClient.SendSpeaker(1)
@@ -352,14 +361,16 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece
_ = localTrack.WriteRTP(payloadType, packet) _ = localTrack.WriteRTP(payloadType, packet)
} }
// Tuya cameras require specific frame sizes
// See: https://developer.tuya.com/en/docs/iot-device-dev/tuyaos-package-ipc-device?id=Kcn1px33iptn2#title-29
switch track.Codec.Name { switch track.Codec.Name {
case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML:
// https://developer.tuya.com/en/docs/iot-device-dev/tuyaos-package-ipc-device?id=Kcn1px33iptn2#title-29-Why%20can%E2%80%99t%20WebRTC%20play%20audio%3F
frameSize := 240 frameSize := 240
if track.Codec.Name == core.CodecPCM { if track.Codec.Name == core.CodecPCM {
frameSize = 560 frameSize = 560
} }
// Repack to required frame size
sender.Handler = pcm.RepackG711(false, frameSize, sender.Handler) sender.Handler = pcm.RepackG711(false, frameSize, sender.Handler)
sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler) sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler)
} }
@@ -463,6 +474,7 @@ func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) {
switch message.Type { switch message.Type {
case "codec": case "codec":
// Camera responded to our codec request - now request frame start
frameRequest, _ := json.Marshal(DataChannelMessage{ frameRequest, _ := json.Marshal(DataChannelMessage{
Type: "start", Type: "start",
Msg: "frame", Msg: "frame",
@@ -474,6 +486,8 @@ func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) {
} }
case "recv": case "recv":
// Camera sends SSRC values for video/audio streams
// We need these to route incoming RTP packets correctly
var recvMessage RecvMessage var recvMessage RecvMessage
if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil { if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil {
return false, err return false, err
@@ -484,6 +498,7 @@ func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) {
c.videoSSRC = &videoSSRC c.videoSSRC = &videoSSRC
c.audioSSRC = &audioSSRC c.audioSSRC = &audioSSRC
// Send "complete" to tell camera we're ready to receive RTP packets
completeMsg, _ := json.Marshal(DataChannelMessage{ completeMsg, _ := json.Marshal(DataChannelMessage{
Type: "complete", Type: "complete",
Msg: "", Msg: "",
+19 -7
View File
@@ -66,17 +66,17 @@ type AudioSkill struct {
} }
type VideoSkill struct { type VideoSkill struct {
StreamType int `json:"streamType"` // 2 = main stream (hd), 4 = sub stream (sd) StreamType int `json:"streamType"` // 2 = main stream (HD), 4 = sub stream (SD)
ProfileId string `json:"profileId,omitempty"` CodecType int `json:"codecType"` // 2 = H264, 4 = H265 (HEVC)
CodecType int `json:"codecType"` // 2 = H264, 4 = H265
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
SampleRate int `json:"sampleRate"` SampleRate int `json:"sampleRate"`
ProfileId string `json:"profileId,omitempty"`
} }
type Skill struct { type Skill struct {
WebRTC int `json:"webrtc"` WebRTC int `json:"webrtc"` // Bit flags: bit 4=speaker, bit 5=clarity, bit 6=record
LowPower int `json:"lowPower,omitempty"` LowPower int `json:"lowPower,omitempty"` // 1 = battery-powered camera
Audios []AudioSkill `json:"audios"` Audios []AudioSkill `json:"audios"`
Videos []VideoSkill `json:"videos"` Videos []VideoSkill `json:"videos"`
} }
@@ -128,6 +128,14 @@ func (c *TuyaClient) GetMqtt() *TuyaMqttClient {
return c.mqtt return c.mqtt
} }
// GetStreamType returns the Skill StreamType for the requested resolution
// Returns Skill values (2 or 4), not MQTT values (0 or 1)
// - "hd" → highest resolution streamType (usually 2 = mainStream)
// - "sd" → lowest resolution streamType (usually 4 = substream)
//
// These values must be mapped before sending to MQTT:
// - streamType 2 → MQTT stream_type 0
// - streamType 4 → MQTT stream_type 1
func (c *TuyaClient) GetStreamType(streamResolution string) int { func (c *TuyaClient) GetStreamType(streamResolution string) int {
// Default streamType if nothing is found // Default streamType if nothing is found
defaultStreamType := 1 defaultStreamType := 1
@@ -136,7 +144,7 @@ func (c *TuyaClient) GetStreamType(streamResolution string) int {
return defaultStreamType return defaultStreamType
} }
// Find the highest and lowest resolution // Find the highest and lowest resolution based on pixel count
var highestResType = defaultStreamType var highestResType = defaultStreamType
var highestRes = 0 var highestRes = 0
var lowestResType = defaultStreamType var lowestResType = defaultStreamType
@@ -169,10 +177,14 @@ func (c *TuyaClient) GetStreamType(streamResolution string) int {
} }
} }
// IsHEVC checks if the given streamType uses H265 (HEVC) codec
// HEVC cameras use DataChannel, H264 cameras use RTP tracks
// - codecType 4 = H265 (HEVC) → DataChannel mode
// - codecType 2 = H264 → Normal RTP mode
func (c *TuyaClient) IsHEVC(streamType int) bool { func (c *TuyaClient) IsHEVC(streamType int) bool {
for _, video := range c.skill.Videos { for _, video := range c.skill.Videos {
if video.StreamType == streamType { if video.StreamType == streamType {
return video.CodecType == 4 return video.CodecType == 4 // 4 = H265/HEVC
} }
} }
+27 -19
View File
@@ -52,9 +52,9 @@ type MqttFrame struct {
type OfferFrame struct { type OfferFrame struct {
Mode string `json:"mode"` Mode string `json:"mode"`
Sdp string `json:"sdp"` Sdp string `json:"sdp"`
StreamType int `json:"stream_type"` StreamType int `json:"stream_type"` // 0: mainStream(HD), 1: substream(SD)
Auth string `json:"auth"` Auth string `json:"auth"`
DatachannelEnable bool `json:"datachannel_enable"` DatachannelEnable bool `json:"datachannel_enable"` // true for HEVC, false for H264
Token []ICEServer `json:"token"` Token []ICEServer `json:"token"`
} }
@@ -165,8 +165,11 @@ func (c *TuyaMqttClient) Stop() {
c.closed = true c.closed = true
} }
// WakeUp sends a wake-up signal to battery-powered cameras (LowPower mode).
// The camera wakes up and starts responding immediately - we don't wait for dps[149].
// Note: LowPower cameras sleep after ~3 minutes of inactivity.
func (c *TuyaMqttClient) WakeUp(localKey string) error { func (c *TuyaMqttClient) WakeUp(localKey string) error {
// Calculate CRC32 of localKey // Calculate CRC32 of localKey as wake-up payload
crc := crc32.ChecksumIEEE([]byte(localKey)) crc := crc32.ChecksumIEEE([]byte(localKey))
// Convert to hex string // Convert to hex string
@@ -189,7 +192,8 @@ func (c *TuyaMqttClient) WakeUp(localKey string) error {
return fmt.Errorf("failed to publish wake-up message: %w", token.Error()) return fmt.Errorf("failed to publish wake-up message: %w", token.Error())
} }
// Subscribe to lowPower topic: smart/decrypt/in/{deviceId} // Subscribe to lowPower topic to receive dps[149] status updates
// (we don't wait for this signal - camera responds immediately)
lowPowerTopic := fmt.Sprintf("smart/decrypt/in/%s", c.deviceId) lowPowerTopic := fmt.Sprintf("smart/decrypt/in/%s", c.deviceId)
if token := c.client.Subscribe(lowPowerTopic, 1, c.onLowPowerMessage); token.Wait() && token.Error() != nil { if token := c.client.Subscribe(lowPowerTopic, 1, c.onLowPowerMessage); token.Wait() && token.Error() != nil {
return fmt.Errorf("failed to subscribe to lowPower topic: %w", token.Error()) return fmt.Errorf("failed to subscribe to lowPower topic: %w", token.Error())
@@ -199,6 +203,7 @@ func (c *TuyaMqttClient) WakeUp(localKey string) error {
} }
func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error { func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error {
// Map Skill StreamType to MQTT stream_type values
// streamType comes from GetStreamType() and uses Skill StreamType values: // streamType comes from GetStreamType() and uses Skill StreamType values:
// - mainStream = 2 (HD) // - mainStream = 2 (HD)
// - substream = 4 (SD) // - substream = 4 (SD)
@@ -220,7 +225,7 @@ func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamTy
Sdp: sdp, Sdp: sdp,
StreamType: mqttStreamType, StreamType: mqttStreamType,
Auth: c.auth, Auth: c.auth,
DatachannelEnable: isHEVC, DatachannelEnable: isHEVC, // must be true for HEVC
Token: c.iceServers, Token: c.iceServers,
}) })
} }
@@ -233,30 +238,32 @@ func (c *TuyaMqttClient) SendCandidate(candidate string) error {
} }
func (c *TuyaMqttClient) SendResolution(resolution int) error { func (c *TuyaMqttClient) SendResolution(resolution int) error {
// isClaritySupperted := (c.webrtcVersion & (1 << 5)) != 0 // Check if camera supports clarity switching
// if !isClaritySupperted { isClaritySupported := (c.webrtcVersion & (1 << 5)) != 0
// return nil if !isClaritySupported {
// } return nil
}
// Protocol 312 is used for clarity
return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{ return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{
Mode: "webrtc", Mode: "webrtc",
Value: resolution, Value: resolution, // 0: HD, 1: SD
}) })
} }
func (c *TuyaMqttClient) SendSpeaker(speaker int) error { func (c *TuyaMqttClient) SendSpeaker(speaker int) error {
// Protocol 312 is used for speaker if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{
return c.sendMqttMessage("speaker", 312, "", SpeakerFrame{
Mode: "webrtc", Mode: "webrtc",
Value: speaker, Value: speaker, // 0: off, 1: on
}) }); err != nil {
return err
}
// if err := c.speakerWaiter.Wait(); err != nil { // Wait for camera response
// return fmt.Errorf("speaker wait failed: %w", err) if err := c.speakerWaiter.Wait(); err != nil {
// } return fmt.Errorf("speaker wait failed: %w", err)
}
// return nil return nil
} }
func (c *TuyaMqttClient) SendDisconnect() error { func (c *TuyaMqttClient) SendDisconnect() error {
@@ -281,6 +288,7 @@ func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) {
return return
} }
// Filter by session ID to prevent processing messages from other sessions
if rmqtt.Data.Header.SessionID != c.sessionId { if rmqtt.Data.Header.SessionID != c.sessionId {
return return
} }