diff --git a/README.md b/README.md index ee647bf3..f27afeb0 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [Source: DVRIP](#source-dvrip) * [Source: Tapo](#source-tapo) * [Source: Kasa](#source-kasa) + * [Source: Tuya](#source-tuya) * [Source: GoPro](#source-gopro) * [Source: Ivideon](#source-ivideon) * [Source: Hass](#source-hass) @@ -204,6 +205,7 @@ Available source types: - [dvrip](#source-dvrip) - streaming from DVR-IP NVR - [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support - [ring](#source-ring) - Ring cameras with [two way audio](#two-way-audio) support +- [tuya](#source-tuya) - Tuya cameras with [two way audio](#two-way-audio) support - [kasa](#source-tapo) - TP-Link Kasa cameras - [gopro](#source-gopro) - GoPro cameras - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service @@ -226,6 +228,7 @@ Supported sources: - [Roborock vacuums](#source-roborock) models with cameras - [Exec](#source-exec) audio on server - [Ring](#source-ring) cameras +- [Tuya](#source-tuya) cameras - [Any Browser](#incoming-browser) as IP-camera Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). @@ -575,6 +578,43 @@ streams: Tested: KD110, KC200, KC401, KC420WS, EC71. +#### Source: Tuya + +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. + +**Tuya Smart API (recommended)**: +- Cameras can be discovered through the go2rtc web interface via Tuya Smart account (Add > Tuya > Select region and fill in email and password > Login). +- **Smart Life accounts are not supported**, you need to create a Tuya Smart account. If the cameras are already added to the Smart Life app, you need to remove them and add them again to the Tuya Smart app. + +**Tuya Cloud API**: +- Requires setting up a cloud project in the Tuya Developer Platform. +- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). +- Please ensure that you have subscribed to the `IoT Video Live Stream` service (Free Trial) in the Tuya Developer Platform, otherwise the stream will not work (Tuya Developer Platform > Service API > Authorize > IoT Video Live Stream). + +**Configuring the stream:** +- Use `resolution` parameter to select the stream (not all cameras support `hd` stream through WebRTC even if the camera has it): + - `hd` - HD stream (default) + - `sd` - SD stream + +```yaml +streams: + # Tuya Smart API: WebRTC main stream (use Add > Tuya to discover the URL) + tuya_main: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX + + # Tuya Smart API: WebRTC sub stream (use Add > Tuya to discover the URL) + tuya_sub: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd + + # Tuya Cloud API: WebRTC main stream + tuya_webrtc: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX + + # Tuya Cloud API: WebRTC sub stream + tuya_webrtc_sd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd +``` + #### Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* diff --git a/go.mod b/go.mod index 27293598..d1bc1971 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( require ( github.com/asticode/go-astikit v0.57.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/pion/datachannel v1.5.10 // indirect diff --git a/go.sum b/go.sum index 00ab85d6..f2134ca0 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o= +github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk= github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso= github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go new file mode 100644 index 00000000..9dcf2721 --- /dev/null +++ b/internal/tuya/tuya.go @@ -0,0 +1,248 @@ +package tuya + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tuya" +) + +func Init() { + streams.HandleFunc("tuya", func(source string) (core.Producer, error) { + return tuya.Dial(source) + }) + + api.HandleFunc("api/tuya", apiTuya) +} + +func apiTuya(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + region := query.Get("region") + email := query.Get("email") + password := query.Get("password") + + if email == "" || password == "" || region == "" { + http.Error(w, "email, password and region are required", http.StatusBadRequest) + return + } + + var tuyaRegion *tuya.Region + for _, r := range tuya.AvailableRegions { + if r.Host == region { + tuyaRegion = &r + break + } + } + + if tuyaRegion == nil { + http.Error(w, fmt.Sprintf("invalid region: %s", region), http.StatusBadRequest) + return + } + + httpClient := tuya.CreateHTTPClientWithSession() + + _, err := login(httpClient, tuyaRegion.Host, email, password, tuyaRegion.Continent) + if err != nil { + http.Error(w, fmt.Sprintf("login failed: %v", err), http.StatusInternalServerError) + return + } + + tuyaAPI, err := tuya.NewTuyaSmartApiClient( + httpClient, + tuyaRegion.Host, + email, + password, + "", + ) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var devices []tuya.Device + + homes, _ := tuyaAPI.GetHomeList() + if homes != nil && len(homes.Result) > 0 { + for _, home := range homes.Result { + roomList, err := tuyaAPI.GetRoomList(strconv.Itoa(home.Gid)) + if err != nil { + continue + } + + for _, room := range roomList.Result { + for _, device := range room.DeviceList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + } + + sharedHomes, _ := tuyaAPI.GetSharedHomeList() + if sharedHomes != nil && len(sharedHomes.Result.SecurityWebCShareInfoList) > 0 { + for _, sharedHome := range sharedHomes.Result.SecurityWebCShareInfoList { + for _, device := range sharedHome.DeviceInfoList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + + if len(devices) == 0 { + http.Error(w, "no cameras found", http.StatusNotFound) + return + } + + var items []*api.Source + for _, device := range devices { + cleanQuery := url.Values{} + cleanQuery.Set("device_id", device.DeviceId) + cleanQuery.Set("email", email) + cleanQuery.Set("password", password) + url := fmt.Sprintf("tuya://%s?%s", tuyaRegion.Host, cleanQuery.Encode()) + + items = append(items, &api.Source{ + Name: device.DeviceName, + URL: url, + }) + } + + api.ResponseSources(w, items) +} + +func login(client *http.Client, serverHost, email, password, countryCode string) (*tuya.LoginResult, error) { + tokenResp, err := getLoginToken(client, serverHost, email, countryCode) + if err != nil { + return nil, err + } + + encryptedPassword, err := tuya.EncryptPassword(password, tokenResp.Result.PbKey) + if err != nil { + return nil, fmt.Errorf("failed to encrypt password: %v", err) + } + + var loginResp *tuya.PasswordLoginResponse + var url string + + loginReq := tuya.PasswordLoginRequest{ + CountryCode: countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if tuya.IsEmailAddress(email) { + url = fmt.Sprintf("https://%s/api/private/email/login", serverHost) + loginReq.Email = email + } else { + url = fmt.Sprintf("https://%s/api/private/phone/login", serverHost) + loginReq.Mobile = email + } + + loginResp, err = performLogin(client, url, loginReq, serverHost) + + if err != nil { + return nil, err + } + + if !loginResp.Success { + return nil, errors.New(loginResp.ErrorMsg) + } + + return &loginResp.Result, nil +} + +func getLoginToken(client *http.Client, serverHost, username, countryCode string) (*tuya.LoginTokenResponse, error) { + url := fmt.Sprintf("https://%s/api/login/token", serverHost) + + tokenReq := tuya.LoginTokenRequest{ + CountryCode: countryCode, + Username: username, + IsUid: false, + } + + jsonData, err := json.Marshal(tokenReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var tokenResp tuya.LoginTokenResponse + if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, err + } + + if !tokenResp.Success { + return nil, errors.New("tuya: " + tokenResp.Msg) + } + + return &tokenResp, nil +} + +func performLogin(client *http.Client, url string, loginReq tuya.PasswordLoginRequest, serverHost string) (*tuya.PasswordLoginResponse, error) { + jsonData, err := json.Marshal(loginReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var loginResp tuya.PasswordLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return nil, err + } + + return &loginResp, nil +} + +func containsDevice(devices []tuya.Device, deviceID string) bool { + for _, device := range devices { + if device.DeviceId == deviceID { + return true + } + } + return false +} diff --git a/main.go b/main.go index c548e99f..bdf94a6a 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/srtp" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/tapo" + "github.com/AlexxIT/go2rtc/internal/tuya" "github.com/AlexxIT/go2rtc/internal/v4l2" "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" @@ -96,6 +97,7 @@ func main() { {"ring", ring.Init}, {"roborock", roborock.Init}, {"tapo", tapo.Init}, + {"tuya", tuya.Init}, {"yandex", yandex.Init}, // Helper modules {"debug", debug.Init}, diff --git a/pkg/tuya/README.md b/pkg/tuya/README.md new file mode 100644 index 00000000..f1936404 --- /dev/null +++ b/pkg/tuya/README.md @@ -0,0 +1,9 @@ +## Useful links + +- https://developer.tuya.com/en/docs/iot/webrtc?id=Kacsd4x2hl0se +- https://github.com/tuya/webrtc-demo-go +- https://github.com/bacco007/HomeAssistantConfig/blob/master/custom_components/xtend_tuya/multi_manager/tuya_iot/ipc/webrtc/xt_tuya_iot_webrtc_manager.py +- https://github.com/tuya/tuya-device-sharing-sdk +- https://github.com/make-all/tuya-local/blob/main/custom_components/tuya_local/cloud.py +- https://ipc-us.ismartlife.me/ +- https://protect-us.ismartlife.me/ \ No newline at end of file diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go new file mode 100644 index 00000000..3043a8d2 --- /dev/null +++ b/pkg/tuya/client.go @@ -0,0 +1,555 @@ +package tuya + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/pion/rtp" + pion "github.com/pion/webrtc/v4" +) + +type Client struct { + api TuyaAPI + conn *webrtc.Conn + pc *pion.PeerConnection + connected core.Waiter + closed bool + + // HEVC only: + dc *pion.DataChannel + videoSSRC *uint32 + audioSSRC *uint32 + streamType int + isHEVC bool + handlersMu sync.RWMutex + handlers map[uint32]func(*rtp.Packet) +} + +type DataChannelMessage struct { + Type string `json:"type"` // "codec", "start", "recv", "complete" + Msg string `json:"msg"` +} + +// RecvMessage contains SSRC values for video/audio streams +type RecvMessage struct { + Video struct { + SSRC uint32 `json:"ssrc"` + } `json:"video"` + Audio struct { + SSRC uint32 `json:"ssrc"` + } `json:"audio"` +} + +func Dial(rawURL string) (core.Producer, error) { + escapedURL := strings.ReplaceAll(rawURL, "#", "%23") + u, err := url.Parse(escapedURL) + if err != nil { + return nil, err + } + + query := u.Query() + + // Tuya Smart API + email := query.Get("email") + password := query.Get("password") + + // Tuya Cloud API + uid := query.Get("uid") + clientId := query.Get("client_id") + clientSecret := query.Get("client_secret") + + // Shared params + deviceId := query.Get("device_id") + + // Stream params + streamResolution := query.Get("resolution") + + useSmartApi := deviceId != "" && email != "" && password != "" + useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != "" + + if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { + streamResolution = "hd" + } + + if !useSmartApi && !useCloudApi { + return nil, errors.New("tuya: wrong query params") + } + + client := &Client{ + handlers: make(map[uint32]func(*rtp.Packet)), + } + + if useSmartApi { + if client.api, err = NewTuyaSmartApiClient(nil, u.Hostname(), email, password, deviceId); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + } else { + if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + } + + if err := client.api.Init(); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + + client.streamType = client.api.GetStreamType(streamResolution) + client.isHEVC = client.api.IsHEVC(client.streamType) + + // Create a new PeerConnection + conf := pion.Configuration{ + ICEServers: client.api.GetICEServers(), + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyMaxBundle, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.Close(err) + return nil, err + } + + client.pc, err = api.NewPeerConnection(conf) + if err != nil { + client.Close(err) + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // Create new WebRTC connection + client.conn = webrtc.NewConn(client.pc) + client.conn.FormatName = "tuya/webrtc" + client.conn.Mode = core.ModeActiveProducer + client.conn.Protocol = "mqtt" + + mqttClient := client.api.GetMqtt() + if mqttClient == nil { + err = errors.New("tuya: no mqtt client") + client.Close(err) + return nil, err + } + + // Set up MQTT handlers + mqttClient.handleAnswer = func(answer AnswerFrame) { + // fmt.Printf("tuya: answer: %s\n", answer.Sdp) + + desc := pion.SessionDescription{ + Type: pion.SDPTypePranswer, + SDP: answer.Sdp, + } + + if err = client.pc.SetRemoteDescription(desc); err != nil { + client.Close(err) + return + } + + if err = client.conn.SetAnswer(answer.Sdp); err != nil { + client.Close(err) + return + } + + if client.isHEVC { + // Tuya responds with H264/90000 even for HEVC streams + // So we need to replace video codecs with HEVC ones from API + for _, media := range client.conn.Medias { + if media.Kind == core.KindVideo { + codecs := client.api.GetVideoCodecs() + if codecs != nil { + media.Codecs = codecs + } + } + } + + // Audio codecs from API as well + // Tuya responds with multiple audio codecs (PCMU, PCMA) + // But the quality is bad if we use PCMU and skill only has PCMA + for _, media := range client.conn.Medias { + if media.Kind == core.KindAudio { + codecs := client.api.GetAudioCodecs() + if codecs != nil { + media.Codecs = codecs + } + } + } + } + } + + mqttClient.handleCandidate = func(candidate CandidateFrame) { + // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) + + if candidate.Candidate != "" { + client.conn.AddCandidate(candidate.Candidate) + if err != nil { + client.Close(err) + } + } + } + + mqttClient.handleDisconnect = func() { + // fmt.Println("tuya: disconnect") + client.Close(errors.New("mqtt: disconnect")) + } + + mqttClient.handleError = func(err error) { + // fmt.Printf("tuya: error: %s\n", err.Error()) + client.Close(err) + } + + if client.isHEVC { + maxRetransmits := uint16(5) + ordered := true + client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ + MaxRetransmits: &maxRetransmits, + Ordered: &ordered, + }) + + // 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) { + if msg.IsString { + // Handle control messages (codec, recv, etc.) + if connected, err := client.probe(msg); err != nil { + client.Close(err) + } else if connected { + client.connected.Done(nil) + } + } else { + // Handle RTP packets - Route by SSRC retrieved from "recv" message + packet := &rtp.Packet{} + if err := packet.Unmarshal(msg.Data); err != nil { + // Skip invalid packets + return + } + + if handler, ok := client.getHandler(packet.SSRC); ok { + handler(packet) + } + } + }) + + client.dc.OnError(func(err error) { + // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) + client.Close(err) + }) + + client.dc.OnClose(func() { + // fmt.Println("tuya: datachannel closed") + client.Close(errors.New("datachannel: closed")) + }) + + client.dc.OnOpen(func() { + // fmt.Println("tuya: datachannel opened") + + codecRequest, _ := json.Marshal(DataChannelMessage{ + Type: "codec", + Msg: "", + }) + + if err := client.sendMessageToDataChannel(codecRequest); err != nil { + client.Close(fmt.Errorf("failed to send codec request: %w", err)) + } + }) + } + + // Set up pc handler + client.conn.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + if err := mqttClient.SendCandidate("a=" + msg.ToJSON().Candidate); err != nil { + client.Close(err) + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateNew: + break + case pion.PeerConnectionStateConnecting: + break + case pion.PeerConnectionStateConnected: + // On HEVC, wait for DataChannel to be opened and camera to send codec info + if !client.isHEVC { + if streamResolution == "hd" { + _ = mqttClient.SendResolution(0) + } + client.connected.Done(nil) + } + case pion.PeerConnectionStateClosed: + client.Close(errors.New("webrtc: " + msg.String())) + default: + // client.Close(errors.New("webrtc: " + msg.String())) + } + } + }) + + // Audio first, otherwise tuya will send corrupt sdp + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionSendRecv}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + } + + // Create offer + offer, err := client.conn.CreateOffer(medias) + if err != nil { + client.Close(err) + return nil, err + } + + // horter sdp, remove a=extmap... line, device ONLY allow 8KB json payload + // https://github.com/tuya/webrtc-demo-go/blob/04575054f18ccccb6bc9d82939dd46d449544e20/static/js/main.js#L224 + re := regexp.MustCompile(`\r\na=extmap[^\r\n]*`) + offer = re.ReplaceAllString(offer, "") + + // Send offer + if err := mqttClient.SendOffer(offer, streamResolution, client.streamType, client.isHEVC); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + sendOffer.Done(nil) + + // Wait for connection + if err = client.connected.Wait(); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + return client, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + localTrack := c.conn.GetSenderTrack(media.ID) + if localTrack == nil { + 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() + // if mqttClient != nil { + // _ = mqttClient.SendSpeaker(1) + // } + + payloadType := codec.PayloadType + + sender := core.NewSender(media, codec) + + switch track.Codec.Name { + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: + // Frame size affects audio delay with Tuya cameras: + // Browser sends standard 20ms frames (160 bytes for G.711), but this causes + // up to 4s delay on some Tuya cameras. Increasing to 240 bytes (30ms) reduces + // delay to ~2s. Higher values (320+ bytes) don't work and cause issues. + // Using 240 bytes (30ms) as optimal balance between latency and stability. + frameSize := 240 + + var buf []byte + var seq uint16 + var ts uint32 + + sender.Handler = func(packet *rtp.Packet) { + buf = append(buf, packet.Payload...) + + for len(buf) >= frameSize { + payload := buf[:frameSize] + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + PayloadType: payloadType, + SequenceNumber: seq, + Timestamp: ts, + SSRC: packet.SSRC, + }, + Payload: payload, + } + + seq++ + ts += uint32(frameSize) + buf = buf[frameSize:] + + c.conn.Send += pkt.MarshalSize() + _ = localTrack.WriteRTP(payloadType, pkt) + } + } + + default: + sender.Handler = func(packet *rtp.Packet) { + c.conn.Send += packet.MarshalSize() + _ = localTrack.WriteRTP(payloadType, packet) + } + } + + sender.HandleRTP(track) + c.conn.Senders = append(c.conn.Senders, sender) + + return nil +} + +func (c *Client) Start() error { + if len(c.conn.Receivers) == 0 { + return errors.New("tuya: no receivers") + } + + var video, audio *core.Receiver + for _, receiver := range c.conn.Receivers { + if receiver.Codec.IsVideo() { + video = receiver + } else if receiver.Codec.IsAudio() { + audio = receiver + } + } + + if c.videoSSRC != nil { + c.setHandler(*c.videoSSRC, func(packet *rtp.Packet) { + if video != nil { + video.WriteRTP(packet) + } + }) + } + + if c.audioSSRC != nil { + c.setHandler(*c.audioSSRC, func(packet *rtp.Packet) { + if audio != nil { + audio.WriteRTP(packet) + } + }) + } + + return c.conn.Start() +} + +func (c *Client) Stop() error { + if c.closed { + return nil + } + + c.closed = true + + c.clearHandlers() + + if c.conn != nil { + _ = c.conn.Stop() + } + + if c.api != nil { + c.api.Close() + } + + return nil +} + +func (c *Client) Close(err error) error { + c.connected.Done(err) + return c.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} + +func (c *Client) setHandler(ssrc uint32, handler func(*rtp.Packet)) { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + c.handlers[ssrc] = handler +} + +func (c *Client) getHandler(ssrc uint32) (func(*rtp.Packet), bool) { + c.handlersMu.RLock() + defer c.handlersMu.RUnlock() + handler, ok := c.handlers[ssrc] + return handler, ok +} + +func (c *Client) clearHandlers() { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + for ssrc := range c.handlers { + delete(c.handlers, ssrc) + } +} + +func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { + // fmt.Printf("[tuya] Received string message: %s\n", string(msg.Data)) + + var message DataChannelMessage + if err := json.Unmarshal([]byte(msg.Data), &message); err != nil { + return false, err + } + + switch message.Type { + case "codec": + // Camera responded to our codec request - now request frame start + frameRequest, _ := json.Marshal(DataChannelMessage{ + Type: "start", + Msg: "frame", + }) + + err := c.sendMessageToDataChannel(frameRequest) + if err != nil { + return false, err + } + + case "recv": + // Camera sends SSRC values for video/audio streams + // We need these to route incoming RTP packets correctly + var recvMessage RecvMessage + if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil { + return false, err + } + + videoSSRC := recvMessage.Video.SSRC + audioSSRC := recvMessage.Audio.SSRC + c.videoSSRC = &videoSSRC + c.audioSSRC = &audioSSRC + + // Send "complete" to tell camera we're ready to receive RTP packets + completeMsg, _ := json.Marshal(DataChannelMessage{ + Type: "complete", + Msg: "", + }) + + err := c.sendMessageToDataChannel(completeMsg) + if err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func (c *Client) sendMessageToDataChannel(message []byte) error { + if c.dc != nil { + // fmt.Printf("[tuya] sending message to data channel: %s\n", message) + return c.dc.Send(message) + } + + return nil +} diff --git a/pkg/tuya/cloud_api.go b/pkg/tuya/cloud_api.go new file mode 100644 index 00000000..c34d0fe4 --- /dev/null +++ b/pkg/tuya/cloud_api.go @@ -0,0 +1,322 @@ +package tuya + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" +) + +type Token struct { + UID string `json:"uid"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` +} + +type WebRTCConfigResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result WebRTCConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TokenResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result Token `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type OpenIoTHubConfigRequest struct { + UID string `json:"uid"` + UniqueID string `json:"unique_id"` + LinkType string `json:"link_type"` + Topics string `json:"topics"` +} + +type OpenIoTHubConfig struct { + Url string `json:"url"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` + SinkTopic struct { + IPC string `json:"ipc"` + } `json:"sink_topic"` + SourceSink struct { + IPC string `json:"ipc"` + } `json:"source_topic"` + ExpireTime int `json:"expire_time"` +} + +type OpenIoTHubConfigResponse struct { + Timestamp int `json:"t"` + Success bool `json:"success"` + Result OpenIoTHubConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TuyaCloudApiClient struct { + TuyaClient + uid string + clientId string + clientSecret string + accessToken string + refreshToken string + refreshingToken bool +} + +func NewTuyaCloudApiClient(baseUrl, uid, deviceId, clientId, clientSecret string) (*TuyaCloudApiClient, error) { + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaCloudApiClient{ + TuyaClient: TuyaClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + mqtt: mqttClient, + deviceId: deviceId, + expireTime: 0, + baseUrl: baseUrl, + }, + uid: uid, + clientId: clientId, + clientSecret: clientSecret, + refreshingToken: false, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaCloudApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + + return nil +} + +func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + if err := c.initToken(); err != nil { + return "", fmt.Errorf("failed to initialize token: %w", err) + } + + url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.baseUrl, c.deviceId) + + request := &AllocateRequest{ + Type: streamType, + } + + body, err := c.request("POST", url, request) + if err != nil { + return "", err + } + + var allocResponse AllocateResponse + err = json.Unmarshal(body, &allocResponse) + if err != nil { + return "", err + } + + if !allocResponse.Success { + return "", errors.New(allocResponse.Msg) + } + + return allocResponse.Result.URL, nil +} + +func (c *TuyaCloudApiClient) initToken() (err error) { + if c.refreshingToken { + return nil + } + + now := time.Now().Unix() + if (c.expireTime - 60) > now { + return nil + } + + c.refreshingToken = true + + url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.baseUrl) + + c.accessToken = "" + c.refreshToken = "" + + body, err := c.request("GET", url, nil) + if err != nil { + return err + } + + var tokenResponse TokenResponse + err = json.Unmarshal(body, &tokenResponse) + if err != nil { + return err + } + + if !tokenResponse.Success { + return errors.New(tokenResponse.Msg) + } + + c.accessToken = tokenResponse.Result.AccessToken + c.refreshToken = tokenResponse.Result.RefreshToken + c.expireTime = tokenResponse.Timestamp + tokenResponse.Result.ExpireTime + c.refreshingToken = false + + return nil +} + +func (c *TuyaCloudApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.baseUrl, c.uid, c.deviceId) + + body, err := c.request("GET", url, nil) + if err != nil { + return nil, err + } + + var webRTCConfigResponse WebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, fmt.Errorf(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + // Store LocalKey (not sure if cloud api provides this, but we need it for low power cameras) + c.localKey = webRTCConfigResponse.Result.LocalKey + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &webRTCConfigResponse.Result, nil +} + +func (c *TuyaCloudApiClient) loadHubConfig() (config *MQTTConfig, err error) { + url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.baseUrl) + + request := &OpenIoTHubConfigRequest{ + UID: c.uid, + UniqueID: uuid.New().String(), + LinkType: "mqtt", + Topics: "ipc", + } + + body, err := c.request("POST", url, request) + if err != nil { + return nil, err + } + + var openIoTHubConfigResponse OpenIoTHubConfigResponse + err = json.Unmarshal(body, &openIoTHubConfigResponse) + if err != nil { + return nil, err + } + + if !openIoTHubConfigResponse.Success { + return nil, fmt.Errorf(openIoTHubConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: openIoTHubConfigResponse.Result.Url, + Username: openIoTHubConfigResponse.Result.Username, + Password: openIoTHubConfigResponse.Result.Password, + ClientID: openIoTHubConfigResponse.Result.ClientID, + PublishTopic: openIoTHubConfigResponse.Result.SinkTopic.IPC, + SubscribeTopic: openIoTHubConfigResponse.Result.SourceSink.IPC, + }, nil +} + +func (c *TuyaCloudApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + ts := time.Now().UnixNano() / 1000000 + sign := c.calBusinessSign(ts) + + req.Header.Set("Accept", "*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Access-Control-Allow-Origin", "*") + req.Header.Set("Access-Control-Allow-Methods", "*") + req.Header.Set("Access-Control-Allow-Headers", "*") + req.Header.Set("mode", "no-cors") + req.Header.Set("client_id", c.clientId) + req.Header.Set("access_token", c.accessToken) + req.Header.Set("sign", sign) + req.Header.Set("t", strconv.FormatInt(ts, 10)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} + +func (c *TuyaCloudApiClient) calBusinessSign(ts int64) string { + data := fmt.Sprintf("%s%s%s%d", c.clientId, c.accessToken, c.clientSecret, ts) + val := md5.Sum([]byte(data)) + res := fmt.Sprintf("%X", val) + return res +} diff --git a/pkg/tuya/helper.go b/pkg/tuya/helper.go new file mode 100644 index 00000000..7c9eb410 --- /dev/null +++ b/pkg/tuya/helper.go @@ -0,0 +1,69 @@ +package tuya + +import ( + "crypto/md5" + cryptoRand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "net/http" + "net/http/cookiejar" + "regexp" + "time" + + "golang.org/x/net/publicsuffix" +) + +func EncryptPassword(password, pbKey string) (string, error) { + // Hash password with MD5 + hasher := md5.New() + hasher.Write([]byte(password)) + hashedPassword := hex.EncodeToString(hasher.Sum(nil)) + + // Decode PEM public key + block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + pbKey + "\n-----END PUBLIC KEY-----")) + if block == nil { + return "", errors.New("failed to decode PEM block") + } + + pubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", err + } + + rsaPubKey, ok := pubKey.(*rsa.PublicKey) + if !ok { + return "", errors.New("not an RSA public key") + } + + // Encrypt with RSA + encrypted, err := rsa.EncryptPKCS1v15(cryptoRand.Reader, rsaPubKey, []byte(hashedPassword)) + if err != nil { + return "", err + } + + // Convert to hex string + return hex.EncodeToString(encrypted), nil +} + +func IsEmailAddress(input string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(input) +} + +func CreateHTTPClientWithSession() *http.Client { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + if err != nil { + return nil + } + + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + } +} diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go new file mode 100644 index 00000000..25ba0ddd --- /dev/null +++ b/pkg/tuya/interface.go @@ -0,0 +1,270 @@ +package tuya + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/core" + pionWebrtc "github.com/pion/webrtc/v4" +) + +type TuyaAPI interface { + GetMqtt() *TuyaMqttClient + + GetStreamType(streamResolution string) int + IsHEVC(streamType int) bool + + GetVideoCodecs() []*core.Codec + GetAudioCodecs() []*core.Codec + + GetStreamUrl(streamUrl string) (string, error) + GetICEServers() []pionWebrtc.ICEServer + + Init() error + Close() +} + +type TuyaClient struct { + TuyaAPI + + httpClient *http.Client + mqtt *TuyaMqttClient + baseUrl string + expireTime int64 + deviceId string + localKey string + skill *Skill + iceServers []pionWebrtc.ICEServer +} + +type AudioAttributes struct { + CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way + HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker +} + +type ICEServer struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type WebICE struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +type P2PConfig struct { + Ices []ICEServer `json:"ices"` +} + +type AudioSkill struct { + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` +} + +type VideoSkill struct { + StreamType int `json:"streamType"` // 2 = main stream (HD), 4 = sub stream (SD) + CodecType int `json:"codecType"` // 2 = H264, 4 = H265 (HEVC) + Width int `json:"width"` + Height int `json:"height"` + SampleRate int `json:"sampleRate"` + ProfileId string `json:"profileId,omitempty"` +} + +type Skill struct { + WebRTC int `json:"webrtc"` // Bit flags: bit 4=speaker, bit 5=clarity, bit 6=record + LowPower int `json:"lowPower,omitempty"` // 1 = battery-powered camera + Audios []AudioSkill `json:"audios"` + Videos []VideoSkill `json:"videos"` +} + +type WebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audio_attributes"` + Auth string `json:"auth"` + ID string `json:"id"` + LocalKey string `json:"local_key,omitempty"` + MotoID string `json:"moto_id"` + P2PConfig P2PConfig `json:"p2p_config"` + ProtocolVersion string `json:"protocol_version"` + Skill string `json:"skill"` + SupportsWebRTCRecord bool `json:"supports_webrtc_record"` + SupportsWebRTC bool `json:"supports_webrtc"` + VedioClaritiy int `json:"vedio_clarity"` + VideoClaritiy int `json:"video_clarity"` + VideoClarities []int `json:"video_clarities"` +} + +type MQTTConfig struct { + Url string `json:"url"` + PublishTopic string `json:"publish_topic"` + SubscribeTopic string `json:"subscribe_topic"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` +} + +type Allocate struct { + URL string `json:"url"` +} + +type AllocateRequest struct { + Type string `json:"type"` +} + +type AllocateResponse struct { + Success bool `json:"success"` + Result Allocate `json:"result"` + Msg string `json:"msg,omitempty"` +} + +func (c *TuyaClient) GetICEServers() []pionWebrtc.ICEServer { + return c.iceServers +} + +func (c *TuyaClient) GetMqtt() *TuyaMqttClient { + 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 { + // Default streamType if nothing is found + defaultStreamType := 1 + + if c.skill == nil || len(c.skill.Videos) == 0 { + return defaultStreamType + } + + // Find the highest and lowest resolution based on pixel count + var highestResType = defaultStreamType + var highestRes = 0 + var lowestResType = defaultStreamType + var lowestRes = 0 + + for _, video := range c.skill.Videos { + res := video.Width * video.Height + + // Highest Resolution + if res > highestRes { + highestRes = res + highestResType = video.StreamType + } + + // Lower Resolution (or first if not set yet) + if lowestRes == 0 || res < lowestRes { + lowestRes = res + lowestResType = video.StreamType + } + } + + // Return the streamType based on the selection + switch streamResolution { + case "hd": + return highestResType + case "sd": + return lowestResType + default: + return defaultStreamType + } +} + +// 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 { + for _, video := range c.skill.Videos { + if video.StreamType == streamType { + return video.CodecType == 4 // 4 = H265/HEVC + } + } + + return false +} + +func (c *TuyaClient) GetVideoCodecs() []*core.Codec { + if len(c.skill.Videos) > 0 { + codecs := make([]*core.Codec, 0) + + for _, video := range c.skill.Videos { + name := core.CodecH264 + if c.IsHEVC(video.StreamType) { + name = core.CodecH265 + } + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(video.SampleRate), + } + + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) GetAudioCodecs() []*core.Codec { + if len(c.skill.Audios) > 0 { + codecs := make([]*core.Codec, 0) + + for _, audio := range c.skill.Audios { + name := getAudioCodecName(&audio) + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + } + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) Close() { + c.mqtt.Stop() + c.httpClient.CloseIdleConnections() +} + +// https://protect-us.ismartlife.me/ +func getAudioCodecName(audioSkill *AudioSkill) string { + switch audioSkill.CodecType { + // case 100: + // return "ADPCM" + case 101: + return core.CodecPCML + case 102, 103, 104: + return core.CodecAAC + case 105: + return core.CodecPCMU + case 106: + return core.CodecPCMA + // case 107: + // return "G726-32" + // case 108: + // return "SPEEX" + case 109: + return core.CodecMP3 + default: + return core.CodecPCML + } +} diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go new file mode 100644 index 00000000..5f64ef48 --- /dev/null +++ b/pkg/tuya/mqtt.go @@ -0,0 +1,436 @@ +package tuya + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +type TuyaMqttClient struct { + client mqtt.Client + waiter core.Waiter + wakeupWaiter core.Waiter + speakerWaiter core.Waiter + publishTopic string + subscribeTopic string + auth string + iceServers []ICEServer + uid string + motoId string + deviceId string + sessionId string + closed bool + webrtcVersion int + handleAnswer func(answer AnswerFrame) + handleCandidate func(candidate CandidateFrame) + handleDisconnect func() + handleError func(err error) +} + +type MqttFrameHeader struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + SubDevID string `json:"sub_dev_id"` + SessionID string `json:"sessionid"` + MotoID string `json:"moto_id"` + TransactionID string `json:"tid"` +} + +type MqttFrame struct { + Header MqttFrameHeader `json:"header"` + Message json.RawMessage `json:"msg"` +} + +type OfferFrame struct { + Mode string `json:"mode"` + Sdp string `json:"sdp"` + StreamType int `json:"stream_type"` // 0: mainStream(HD), 1: substream(SD) + Auth string `json:"auth"` + DatachannelEnable bool `json:"datachannel_enable"` // true for HEVC, false for H264 + Token []ICEServer `json:"token"` +} + +type AnswerFrame struct { + Mode string `json:"mode"` + Sdp string `json:"sdp"` +} + +type CandidateFrame struct { + Mode string `json:"mode"` + Candidate string `json:"candidate"` +} + +type ResolutionFrame struct { + Mode string `json:"mode"` + Value int `json:"cmdValue"` // 0: HD, 1: SD +} + +type SpeakerFrame struct { + Mode string `json:"mode"` + Value int `json:"cmdValue"` // 0: off, 1: on +} + +type DisconnectFrame struct { + Mode string `json:"mode"` +} + +type MqttLowPowerMessage struct { + Protocol int `json:"protocol"` + T int `json:"t"` + S int `json:"s,omitempty"` + Type string `json:"type,omitempty"` + Data struct { + DevID string `json:"devId,omitempty"` + Online bool `json:"online,omitempty"` + LastOnlineChangeTime int64 `json:"lastOnlineChangeTime,omitempty"` + GwID string `json:"gwId,omitempty"` + Cmd string `json:"cmd,omitempty"` + Dps map[string]interface{} `json:"dps,omitempty"` + } `json:"data"` +} + +type MqttMessage struct { + Protocol int `json:"protocol"` + Pv string `json:"pv"` + T int64 `json:"t"` + Data MqttFrame `json:"data"` +} + +func NewTuyaMqttClient(deviceId string) *TuyaMqttClient { + return &TuyaMqttClient{ + deviceId: deviceId, + sessionId: core.RandString(6, 62), + waiter: core.Waiter{}, + wakeupWaiter: core.Waiter{}, + } +} + +func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig, webrtcVersion int) error { + c.webrtcVersion = webrtcVersion + c.motoId = webrtcConfig.MotoID + c.auth = webrtcConfig.Auth + c.iceServers = webrtcConfig.P2PConfig.Ices + + c.publishTopic = hubConfig.PublishTopic + c.subscribeTopic = hubConfig.SubscribeTopic + + c.publishTopic = strings.Replace(c.publishTopic, "moto_id", c.motoId, 1) + c.publishTopic = strings.Replace(c.publishTopic, "{device_id}", c.deviceId, 1) + + parts := strings.Split(c.subscribeTopic, "/") + c.uid = parts[3] + + opts := mqtt.NewClientOptions().AddBroker(hubConfig.Url). + SetClientID(hubConfig.ClientID). + SetUsername(hubConfig.Username). + SetPassword(hubConfig.Password). + SetOnConnectHandler(c.onConnect). + SetAutoReconnect(true). + SetMaxReconnectInterval(30 * time.Second). + SetConnectTimeout(30 * time.Second). + SetKeepAlive(60 * time.Second). + SetPingTimeout(20 * time.Second) + + c.client = mqtt.NewClient(opts) + + if token := c.client.Connect(); token.Wait() && token.Error() != nil { + return token.Error() + } + + if err := c.waiter.Wait(); err != nil { + return err + } + + return nil +} + +func (c *TuyaMqttClient) Stop() { + c.waiter.Done(errors.New("mqtt: stopped")) + c.wakeupWaiter.Done(errors.New("mqtt: stopped")) + c.speakerWaiter.Done(errors.New("mqtt: stopped")) + + if c.client != nil { + _ = c.SendDisconnect() + c.client.Disconnect(100) + } + + 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 { + // Calculate CRC32 of localKey as wake-up payload + crc := crc32.ChecksumIEEE([]byte(localKey)) + + // Convert to hex string + hexStr := fmt.Sprintf("%08x", crc) + + // Convert hex string to byte array (2 chars at a time) + payload := make([]byte, len(hexStr)/2) + for i := 0; i < len(hexStr); i += 2 { + b, err := hex.DecodeString(hexStr[i : i+2]) + if err != nil { + return fmt.Errorf("failed to decode hex: %w", err) + } + payload[i/2] = b[0] + } + + // Publish to wake-up topic: m/w/{deviceId} + wakeUpTopic := fmt.Sprintf("m/w/%s", c.deviceId) + token := c.client.Publish(wakeUpTopic, 1, false, payload) + if token.Wait() && token.Error() != nil { + return fmt.Errorf("failed to publish wake-up message: %w", token.Error()) + } + + // 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) + 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 nil +} + +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: + // - mainStream = 2 (HD) + // - substream = 4 (SD) + // + // But MQTT expects mapped stream_type values: + // - mainStream (2) → stream_type: 0 + // - substream (4) → stream_type: 1 + + mqttStreamType := streamType + switch streamType { + case 2: + mqttStreamType = 0 // mainStream (HD) + case 4: + mqttStreamType = 1 // substream (SD) + } + + return c.sendMqttMessage("offer", 302, "", OfferFrame{ + Mode: "webrtc", + Sdp: sdp, + StreamType: mqttStreamType, + Auth: c.auth, + DatachannelEnable: isHEVC, // must be true for HEVC + Token: c.iceServers, + }) +} + +func (c *TuyaMqttClient) SendCandidate(candidate string) error { + return c.sendMqttMessage("candidate", 302, "", CandidateFrame{ + Mode: "webrtc", + Candidate: candidate, + }) +} + +func (c *TuyaMqttClient) SendResolution(resolution int) error { + // Check if camera supports clarity switching + isClaritySupported := (c.webrtcVersion & (1 << 5)) != 0 + if !isClaritySupported { + return nil + } + + return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{ + Mode: "webrtc", + Value: resolution, // 0: HD, 1: SD + }) +} + +func (c *TuyaMqttClient) SendSpeaker(speaker int) error { + if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ + Mode: "webrtc", + Value: speaker, // 0: off, 1: on + }); err != nil { + return err + } + + // Wait for camera response + if err := c.speakerWaiter.Wait(); err != nil { + return fmt.Errorf("speaker wait failed: %w", err) + } + + return nil +} + +func (c *TuyaMqttClient) SendDisconnect() error { + return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ + Mode: "webrtc", + }) +} + +func (c *TuyaMqttClient) onConnect(client mqtt.Client) { + if token := client.Subscribe(c.subscribeTopic, 1, c.onMessage); token.Wait() && token.Error() != nil { + c.waiter.Done(token.Error()) + return + } + + c.waiter.Done(nil) +} + +func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) { + var rmqtt MqttMessage + if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil { + c.onError(err) + return + } + + // Filter by session ID to prevent processing messages from other sessions + if rmqtt.Data.Header.SessionID != c.sessionId { + return + } + + switch rmqtt.Data.Header.Type { + case "answer": + c.onMqttAnswer(&rmqtt) + case "candidate": + c.onMqttCandidate(&rmqtt) + case "disconnect": + c.onMqttDisconnect() + case "speaker": + c.onMqttSpeaker(&rmqtt) + } +} + +func (c *TuyaMqttClient) onLowPowerMessage(client mqtt.Client, msg mqtt.Message) { + var message MqttLowPowerMessage + if err := json.Unmarshal(msg.Payload(), &message); err != nil { + return + } + + // Check if protocol is 4 and dps[149] is true + // https://developer.tuya.com/en/docs/iot-device-dev/doorbell_solution?id=Kayamyivh15ox#title-2-Battery + if message.Protocol == 4 { + if val, ok := message.Data.Dps["149"]; ok { + if ready, ok := val.(bool); ok && ready { + // Camera is now ready after wake-up (dps[149]:true received). + // However, we don't wait for this signal (like ismartlife.me doesn't either). + // The camera starts responding immediately after WakeUp() is called, + // so we proceed with the connection without blocking. + // This waiter is kept for potential future use. + c.wakeupWaiter.Done(nil) + } + } + } +} + +func (c *TuyaMqttClient) onMqttAnswer(msg *MqttMessage) { + var answerFrame AnswerFrame + if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { + c.onError(err) + return + } + + c.onAnswer(answerFrame) +} + +func (c *TuyaMqttClient) onMqttCandidate(msg *MqttMessage) { + var candidateFrame CandidateFrame + if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { + c.onError(err) + return + } + + // fix candidates + candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=") + candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n") + + c.onCandidate(candidateFrame) +} + +func (c *TuyaMqttClient) onMqttDisconnect() { + c.closed = true + c.onDisconnect() +} + +func (c *TuyaMqttClient) onMqttSpeaker(msg *MqttMessage) { + var speakerResponse struct { + ResCode int `json:"resCode"` + } + + if err := json.Unmarshal(msg.Data.Message, &speakerResponse); err == nil { + if speakerResponse.ResCode != 0 { + c.speakerWaiter.Done(fmt.Errorf("speaker failed with resCode: %d", speakerResponse.ResCode)) + return + } + } + + c.speakerWaiter.Done(nil) +} + +func (c *TuyaMqttClient) onAnswer(answer AnswerFrame) { + if c.handleAnswer != nil { + c.handleAnswer(answer) + } +} + +func (c *TuyaMqttClient) onCandidate(candidate CandidateFrame) { + if c.handleCandidate != nil { + c.handleCandidate(candidate) + } +} + +func (c *TuyaMqttClient) onDisconnect() { + if c.handleDisconnect != nil { + c.handleDisconnect() + } +} + +func (c *TuyaMqttClient) onError(err error) { + if c.handleError != nil { + c.handleError(err) + } +} + +func (c *TuyaMqttClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { + if c.closed { + return fmt.Errorf("mqtt client is closed, send mqtt message fail") + } + + jsonMessage, err := json.Marshal(data) + if err != nil { + return err + } + + msg := &MqttMessage{ + Protocol: protocol, + Pv: "2.2", + T: time.Now().Unix(), + Data: MqttFrame{ + Header: MqttFrameHeader{ + Type: messageType, + From: c.uid, + To: c.deviceId, + SessionID: c.sessionId, + MotoID: c.motoId, + TransactionID: transactionID, + }, + Message: jsonMessage, + }, + } + + payload, err := json.Marshal(msg) + if err != nil { + return err + } + + token := c.client.Publish(c.publishTopic, 1, false, payload) + if token.Wait() && token.Error() != nil { + return token.Error() + } + + return nil +} diff --git a/pkg/tuya/smart_api.go b/pkg/tuya/smart_api.go new file mode 100644 index 00000000..09615db4 --- /dev/null +++ b/pkg/tuya/smart_api.go @@ -0,0 +1,597 @@ +package tuya + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" +) + +type LoginTokenRequest struct { + CountryCode string `json:"countryCode"` + Username string `json:"username"` + IsUid bool `json:"isUid"` +} + +type LoginTokenResponse struct { + Result LoginToken `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type LoginToken struct { + Token string `json:"token"` + Exponent string `json:"exponent"` + PublicKey string `json:"publicKey"` + PbKey string `json:"pbKey"` +} + +type PasswordLoginRequest struct { + CountryCode string `json:"countryCode"` + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + Passwd string `json:"passwd"` + Token string `json:"token"` + IfEncrypt int `json:"ifencrypt"` + Options string `json:"options"` +} + +type PasswordLoginResponse struct { + Result LoginResult `json:"result"` + Success bool `json:"success"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg,omitempty"` +} + +type LoginResult struct { + Attribute int `json:"attribute"` + ClientId string `json:"clientId"` + DataVersion int `json:"dataVersion"` + Domain Domain `json:"domain"` + Ecode string `json:"ecode"` + Email string `json:"email"` + Extras Extras `json:"extras"` + HeadPic string `json:"headPic"` + ImproveCompanyInfo bool `json:"improveCompanyInfo"` + Nickname string `json:"nickname"` + PartnerIdentity string `json:"partnerIdentity"` + PhoneCode string `json:"phoneCode"` + Receiver string `json:"receiver"` + RegFrom int `json:"regFrom"` + Sid string `json:"sid"` + SnsNickname string `json:"snsNickname"` + TempUnit int `json:"tempUnit"` + Timezone string `json:"timezone"` + TimezoneId string `json:"timezoneId"` + Uid string `json:"uid"` + UserType int `json:"userType"` + Username string `json:"username"` +} + +type Domain struct { + AispeechHttpsUrl string `json:"aispeechHttpsUrl"` + AispeechQuicUrl string `json:"aispeechQuicUrl"` + DeviceHttpUrl string `json:"deviceHttpUrl"` + DeviceHttpsPskUrl string `json:"deviceHttpsPskUrl"` + DeviceHttpsUrl string `json:"deviceHttpsUrl"` + DeviceMediaMqttUrl string `json:"deviceMediaMqttUrl"` + DeviceMediaMqttsUrl string `json:"deviceMediaMqttsUrl"` + DeviceMqttsPskUrl string `json:"deviceMqttsPskUrl"` + DeviceMqttsUrl string `json:"deviceMqttsUrl"` + GwApiUrl string `json:"gwApiUrl"` + GwMqttUrl string `json:"gwMqttUrl"` + HttpPort int `json:"httpPort"` + HttpsPort int `json:"httpsPort"` + HttpsPskPort int `json:"httpsPskPort"` + MobileApiUrl string `json:"mobileApiUrl"` + MobileMediaMqttUrl string `json:"mobileMediaMqttUrl"` + MobileMqttUrl string `json:"mobileMqttUrl"` + MobileMqttsUrl string `json:"mobileMqttsUrl"` + MobileQuicUrl string `json:"mobileQuicUrl"` + MqttPort int `json:"mqttPort"` + MqttQuicUrl string `json:"mqttQuicUrl"` + MqttsPort int `json:"mqttsPort"` + MqttsPskPort int `json:"mqttsPskPort"` + RegionCode string `json:"regionCode"` +} + +type Extras struct { + HomeId string `json:"homeId"` + SceneType string `json:"sceneType"` +} + +type AppInfoResponse struct { + Result AppInfo `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type AppInfo struct { + AppId int `json:"appId"` + AppName string `json:"appName"` + ClientId string `json:"clientId"` + Icon string `json:"icon"` +} + +type MQTTConfigResponse struct { + Result SmartApiMQTTConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SmartApiMQTTConfig struct { + Msid string `json:"msid"` + Password string `json:"password"` +} + +type HomeListResponse struct { + Result []Home `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHomeListResponse struct { + Result SharedHome `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHome struct { + SecurityWebCShareInfoList []struct { + DeviceInfoList []Device `json:"deviceInfoList"` + Nickname string `json:"nickname"` + Username string `json:"username"` + } `json:"securityWebCShareInfoList"` +} + +type Home struct { + Admin bool `json:"admin"` + Background string `json:"background"` + DealStatus int `json:"dealStatus"` + DisplayOrder int `json:"displayOrder"` + GeoName string `json:"geoName"` + Gid int `json:"gid"` + GmtCreate int64 `json:"gmtCreate"` + GmtModified int64 `json:"gmtModified"` + GroupId int `json:"groupId"` + GroupUserId int `json:"groupUserId"` + Id int `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + ManagementStatus bool `json:"managementStatus"` + Name string `json:"name"` + OwnerId string `json:"ownerId"` + Role int `json:"role"` + Status bool `json:"status"` + Uid string `json:"uid"` +} + +type RoomListRequest struct { + HomeId string `json:"homeId"` +} + +type RoomListResponse struct { + Result []Room `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type Room struct { + DeviceCount int `json:"deviceCount"` + DeviceList []Device `json:"deviceList"` + RoomId string `json:"roomId"` + RoomName string `json:"roomName"` +} + +type Device struct { + Category string `json:"category"` + DeviceId string `json:"deviceId"` + DeviceName string `json:"deviceName"` + P2pType int `json:"p2pType"` + ProductId string `json:"productId"` + SupportCloudStorage bool `json:"supportCloudStorage"` + Uuid string `json:"uuid"` +} + +type SmartApiWebRTCConfigRequest struct { + DevId string `json:"devId"` + ClientTraceId string `json:"clientTraceId"` +} + +type SmartApiWebRTCConfigResponse struct { + Result SmartApiWebRTCConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SmartApiWebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audioAttributes"` + Auth string `json:"auth"` + GatewayId string `json:"gatewayId"` + Id string `json:"id"` + LocalKey string `json:"localKey"` + MotoId string `json:"motoId"` + NodeId string `json:"nodeId"` + P2PConfig P2PConfig `json:"p2pConfig"` + ProtocolVersion string `json:"protocolVersion"` + Skill string `json:"skill"` + Sub bool `json:"sub"` + SupportWebrtcRecord bool `json:"supportWebrtcRecord"` + SupportsPtz bool `json:"supportsPtz"` + SupportsWebrtc bool `json:"supportsWebrtc"` + VedioClarity int `json:"vedioClarity"` + VedioClaritys []int `json:"vedioClaritys"` + VideoClarity int `json:"videoClarity"` +} + +type TuyaSmartApiClient struct { + TuyaClient + + email string + password string + countryCode string + mqttsUrl string +} + +type Region struct { + Name string `json:"name"` + Host string `json:"host"` + Description string `json:"description"` + Continent string `json:"continent"` +} + +var AvailableRegions = []Region{ + {"eu-central", "protect-eu.ismartlife.me", "Central Europe", "EU"}, + {"eu-east", "protect-we.ismartlife.me", "East Europe", "EU"}, + {"us-west", "protect-us.ismartlife.me", "West America", "AZ"}, + {"us-east", "protect-ue.ismartlife.me", "East America", "AZ"}, + {"china", "protect.ismartlife.me", "China", "AY"}, + {"india", "protect-in.ismartlife.me", "India", "IN"}, +} + +func NewTuyaSmartApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaSmartApiClient, error) { + var region *Region + for _, r := range AvailableRegions { + if r.Host == baseUrl { + region = &r + break + } + } + + if region == nil { + return nil, fmt.Errorf("invalid region: %s", baseUrl) + } + + if httpClient == nil { + httpClient = CreateHTTPClientWithSession() + } + + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaSmartApiClient{ + TuyaClient: TuyaClient{ + httpClient: httpClient, + mqtt: mqttClient, + deviceId: deviceId, + expireTime: 0, + baseUrl: baseUrl, + }, + email: email, + password: password, + countryCode: region.Continent, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaSmartApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + + return nil +} + +func (c *TuyaSmartApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + return "", errors.New("not supported") +} + +func (c *TuyaSmartApiClient) GetAppInfo() (*AppInfoResponse, error) { + url := fmt.Sprintf("https://%s/api/customized/web/app/info", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var appInfoResponse AppInfoResponse + if err := json.Unmarshal(body, &appInfoResponse); err != nil { + return nil, err + } + + if !appInfoResponse.Success { + return nil, errors.New(appInfoResponse.Msg) + } + + return &appInfoResponse, nil +} + +func (c *TuyaSmartApiClient) GetHomeList() (*HomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/homeList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var homeListResponse HomeListResponse + if err := json.Unmarshal(body, &homeListResponse); err != nil { + return nil, err + } + + if !homeListResponse.Success { + return nil, errors.New(homeListResponse.Msg) + } + + return &homeListResponse, nil +} + +func (c *TuyaSmartApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/playback/shareList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var sharedHomeListResponse SharedHomeListResponse + if err := json.Unmarshal(body, &sharedHomeListResponse); err != nil { + return nil, err + } + + if !sharedHomeListResponse.Success { + return nil, errors.New(sharedHomeListResponse.Msg) + } + + return &sharedHomeListResponse, nil +} + +func (c *TuyaSmartApiClient) GetRoomList(homeId string) (*RoomListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/roomList", c.baseUrl) + + data := RoomListRequest{ + HomeId: homeId, + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var roomListResponse RoomListResponse + if err := json.Unmarshal(body, &roomListResponse); err != nil { + return nil, err + } + + if !roomListResponse.Success { + return nil, errors.New(roomListResponse.Msg) + } + + return &roomListResponse, nil +} + +func (c *TuyaSmartApiClient) initToken() error { + tokenUrl := fmt.Sprintf("https://%s/api/login/token", c.baseUrl) + + tokenReq := LoginTokenRequest{ + CountryCode: c.countryCode, + Username: c.email, + IsUid: false, + } + + body, err := c.request("POST", tokenUrl, tokenReq) + if err != nil { + return err + } + + var tokenResp LoginTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return err + } + + if !tokenResp.Success { + return errors.New(tokenResp.Msg) + } + + encryptedPassword, err := EncryptPassword(c.password, tokenResp.Result.PbKey) + if err != nil { + return fmt.Errorf("failed to encrypt password: %v", err) + } + var loginUrl string + + loginReq := PasswordLoginRequest{ + CountryCode: c.countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if IsEmailAddress(c.email) { + loginUrl = fmt.Sprintf("https://%s/api/private/email/login", c.baseUrl) + loginReq.Email = c.email + } else { + loginUrl = fmt.Sprintf("https://%s/api/private/phone/login", c.baseUrl) + loginReq.Mobile = c.email + } + + body, err = c.request("POST", loginUrl, loginReq) + if err != nil { + return err + } + + var loginResp *PasswordLoginResponse + if err := json.Unmarshal(body, &loginResp); err != nil { + return err + } + + if !loginResp.Success { + return errors.New(loginResp.ErrorMsg) + } + + c.mqttsUrl = fmt.Sprintf("ssl://%s:%d", loginResp.Result.Domain.MobileMqttsUrl, loginResp.Result.Domain.MqttsPort) + c.expireTime = time.Now().Unix() + 2*24*60*60 // 2 days in seconds + + return nil +} + +func (c *TuyaSmartApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl) + + data := SmartApiWebRTCConfigRequest{ + DevId: c.deviceId, + ClientTraceId: fmt.Sprintf("%x", rand.Int63()), + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var webRTCConfigResponse SmartApiWebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, errors.New(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + // Store LocalKey + c.localKey = webRTCConfigResponse.Result.LocalKey + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &WebRTCConfig{ + AudioAttributes: webRTCConfigResponse.Result.AudioAttributes, + Auth: webRTCConfigResponse.Result.Auth, + ID: webRTCConfigResponse.Result.Id, + MotoID: webRTCConfigResponse.Result.MotoId, + P2PConfig: webRTCConfigResponse.Result.P2PConfig, + ProtocolVersion: webRTCConfigResponse.Result.ProtocolVersion, + Skill: webRTCConfigResponse.Result.Skill, + SupportsWebRTCRecord: webRTCConfigResponse.Result.SupportWebrtcRecord, + SupportsWebRTC: webRTCConfigResponse.Result.SupportsWebrtc, + VedioClaritiy: webRTCConfigResponse.Result.VedioClarity, + VideoClaritiy: webRTCConfigResponse.Result.VideoClarity, + VideoClarities: webRTCConfigResponse.Result.VedioClaritys, + }, nil +} + +func (c *TuyaSmartApiClient) loadHubConfig() (config *MQTTConfig, err error) { + mqttUrl := fmt.Sprintf("https://%s/api/jarvis/mqtt", c.baseUrl) + + mqttBody, err := c.request("POST", mqttUrl, nil) + if err != nil { + return nil, err + } + + var mqttConfigResponse MQTTConfigResponse + err = json.Unmarshal(mqttBody, &mqttConfigResponse) + if err != nil { + return nil, err + } + + if !mqttConfigResponse.Success { + return nil, errors.New(mqttConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: c.mqttsUrl, + ClientID: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Username: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Password: mqttConfigResponse.Result.Password, + PublishTopic: "/av/moto/moto_id/u/{device_id}", + SubscribeTopic: fmt.Sprintf("/av/u/%s", mqttConfigResponse.Result.Msid), + }, nil +} + +func (c *TuyaSmartApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", c.baseUrl)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} diff --git a/pkg/webrtc/client.go b/pkg/webrtc/client.go index 84e9e86b..bc2c4f87 100644 --- a/pkg/webrtc/client.go +++ b/pkg/webrtc/client.go @@ -63,12 +63,12 @@ func (c *Conn) SetAnswer(answer string) (err error) { SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer), } if err = c.pc.SetRemoteDescription(desc); err != nil { - return + return err } sd := &sdp.SessionDescription{} if err = sd.Unmarshal([]byte(answer)); err != nil { - return + return err } c.Medias = UnmarshalMedias(sd.MediaDescriptions) diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 092b05c8..924fd550 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -161,16 +161,7 @@ func (c *Conn) AddCandidate(candidate string) error { return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate}) } -func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { - for _, tr := range c.pc.GetTransceivers() { - if tr.Mid() == mid { - return tr - } - } - return nil -} - -func (c *Conn) getSenderTrack(mid string) *Track { +func (c *Conn) GetSenderTrack(mid string) *Track { if tr := c.getTranseiver(mid); tr != nil { if s := tr.Sender(); s != nil { if t := s.Track().(*Track); t != nil { @@ -181,6 +172,15 @@ func (c *Conn) getSenderTrack(mid string) *Track { return nil } +func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { + for _, tr := range c.pc.GetTransceivers() { + if tr.Mid() == mid { + return tr + } + } + return nil +} + func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) { for _, tr := range c.pc.GetTransceivers() { // search Transeiver for this TrackRemote diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index ebc3a008..767394df 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -32,7 +32,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv panic(core.Caller()) } - localTrack := c.getSenderTrack(media.ID) + localTrack := c.GetSenderTrack(media.ID) if localTrack == nil { return errors.New("webrtc: can't get track") } diff --git a/www/add.html b/www/add.html index 75f7b996..f8ef46b8 100644 --- a/www/add.html +++ b/www/add.html @@ -330,6 +330,53 @@ + +
+
+ + + + +
+
+
+ + +
@@ -379,4 +426,4 @@ - + \ No newline at end of file diff --git a/www/main.js b/www/main.js index c901f300..d5629178 100644 --- a/www/main.js +++ b/www/main.js @@ -58,7 +58,7 @@ document.head.innerHTML += ` gap: 10px; } - input[type="text"] { + input[type="text"], input[type="email"], input[type="password"], select { padding: 10px; border: 1px solid #ccc; border-radius: 4px;