From 998c85d6f5598f08d0e23c241756c8ef8c700ae6 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 22 May 2025 00:05:49 +0200 Subject: [PATCH] - support adding cameras via interface - support qr code auth - support resolution change - support h265 - refactor code --- README.md | 59 +++-- internal/tuya/tuya.go | 201 ++++++++++++++++ pkg/tuya/README.md | 5 +- pkg/tuya/api.go | 507 --------------------------------------- pkg/tuya/client.go | 513 +++++++++++++++++++++------------------- pkg/tuya/cloud_api.go | 312 ++++++++++++++++++++++++ pkg/tuya/crypto.go | 134 +++++++++++ pkg/tuya/helper.go | 72 ++++++ pkg/tuya/interface.go | 259 ++++++++++++++++++++ pkg/tuya/mqtt.go | 267 +++++++++++---------- pkg/tuya/sharing_api.go | 473 ++++++++++++++++++++++++++++++++++++ www/add.html | 105 ++++++++ 12 files changed, 2003 insertions(+), 904 deletions(-) delete mode 100644 pkg/tuya/api.go create mode 100644 pkg/tuya/cloud_api.go create mode 100644 pkg/tuya/crypto.go create mode 100644 pkg/tuya/helper.go create mode 100644 pkg/tuya/interface.go create mode 100644 pkg/tuya/sharing_api.go diff --git a/README.md b/README.md index 05d940fd..00b9cc45 100644 --- a/README.md +++ b/README.md @@ -568,39 +568,60 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. #### Source: Tuya -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Open API`. +The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Open API` does not require a cloud project and the cameras can be added through the interface via QR code (user code required), but it does not support webrtc mode and two way audio. + +**Cloud API**: - Obtain `device_id`, `client_id`, `client_secret`, and `uid` (if using `mode=webrtc`) from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). -- Use `mode` parameter to select the stream type: - - `webrtc` - WebRTC stream (default) - - `rtsp` - RTSP stream _(if available)_ - - `hls` - HLS stream _(if available)_ -- Use `resolution` parameter to select the stream: + +**Open API**: +- To get your user code, open the Tuya Smart app or Smart Life app and go to `Profile` > `Settings` > `Account and Security` > `User Code` +- Open the Go2rtc interface and go to `Add` > `Tuya` and enter your `User Code` in the `User Code` field. Click on `Generate QR Code` and scan it with the Tuya Smart app or Smart Life app. After scanning, click on `Login`. All cameras in your home (not shared ones) will be listed in the Go2rtc interface. Copy/Paste stream URLs to your `go2rtc.yaml` file. + +**Configuring the stream:** +- Use `mode` parameter to select the stream type (not all cameras support all modes): + - `webrtc` - WebRTC stream _(default for `Cloud API`)_ + - `rtsp` - RTSP stream _(default for `Open API`)_ + - `hls` - HLS stream + - `flv` - FLV stream _(only available for `Open API`)_ + - `rtmp` - RTMP stream _(only available for `Open API`)_ + +- Use `resolution` parameter to select the stream (only available for `Cloud API` and not all cameras support `hd` stream): - `hd` - HD stream (default) - `sd` - SD stream ```yaml streams: - # Tuya WebRTC stream - tuya_webrtc: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX + # Cloud API: WebRTC stream + tuya_webrtc: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX - # Tuya WebRTC stream (same as above) - tuya_webrtc_2: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&mode=webrtc + # Cloud API: WebRTC stream (same as above) + tuya_webrtc_2: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&mode=webrtc - # Tuya WebRTC stream (HD) - tuya_webrtc_hd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=hd + # Cloud API: WebRTC stream (HD) + tuya_webrtc_hd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=hd - # Tuya WebRTC stream (SD) - tuya_webrtc_sd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd + # Cloud API: WebRTC stream (SD) + tuya_webrtc_sd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd - # Using RTSP when available (no "uid" required) - tuya_rtsp: tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp + # Cloud API: RTSP stream when available (no "uid" required) + tuya_rtsp: + - tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp - # Using HLS when available (no "uid" required) - tuya_hls: tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=hls + # Cloud API: HLS stream when available (no "uid" required) + tuya_hls: + - tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=hls + + # Open API: RTSP stream + tuya_openapi: + - tuya://apigw.tuyaeu.com?device_id=XXX&terminal_id=XXX&token=XXX&uid=XXX ``` - #### Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go index c3b34e4a..b5457253 100644 --- a/internal/tuya/tuya.go +++ b/internal/tuya/tuya.go @@ -1,13 +1,214 @@ package tuya import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tuya" ) +var users = make(map[string]tuya.LoginResponse) + 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() + userCode := query.Get("user_code") + token := query.Get("token") + + if userCode == "" { + http.Error(w, "user_code is required", http.StatusBadRequest) + return + } + + var auth *tuya.LoginResponse + if loginResponse, ok := users[userCode]; ok { + expireTime := loginResponse.Timestamp + loginResponse.Result.ExpireTime + + if expireTime > time.Now().Unix() { + auth = &loginResponse + } else { + delete(users, userCode) + token = "" + } + } + + if auth == nil && token == "" { + qrCode, err := getQRCode(userCode) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // response qrCode + json.NewEncoder(w).Encode(map[string]interface{}{ + "qrCode": qrCode, + }) + + return + } + + if auth == nil && token != "" { + authResponse, err := login(userCode, token) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + auth = authResponse + } + + if auth == nil { + http.Error(w, "failed to get auth", http.StatusInternalServerError) + return + } + + tokenInfo := tuya.TokenInfo{ + AccessToken: auth.Result.AccessToken, + ExpireTime: auth.Timestamp + auth.Result.ExpireTime, + RefreshToken: auth.Result.RefreshToken, + } + + tokenInfoBase64, err := tuya.ToBase64(&tokenInfo) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tuyaAPI, err := tuya.NewTuyaOpenApiClient( + strings.Replace(auth.Result.Endpoint, "https://", "", 1), + auth.Result.UID, + "", + auth.Result.TerminalID, + tokenInfo, + "", + ) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := tuyaAPI.GetAllDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + for _, device := range devices { + cleanQuery := url.Values{} + cleanQuery.Set("uid", auth.Result.UID) + cleanQuery.Set("token", tokenInfoBase64) + cleanQuery.Set("terminal_id", auth.Result.TerminalID) + cleanQuery.Set("device_id", device.ID) + + endpoint := strings.Replace(auth.Result.Endpoint, "https://", "tuya://", 1) + url := fmt.Sprintf("%s?%s", endpoint, cleanQuery.Encode()) + + items = append(items, &api.Source{ + Name: device.Name, + URL: url, + }) + } + + api.ResponseSources(w, items) +} + +func login(userCode string, qrCode string) (*tuya.LoginResponse, error) { + url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens/%s?clientid=%s&usercode=%s", tuya.TUYA_HOST, qrCode, tuya.TUYA_CLIENT_ID, userCode) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + response, err := 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, fmt.Errorf("failed to get QR code: %s", string(res)) + } + + var loginResponse tuya.LoginResponse + err = json.Unmarshal(res, &loginResponse) + if err != nil { + return nil, err + } + + if !loginResponse.Success { + return nil, fmt.Errorf("failed to login: %s", loginResponse.Msg) + } + + users[userCode] = loginResponse + + return &loginResponse, nil +} + +func getQRCode(userCode string) (string, error) { + url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens?clientid=%s&schema=%s&usercode=%s", tuya.TUYA_HOST, tuya.TUYA_CLIENT_ID, tuya.TUYA_SCHEMA, userCode) + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "text/plain") + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + response, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + if response.StatusCode != http.StatusOK { + return "", err + } + + var qrResponse tuya.QRResponse + err = json.Unmarshal(res, &qrResponse) + if err != nil { + return "", err + } + + if !qrResponse.Success { + return "", fmt.Errorf("failed to get QR code: %s", qrResponse.Msg) + } + + return qrResponse.Result.Code, nil } diff --git a/pkg/tuya/README.md b/pkg/tuya/README.md index cc213a66..f1936404 100644 --- a/pkg/tuya/README.md +++ b/pkg/tuya/README.md @@ -3,6 +3,7 @@ - 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/ -- https://github.com/tuya/tuya-device-sharing-sdk \ No newline at end of file +- https://protect-us.ismartlife.me/ \ No newline at end of file diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go deleted file mode 100644 index 3fb72c62..00000000 --- a/pkg/tuya/api.go +++ /dev/null @@ -1,507 +0,0 @@ -package tuya - -import ( - "bytes" - "crypto/md5" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "time" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/webrtc" - "github.com/google/uuid" - pionWebrtc "github.com/pion/webrtc/v4" -) - -type TuyaClient struct { - httpClient *http.Client - mqtt *TuyaMQTT - apiURL string - rtspURL string - hlsURL string - sessionId string - clientId string - clientSecret string - deviceId string - accessToken string - refreshToken string - expireTime int64 - uid string - motoId string - auth string - skill *Skill - iceServers []pionWebrtc.ICEServer -} - -type Token struct { - UID string `json:"uid"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpireTime int64 `json:"expire_time"` -} - -type AudioAttributes struct { - CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way - HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker -} - -type OpenApiICE struct { - Urls string `json:"urls"` - Username string `json:"username"` - Credential string `json:"credential"` - TTL int `json:"ttl"` -} - -type WebICE struct { - Urls string `json:"urls"` - Username string `json:"username,omitempty"` - Credential string `json:"credential,omitempty"` -} - -type P2PConfig struct { - Ices []OpenApiICE `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) - ProfileId string `json:"profileId,omitempty"` - CodecType int `json:"codecType"` // 2 = H264, 4 = H265 - Width int `json:"width"` - Height int `json:"height"` - SampleRate int `json:"sampleRate"` -} - -type Skill struct { - WebRTC int `json:"webrtc"` - Audios []AudioSkill `json:"audios"` - Videos []VideoSkill `json:"videos"` -} - -type WebRTConfig struct { - AudioAttributes AudioAttributes `json:"audio_attributes"` - Auth string `json:"auth"` - ID string `json:"id"` - 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 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 WebRTCConfigResponse struct { - Success bool `json:"success"` - Result WebRTConfig `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` -} - -type TokenResponse struct { - Success bool `json:"success"` - Result Token `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` -} - -type AllocateRequest struct { - Type string `json:"type"` -} - -type AllocateResponse struct { - Success bool `json:"success"` - Result struct { - URL string `json:"url"` - } `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 OpenIoTHubConfigResponse struct { - Success bool `json:"success"` - Result OpenIoTHubConfig `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` -} - -func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId string, clientSecret string, streamMode string) (*TuyaClient, error) { - client := &TuyaClient{ - httpClient: &http.Client{Timeout: 5 * time.Second}, - mqtt: &TuyaMQTT{waiter: core.Waiter{}}, - apiURL: openAPIURL, - sessionId: core.RandString(6, 62), - clientId: clientId, - deviceId: deviceId, - clientSecret: clientSecret, - uid: uid, - } - - if err := client.InitToken(); err != nil { - return nil, fmt.Errorf("failed to initialize token: %w", err) - } - - if streamMode == "rtsp" { - if err := client.GetStreamUrl("rtsp"); err != nil { - return nil, fmt.Errorf("failed to get RTSP URL: %w", err) - } - } else if streamMode == "hls" { - if err := client.GetStreamUrl("hls"); err != nil { - return nil, fmt.Errorf("failed to get HLS URL: %w", err) - } - } else { - if err := client.InitDevice(); err != nil { - return nil, fmt.Errorf("failed to initialize device: %w", err) - } - - if err := client.StartMQTT(); err != nil { - return nil, fmt.Errorf("failed to start MQTT: %w", err) - } - } - - return client, nil -} - -func (c *TuyaClient) Close() { - c.StopMQTT() - c.httpClient.CloseIdleConnections() -} - -func (c *TuyaClient) InitToken() (err error) { - url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.apiURL) - - 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 fmt.Errorf(tokenResponse.Msg) - } - - c.accessToken = tokenResponse.Result.AccessToken - c.refreshToken = tokenResponse.Result.RefreshToken - c.expireTime = tokenResponse.Result.ExpireTime - - return nil -} - -func (c *TuyaClient) InitDevice() (err error) { - url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.apiURL, c.uid, c.deviceId) - - body, err := c.Request("GET", url, nil) - if err != nil { - return err - } - - var webRTCConfigResponse WebRTCConfigResponse - err = json.Unmarshal(body, &webRTCConfigResponse) - if err != nil { - return err - } - - if !webRTCConfigResponse.Success { - return fmt.Errorf(webRTCConfigResponse.Msg) - } - - err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) - if err != nil { - return err - } - - iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) - if err != nil { - return err - } - - c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) - if err != nil { - return err - } - - c.motoId = webRTCConfigResponse.Result.MotoID - c.auth = webRTCConfigResponse.Result.Auth - - return nil -} - -func (c *TuyaClient) GetStreamUrl(streamType string) (err error) { - url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.apiURL, c.deviceId) - - request := &AllocateRequest{ - Type: streamType, - } - - body, err := c.Request("POST", url, request) - if err != nil { - return err - } - - var allosResponse AllocateResponse - err = json.Unmarshal(body, &allosResponse) - if err != nil { - return err - } - - if !allosResponse.Success { - return fmt.Errorf(allosResponse.Msg) - } - - switch streamType { - case "rtsp": - c.rtspURL = allosResponse.Result.URL - case "hls": - c.hlsURL = allosResponse.Result.URL - default: - return fmt.Errorf("unsupported stream type: %s", streamType) - } - - return nil -} - -func (c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { - url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.apiURL) - - 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 &openIoTHubConfigResponse.Result, nil -} - -func (c *TuyaClient) 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 *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 -} - -// 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 - } -} - -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 - 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 - } -} - -func (c *TuyaClient) isHEVC(streamType int) bool { - for _, video := range c.skill.Videos { - if video.StreamType == streamType { - return video.CodecType == 4 - } - } - - return false -} - -func (c *TuyaClient) 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/client.go b/pkg/tuya/client.go index 5a865690..4a43c49a 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -15,16 +15,17 @@ import ( ) type Client struct { - api *TuyaClient - conn *webrtc.Conn - pc *pion.PeerConnection - dc *pion.DataChannel - videoSSRC uint32 - audioSSRC uint32 - isHEVC bool - connected core.Waiter - closed bool - handlers map[uint32]func(*rtp.Packet) + api TuyaAPI + conn *webrtc.Conn + pc *pion.PeerConnection + dc *pion.DataChannel + videoSSRC uint32 + audioSSRC uint32 + streamType int + isHEVC bool + connected core.Waiter + closed bool + handlers map[uint32]func(*rtp.Packet) } type DataChannelMessage struct { @@ -41,15 +42,6 @@ type RecvMessage struct { } `json:"audio"` } -const ( - DefaultCnURL = "openapi.tuyacn.com" - DefaultWestUsURL = "openapi.tuyaus.com" - DefaultEastUsURL = "openapi-ueaz.tuyaus.com" - DefaultCentralEuURL = "openapi.tuyaeu.com" - DefaultWestEuURL = "openapi-weaz.tuyaeu.com" - DefaultInURL = "openapi.tuyain.com" -) - func Dial(rawURL string) (core.Producer, error) { u, err := url.Parse(rawURL) if err != nil { @@ -57,274 +49,292 @@ func Dial(rawURL string) (core.Producer, error) { } query := u.Query() - deviceID := query.Get("device_id") - uid := query.Get("uid") + + // Open API + tokenInfo := query.Get("token") + terminalId := query.Get("terminal_id") + + // Cloud API clientId := query.Get("client_id") clientSecret := query.Get("client_secret") + + // Shared params + deviceId := query.Get("device_id") + uid := query.Get("uid") + + // Stream params streamResolution := query.Get("resolution") streamMode := query.Get("mode") + useOpenApi := deviceId != "" && uid != "" && tokenInfo != "" && terminalId != "" + useCloudApi := deviceId != "" && ((streamMode == "webrtc" || streamMode == "") && uid != "") && clientId != "" && clientSecret != "" + if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { streamResolution = "hd" } - useRTSP := streamMode == "rtsp" - useHLS := streamMode == "hls" - useWebRTC := streamMode == "webrtc" || streamMode == "" - - // check if host is correct - switch u.Hostname() { - case DefaultCnURL: - case DefaultWestUsURL: - case DefaultEastUsURL: - case DefaultCentralEuURL: - case DefaultWestEuURL: - case DefaultInURL: - default: - return nil, fmt.Errorf("tuya: wrong host %s", u.Hostname()) + if streamMode == "" || (streamMode != "rtsp" && streamMode != "hls" && streamMode != "flv" && streamMode != "rtmp" && streamMode != "webrtc") { + if useOpenApi { + streamMode = "rtsp" + } else { + streamMode = "webrtc" + } } - if deviceID == "" || clientId == "" || clientSecret == "" { - return nil, errors.New("tuya: no device_id, client_id or client_secret") - } - - if useWebRTC && uid == "" { - return nil, errors.New("tuya: no uid") - } - - if !useRTSP && !useHLS && !useWebRTC { - return nil, errors.New("tuya: wrong stream type") - } - - // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode) - if err != nil { - return nil, fmt.Errorf("tuya: %w", err) + if !useOpenApi && !useCloudApi { + return nil, errors.New("tuya: wrong query params") } client := &Client{ - api: tuyaAPI, handlers: make(map[uint32]func(*rtp.Packet)), } - if useRTSP { - if client.api.rtspURL == "" { - return nil, errors.New("tuya: no rtsp url") + if useOpenApi { + if client.api, err = NewTuyaOpenApiClient(u.Hostname(), uid, deviceId, terminalId, tokenInfo, streamMode); err != nil { + return nil, fmt.Errorf("tuya: %w", err) } - return streams.GetProducer(client.api.rtspURL) - } else if useHLS { - if client.api.hlsURL == "" { - return nil, errors.New("tuya: no hls url") - } - return streams.GetProducer(client.api.hlsURL) } else { - client.isHEVC = client.api.isHEVC(client.api.getStreamType(streamResolution)) - - // Create a new PeerConnection - conf := pion.Configuration{ - ICEServers: client.api.iceServers, - ICETransportPolicy: pion.ICETransportPolicyAll, - BundlePolicy: pion.BundlePolicyMaxBundle, + if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret, streamMode); err != nil { + return nil, fmt.Errorf("tuya: %w", err) } + } - api, err := webrtc.NewAPI() + if streamMode != "webrtc" { + streamUrl, err := client.api.GetStreamUrl(streamMode) if err != nil { - client.Stop() - return nil, err + return nil, fmt.Errorf("tuya: %w", err) } - client.pc, err = api.NewPeerConnection(conf) - if err != nil { - client.Stop() - return nil, err + return streams.GetProducer(streamUrl) + } + + 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, } - // 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" - - // Set up MQTT handlers - client.api.mqtt.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.connected.Done(err) - return - } - - if err = client.conn.SetAnswer(answer.Sdp); err != nil { - client.connected.Done(err) - return - } - - if client.isHEVC { - // Tuya seems to answers always with H264 and PCMU/8000 and PCMA/8000 codecs, replace with real codecs - - for _, media := range client.conn.Medias { - if media.Kind == core.KindVideo { - codecs := client.api.getVideoCodecs() - if codecs != nil { - media.Codecs = codecs - } - } - } - - for _, media := range client.conn.Medias { - if media.Kind == core.KindAudio { - codecs := client.api.getAudioCodecs() - if codecs != nil { - media.Codecs = codecs - } - } - } - } + if err = client.pc.SetRemoteDescription(desc); err != nil { + client.Close(err) + return } - client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { - // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) - - if candidate.Candidate != "" { - client.conn.AddCandidate(candidate.Candidate) - if err != nil { - client.Stop() - } - } + if err = client.conn.SetAnswer(answer.Sdp); err != nil { + client.Close(err) + return } - client.api.mqtt.handleDisconnect = func() { - // fmt.Println("tuya: disconnect") - client.Stop() - } - - client.api.mqtt.handleError = func(err error) { - // fmt.Printf("tuya: error: %s\n", err.Error()) - client.Stop() - } - - // On HEVC, use DataChannel to receive video/audio if client.isHEVC { - // Create a new DataChannel - maxRetransmits := uint16(5) - ordered := true - client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ - MaxRetransmits: &maxRetransmits, - Ordered: &ordered, - }) + // Tuya seems to answers always with H264 and PCMU/8000 and PCMA/8000 codecs, replace with real codecs - // Set up data channel handler - client.dc.OnMessage(func(msg pion.DataChannelMessage) { - if msg.IsString { - client.probe(msg) - } else { - packet := &rtp.Packet{} - if err := packet.Unmarshal(msg.Data); err != nil { - // skip - return - } - - if handler, ok := client.handlers[packet.SSRC]; ok { - handler(packet) + for _, media := range client.conn.Medias { + if media.Kind == core.KindVideo { + codecs := client.api.GetVideoCodecs() + if codecs != nil { + media.Codecs = codecs } } - }) + } - client.dc.OnError(func(err error) { - // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) - client.connected.Done(err) - }) - - client.dc.OnClose(func() { - // fmt.Println("tuya: datachannel closed") - client.connected.Done(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.connected.Done(fmt.Errorf("failed to send codec request: %w", err)) + for _, media := range client.conn.Medias { + if media.Kind == core.KindAudio { + codecs := client.api.GetAudioCodecs() + if codecs != nil { + media.Codecs = codecs + } } - }) + } } + } - // Set up pc handler - client.conn.Listen(func(msg any) { - switch msg := msg.(type) { - case *pion.ICECandidate: - _ = sendOffer.Wait() - if err := client.api.sendCandidate("a=" + msg.ToJSON().Candidate); err != nil { - client.connected.Done(err) + 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) + } + + // On HEVC, use DataChannel to receive video/audio + if client.isHEVC { + // Create a new DataChannel + maxRetransmits := uint16(5) + ordered := true + client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ + MaxRetransmits: &maxRetransmits, + Ordered: &ordered, + }) + + // Set up data channel handler + client.dc.OnMessage(func(msg pion.DataChannelMessage) { + if msg.IsString { + if connected, err := client.probe(msg); err != nil { + client.Close(err) + } else if connected { + client.connected.Done(nil) + } + } else { + packet := &rtp.Packet{} + if err := packet.Unmarshal(msg.Data); err != nil { + // skip + return } - 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 { - client.connected.Done(nil) - } - default: - client.Stop() - client.connected.Done(errors.New("webrtc: " + msg.String())) + if handler, ok := client.handlers[packet.SSRC]; ok { + handler(packet) } } }) - // Audio first, otherwise tuya will send corrupt sdp - medias := []*core.Media{ - {Kind: core.KindAudio, Direction: core.DirectionSendRecv}, - {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, - } + client.dc.OnError(func(err error) { + // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) + client.Close(err) + }) - // Create offer - offer, err := client.conn.CreateOffer(medias) - if err != nil { - client.Stop() - return nil, err - } + client.dc.OnClose(func() { + // fmt.Println("tuya: datachannel closed") + client.Close(errors.New("datachannel: closed")) + }) - // 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, "") + client.dc.OnOpen(func() { + // fmt.Println("tuya: datachannel opened") - // Send offer - if err := client.api.sendOffer(offer, streamResolution); err != nil { - client.Stop() - return nil, fmt.Errorf("tuya: %w", err) - } + codecRequest, _ := json.Marshal(DataChannelMessage{ + Type: "codec", + Msg: "", + }) - sendOffer.Done(nil) - - // Wait for connection - if err = client.connected.Wait(); err != nil { - return nil, fmt.Errorf("tuya: %w", err) - } - - return client, nil + 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) + } + 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 { @@ -343,7 +353,10 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece return errors.New("webrtc: can't get track") } - _ = c.api.sendSpeaker(1) + mqttClient := c.api.GetMqtt() + if mqttClient != nil { + _ = mqttClient.SendSpeaker(1) + } payloadType := codec.PayloadType @@ -411,22 +424,25 @@ func (c *Client) Stop() error { 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) probe(msg pion.DataChannelMessage) { +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 { - c.connected.Done(fmt.Errorf("failed to parse datachannel message: %w", err)) + return false, err } switch message.Type { case "codec": - // fmt.Printf("[tuya] Codec info from camera: %s\n", message.Msg) - frameRequest, _ := json.Marshal(DataChannelMessage{ Type: "start", Msg: "frame", @@ -434,14 +450,13 @@ func (c *Client) probe(msg pion.DataChannelMessage) { err := c.sendMessageToDataChannel(frameRequest) if err != nil { - c.connected.Done(fmt.Errorf("failed to send frame request: %w", err)) + return false, err } case "recv": var recvMessage RecvMessage if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil { - c.connected.Done(fmt.Errorf("failed to parse recv message: %w", err)) - return + return false, err } c.videoSSRC = recvMessage.Video.SSRC @@ -454,11 +469,13 @@ func (c *Client) probe(msg pion.DataChannelMessage) { err := c.sendMessageToDataChannel(completeMsg) if err != nil { - c.connected.Done(fmt.Errorf("failed to send complete message: %w", err)) + return false, err } - c.connected.Done(nil) + return true, nil } + + return false, nil } func (c *Client) sendMessageToDataChannel(message []byte) error { diff --git a/pkg/tuya/cloud_api.go b/pkg/tuya/cloud_api.go new file mode 100644 index 00000000..bd74daf0 --- /dev/null +++ b/pkg/tuya/cloud_api.go @@ -0,0 +1,312 @@ +package tuya + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "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 + clientId string + clientSecret string + refreshingToken bool +} + +func NewTuyaCloudApiClient(baseUrl string, uid string, deviceId string, clientId string, clientSecret string, streamMode string) (*TuyaCloudApiClient, error) { + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaCloudApiClient{ + TuyaClient: TuyaClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + mqtt: mqttClient, + uid: uid, + deviceId: deviceId, + streamMode: streamMode, + expireTime: 0, + baseUrl: baseUrl, + }, + 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) + } + + 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 "", fmt.Errorf(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 fmt.Errorf(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 + } + + 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/crypto.go b/pkg/tuya/crypto.go new file mode 100644 index 00000000..b8f84615 --- /dev/null +++ b/pkg/tuya/crypto.go @@ -0,0 +1,134 @@ +package tuya + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "math/rand" +) + +// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py +func AesGCMEncrypt(rawData string, secret string) (string, error) { + nonce := []byte(RandomNonce(12)) + + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + ciphertext := aesgcm.Seal(nil, nonce, []byte(rawData), nil) + nonceB64 := base64.StdEncoding.EncodeToString(nonce) + ciphertextB64 := base64.StdEncoding.EncodeToString(ciphertext) + + return nonceB64 + ciphertextB64, nil +} + +func AesGCMDecrypt(cipherData string, secret string) (string, error) { + if len(cipherData) <= 16 { + return "", fmt.Errorf("invalid ciphertext length") + } + + nonceB64 := cipherData[:16] + ciphertextB64 := cipherData[16:] + + nonce, err := base64.StdEncoding.DecodeString(nonceB64) + if err != nil { + return "", err + } + + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) + if err != nil { + return "", err + } + + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +func SecretGenerating(rid, sid, hashKey string) string { + message := hashKey + mod := 16 + + if sid != "" { + sidLength := len(sid) + length := sidLength + if length > mod { + length = mod + } + + ecode := "" + for i := 0; i < length; i++ { + idx := int(sid[i]) % mod + ecode += string(sid[idx]) + } + message += "_" + message += ecode + } + + h := hmac.New(sha256.New, []byte(rid)) + h.Write([]byte(message)) + byteTemp := h.Sum(nil) + secret := hex.EncodeToString(byteTemp) + + return secret[:16] +} + +func RestfulSign(hashKey, queryEncdata, bodyEncdata string, data map[string]string) string { + headers := []string{"X-appKey", "X-requestId", "X-sid", "X-time", "X-token"} + headerSignStr := "" + + for _, item := range headers { + val, exists := data[item] + if exists && val != "" { + headerSignStr += item + "=" + val + "||" + } + } + + signStr := "" + if len(headerSignStr) > 2 { + signStr = headerSignStr[:len(headerSignStr)-2] + } + + if queryEncdata != "" { + signStr += queryEncdata + } + if bodyEncdata != "" { + signStr += bodyEncdata + } + + h := hmac.New(sha256.New, []byte(hashKey)) + h.Write([]byte(signStr)) + return hex.EncodeToString(h.Sum(nil)) +} + +func RandomNonce(length int) string { + const charset = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678" + result := make([]byte, length) + for i := range result { + result[i] = charset[rand.Intn(len(charset))] + } + return string(result) +} diff --git a/pkg/tuya/helper.go b/pkg/tuya/helper.go new file mode 100644 index 00000000..0b97b256 --- /dev/null +++ b/pkg/tuya/helper.go @@ -0,0 +1,72 @@ +package tuya + +import ( + "encoding/base64" + "encoding/json" + "fmt" +) + +func FormToJSON(content any) string { + if content == nil { + return "{}" + } + + jsonBytes, err := json.Marshal(content) + if err != nil { + return "{}" + } + + return string(jsonBytes) +} + +func ToBase64(tokenInfo *TokenInfo) (string, error) { + jsonData, err := json.Marshal(tokenInfo) + if err != nil { + return "", fmt.Errorf("error marshalling token: %v", err) + } + + encoded := base64.URLEncoding.EncodeToString(jsonData) + + return encoded, nil +} + +func FromBase64(encodedTokenInfo string) (*TokenInfo, error) { + jsonData, err := base64.URLEncoding.DecodeString(encodedTokenInfo) + if err != nil { + return nil, fmt.Errorf("error decoding token: %v", err) + } + + var tokenInfo TokenInfo + err = json.Unmarshal(jsonData, &tokenInfo) + if err != nil { + return nil, fmt.Errorf("error unmarshalling token: %v", err) + } + + return &tokenInfo, nil +} + +func ParseTokenInfo(tokenInfoOrString any) (*TokenInfo, error) { + var tokenInfo *TokenInfo + var err error + + switch v := tokenInfoOrString.(type) { + case string: + tokenInfo, err = FromBase64(v) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 token: %w", err) + } + case *TokenInfo: + tokenInfo = v + case TokenInfo: + copyOfV := v + tokenInfo = ©OfV + default: + return nil, fmt.Errorf("invalid type: %T", v) + } + + if tokenInfo == nil { + return nil, fmt.Errorf("token info is nil") + } + + return tokenInfo, nil +} diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go new file mode 100644 index 00000000..dae19a82 --- /dev/null +++ b/pkg/tuya/interface.go @@ -0,0 +1,259 @@ +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 + streamMode string + baseUrl string + accessToken string + refreshToken string + expireTime int64 + deviceId string + uid 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 OpenApiICE struct { + Urls string `json:"urls"` + Username string `json:"username"` + Credential string `json:"credential"` + TTL int `json:"ttl"` +} + +type WebICE struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +type P2PConfig struct { + Ices []OpenApiICE `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) + ProfileId string `json:"profileId,omitempty"` + CodecType int `json:"codecType"` // 2 = H264, 4 = H265 + Width int `json:"width"` + Height int `json:"height"` + SampleRate int `json:"sampleRate"` +} + +type Skill struct { + WebRTC int `json:"webrtc"` + Audios []AudioSkill `json:"audios"` + Videos []VideoSkill `json:"videos"` +} + +type WebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audio_attributes"` + Auth string `json:"auth"` + ID string `json:"id"` + 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 +} + +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 + 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 + } +} + +func (c *TuyaClient) IsHEVC(streamType int) bool { + for _, video := range c.skill.Videos { + if video.StreamType == streamType { + return video.CodecType == 4 + } + } + + 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 index 6fd3d4a0..deb90b9e 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -10,13 +10,18 @@ import ( mqtt "github.com/eclipse/paho.mqtt.golang" ) -type TuyaMQTT struct { +type TuyaMqttClient struct { client mqtt.Client waiter core.Waiter publishTopic string subscribeTopic string + auth string uid string + motoId string + deviceId string + sessionId string closed bool + webrtcVersion int handleAnswer func(answer AnswerFrame) handleCandidate func(candidate CandidateFrame) handleDisconnect func() @@ -56,14 +61,14 @@ type CandidateFrame struct { Candidate string `json:"candidate"` } -// type ResolutionFrame struct { -// Mode string `json:"mode"` -// Value int `json:"value"` // 0: HD, 1: SD -// } +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:"value"` // 0: off, 1: on + Value int `json:"cmdValue"` // 0: off, 1: on } type DisconnectFrame struct { @@ -77,20 +82,27 @@ type MqttMessage struct { Data MqttFrame `json:"data"` } -func (c *TuyaClient) StartMQTT() error { - hubConfig, err := c.LoadHubConfig() - if err != nil { - return err +func NewTuyaMqttClient(deviceId string) *TuyaMqttClient { + return &TuyaMqttClient{ + deviceId: deviceId, + sessionId: core.RandString(6, 62), + waiter: core.Waiter{}, } +} - c.mqtt.publishTopic = hubConfig.SinkTopic.IPC - c.mqtt.subscribeTopic = hubConfig.SourceSink.IPC +func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig, webrtcVersion int) error { + c.webrtcVersion = webrtcVersion + c.motoId = webrtcConfig.MotoID + c.auth = webrtcConfig.Auth - c.mqtt.publishTopic = strings.Replace(c.mqtt.publishTopic, "moto_id", c.motoId, 1) - c.mqtt.publishTopic = strings.Replace(c.mqtt.publishTopic, "{device_id}", c.deviceId, 1) + c.publishTopic = hubConfig.PublishTopic + c.subscribeTopic = hubConfig.SubscribeTopic - parts := strings.Split(c.mqtt.subscribeTopic, "/") - c.mqtt.uid = parts[3] + 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). @@ -99,113 +111,27 @@ func (c *TuyaClient) StartMQTT() error { SetOnConnectHandler(c.onConnect). SetConnectTimeout(10 * time.Second) - c.mqtt.client = mqtt.NewClient(opts) + c.client = mqtt.NewClient(opts) - if token := c.mqtt.client.Connect(); token.Wait() && token.Error() != nil { + if token := c.client.Connect(); token.Wait() && token.Error() != nil { return token.Error() } - if err := c.mqtt.waiter.Wait(); err != nil { + if err := c.waiter.Wait(); err != nil { return err } return nil } -func (c *TuyaClient) StopMQTT() { - if c.mqtt.client != nil { - _ = c.sendDisconnect() - c.mqtt.client.Disconnect(1000) +func (c *TuyaMqttClient) Stop() { + if c.client != nil { + _ = c.SendDisconnect() + c.client.Disconnect(1000) } } -func (c *TuyaClient) onConnect(client mqtt.Client) { - if token := client.Subscribe(c.mqtt.subscribeTopic, 1, c.consume); token.Wait() && token.Error() != nil { - c.mqtt.waiter.Done(token.Error()) - return - } - - c.mqtt.waiter.Done(nil) -} - -func (c *TuyaClient) consume(client mqtt.Client, msg mqtt.Message) { - var rmqtt MqttMessage - if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil { - c.mqtt.onError(err) - return - } - - if rmqtt.Data.Header.SessionID != c.sessionId { - return - } - - switch rmqtt.Data.Header.Type { - case "answer": - c.mqtt.onMqttAnswer(&rmqtt) - case "candidate": - c.mqtt.onMqttCandidate(&rmqtt) - case "disconnect": - c.mqtt.onMqttDisconnect() - } -} - -func (c *TuyaMQTT) 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 *TuyaMQTT) onMqttCandidate(msg *MqttMessage) { - var candidateFrame CandidateFrame - if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { - c.onError(err) - return - } - - // candidate from device start with "a=", end with "\r\n", which are not needed by Chrome webRTC - candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=") - candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n") - - c.onCandidate(candidateFrame) -} - -func (c *TuyaMQTT) onMqttDisconnect() { - c.closed = true - c.onDisconnect() -} - -func (c *TuyaMQTT) onAnswer(answer AnswerFrame) { - if c.handleAnswer != nil { - c.handleAnswer(answer) - } -} - -func (c *TuyaMQTT) onCandidate(candidate CandidateFrame) { - if c.handleCandidate != nil { - c.handleCandidate(candidate) - } -} - -func (c *TuyaMQTT) onDisconnect() { - if c.handleDisconnect != nil { - c.handleDisconnect() - } -} - -func (c *TuyaMQTT) onError(err error) { - if c.handleError != nil { - c.handleError(err) - } -} - -func (c *TuyaClient) sendOffer(sdp string, streamResolution string) error { - streamType := c.getStreamType(streamResolution) - isHEVC := c.isHEVC(streamType) - +func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error { if isHEVC { // On HEVC we use streamType 0 for main stream (hd) and 1 for sub stream (sd) if streamResolution == "hd" { @@ -224,40 +150,125 @@ func (c *TuyaClient) sendOffer(sdp string, streamResolution string) error { }) } -func (c *TuyaClient) sendCandidate(candidate string) error { +func (c *TuyaMqttClient) SendCandidate(candidate string) error { return c.sendMqttMessage("candidate", 302, "", CandidateFrame{ Mode: "webrtc", Candidate: candidate, }) } -// func (c *TuyaClient) sendResolution(resolution int) error { -// isClaritySupperted := (c.skill.WebRTC & (1 << 5)) != 0 -// if !isClaritySupperted { -// return nil -// } +func (c *TuyaMqttClient) SendResolution(resolution int) error { + // isClaritySupperted := (c.webrtcVersion & (1 << 5)) != 0 + // if !isClaritySupperted { + // return nil + // } -// return c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ -// Mode: "webrtc", -// Value: resolution, -// }) -// } + // Protocol 312 is used for clarity + return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{ + Mode: "webrtc", + Value: resolution, + }) +} -func (c *TuyaClient) sendSpeaker(speaker int) error { - return c.sendMqttMessage("speaker", 302, "", SpeakerFrame{ +func (c *TuyaMqttClient) SendSpeaker(speaker int) error { + // Protocol 312 is used for speaker + return c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ Mode: "webrtc", Value: speaker, }) } -func (c *TuyaClient) sendDisconnect() error { +func (c *TuyaMqttClient) SendDisconnect() error { return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ Mode: "webrtc", }) } -func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { - if c.mqtt.closed { +func (c *TuyaMqttClient) onConnect(client mqtt.Client) { + if token := client.Subscribe(c.subscribeTopic, 1, c.consume); token.Wait() && token.Error() != nil { + c.waiter.Done(token.Error()) + return + } + + c.waiter.Done(nil) +} + +func (c *TuyaMqttClient) consume(client mqtt.Client, msg mqtt.Message) { + var rmqtt MqttMessage + if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil { + c.onError(err) + return + } + + 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() + } +} + +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 + } + + // candidate from device start with "a=", end with "\r\n", which are not needed by Chrome webRTC + 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) 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") } @@ -273,7 +284,7 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti Data: MqttFrame{ Header: MqttFrameHeader{ Type: messageType, - From: c.mqtt.uid, + From: c.uid, To: c.deviceId, SessionID: c.sessionId, MotoID: c.motoId, @@ -288,7 +299,7 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti return err } - token := c.mqtt.client.Publish(c.mqtt.publishTopic, 1, false, payload) + token := c.client.Publish(c.publishTopic, 1, false, payload) if token.Wait() && token.Error() != nil { return token.Error() } diff --git a/pkg/tuya/sharing_api.go b/pkg/tuya/sharing_api.go new file mode 100644 index 00000000..88ec223d --- /dev/null +++ b/pkg/tuya/sharing_api.go @@ -0,0 +1,473 @@ +package tuya + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + TUYA_HOST = "apigw.iotbing.com" + TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" + TUYA_SCHEMA = "haauthorize" +) + +type OpenApiMQTTConfig struct { + ClientID string `json:"clientId"` + ExpireTime int `json:"expireTime"` + Password string `json:"password"` + Topic struct { + DevID struct { + Pub string `json:"pub"` + Sub string `json:"sub"` + } `json:"devId"` + OwnerID struct { + Sub string `json:"sub"` + } `json:"ownerId"` + } `json:"topic"` + URL string `json:"url"` + Username string `json:"username"` +} + +type OpenApiMQTTConfigRequest struct { + LinkID string `json:"linkId"` +} + +type OpenApiMQTTConfigResponse struct { + Success bool `json:"success"` + Result OpenApiMQTTConfig `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type TokenInfo struct { + AccessToken string `json:"access_token"` + ExpireTime int64 `json:"expire_time"` + RefreshToken string `json:"refresh_token"` +} + +type LoginResult struct { + AccessToken string `json:"access_token"` + Endpoint string `json:"endpoint"` + ExpireTime int64 `json:"expire_time"` // seconds + RefreshToken string `json:"refresh_token"` + TerminalID string `json:"terminal_id"` + UID string `json:"uid"` + Username string `json:"username"` +} + +type LoginResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result LoginResult `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type QRResponse struct { + Success bool `json:"success"` + Result struct { + Code string `json:"qrcode"` + } `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type Home struct { + ID int `json:"id"` + Name string `json:"name"` + OwnerID string `json:"ownerId"` + Background string `json:"background"` + GeoName string `json:"geoName"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + GmtCreate int64 `json:"gmtCreate"` + GmtModified int64 `json:"gmtModified"` + GroupID int64 `json:"groupId"` + Status bool `json:"status"` + UID string `json:"uid"` +} + +type HomesResponse struct { + Success bool `json:"success"` + Result []Home `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type DeviceFunction struct { + Code string `json:"code"` + Desc string `json:"desc"` + Name string `json:"name"` + Type string `json:"type"` + Values map[string]any `json:"values"` +} + +type DeviceStatusRange struct { + Code string `json:"code"` + Type string `json:"type"` + Values map[string]any `json:"values"` +} + +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + LocalKey string `json:"local_key"` + Category string `json:"category"` + ProductID string `json:"product_id"` + ProductName string `json:"product_name"` + Sub bool `json:"sub"` + UUID string `json:"uuid"` + AssetID string `json:"asset_id"` + Online bool `json:"online"` + Icon string `json:"icon"` + IP string `json:"ip"` + TimeZone string `json:"time_zone"` + ActiveTime int64 `json:"active_time"` + CreateTime int64 `json:"create_time"` + UpdateTime int64 `json:"update_time"` +} + +type DeviceRequest struct { + HomeID string `json:"homeId"` +} + +type DeviceResponse struct { + Success bool `json:"success"` + Result []Device `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type TuyaOpenApiClient struct { + TuyaClient + terminalId string + refreshingToken bool +} + +func NewTuyaOpenApiClient( + baseUrl string, + uid string, + deviceId string, + terminalId string, + tokenInfoOrString any, + streamMode string, +) (*TuyaOpenApiClient, error) { + tokenInfo, err := ParseTokenInfo(tokenInfoOrString) + if err != nil { + return nil, fmt.Errorf("failed to parse token info: %w", err) + } + + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaOpenApiClient{ + TuyaClient: TuyaClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + mqtt: mqttClient, + uid: uid, + deviceId: deviceId, + accessToken: tokenInfo.AccessToken, + refreshToken: tokenInfo.RefreshToken, + expireTime: tokenInfo.ExpireTime, + streamMode: streamMode, + baseUrl: baseUrl, + }, + terminalId: terminalId, + refreshingToken: false, + } + + return client, nil +} + +// WebRTC Flow (not supported yet) +func (c *TuyaOpenApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + return fmt.Errorf("stream mode %s is not supported", c.streamMode) +} + +func (c *TuyaOpenApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + if err := c.initToken(); err != nil { + return "", fmt.Errorf("failed to initialize token: %w", err) + } + + urlPath := fmt.Sprintf("/v1.0/m/ipc/%s/stream/actions/allocate", c.deviceId) + + request := &AllocateRequest{ + Type: streamType, + } + + body, err := c.request("POST", urlPath, nil, request) + if err != nil { + return "", err + } + + var allocResponse AllocateResponse + err = json.Unmarshal(body, &allocResponse) + if err != nil { + return "", err + } + + if !allocResponse.Success { + return "", fmt.Errorf(allocResponse.Msg) + } + + return allocResponse.Result.URL, nil +} + +func (c *TuyaOpenApiClient) GetAllDevices() ([]Device, error) { + homes, err := c.queryHomes() + if err != nil { + return nil, err + } + + time.Sleep(500 * time.Millisecond) + deviceMap := make(map[string]Device) + + for i, home := range homes { + if i > 0 { + time.Sleep(300 * time.Millisecond) + } + + devices, err := c.queryDevicesByHome(home.OwnerID) + if err != nil { + return nil, err + } + + for _, device := range devices { + // https://github.com/home-assistant/core/blob/088cfc3576e0018ad1df373c08549092918e6530/homeassistant/components/tuya/camera.py#L19 + if device.Category == "sp" || device.Category == "dghsxj" { + deviceMap[device.ID] = device + } + } + } + + var devices []Device + for _, device := range deviceMap { + devices = append(devices, device) + } + + return devices, nil +} + +func (c *TuyaOpenApiClient) loadHubConfig() (config *MQTTConfig, err error) { + request := OpenApiMQTTConfigRequest{ + LinkID: fmt.Sprintf("tuya-device-sharing-sdk-go.%s", uuid.New().String()), + } + + body, err := c.request("POST", "/v1.0/m/life/ha/access/config", nil, request) + if err != nil { + return nil, err + } + + var mqttConfigResponse OpenApiMQTTConfigResponse + if err := json.Unmarshal(body, &mqttConfigResponse); err != nil { + return nil, err + } + + if !mqttConfigResponse.Success { + return nil, fmt.Errorf("failed to get MQTT config: %s", mqttConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: mqttConfigResponse.Result.URL, + Username: mqttConfigResponse.Result.Username, + Password: mqttConfigResponse.Result.Password, + ClientID: mqttConfigResponse.Result.ClientID, + PublishTopic: mqttConfigResponse.Result.Topic.DevID.Pub, + SubscribeTopic: mqttConfigResponse.Result.Topic.DevID.Sub, + }, nil +} + +func (c *TuyaOpenApiClient) queryHomes() ([]Home, error) { + body, err := c.request("GET", "/v1.0/m/life/users/homes", nil, nil) + if err != nil { + return nil, err + } + + var homesResponse HomesResponse + if err := json.Unmarshal(body, &homesResponse); err != nil { + return nil, err + } + + if !homesResponse.Success { + return nil, fmt.Errorf("failed to get homes: %s", homesResponse.Msg) + } + + return homesResponse.Result, nil +} + +func (c *TuyaOpenApiClient) queryDevicesByHome(homeID string) ([]Device, error) { + params := DeviceRequest{ + HomeID: homeID, + } + + body, err := c.request("GET", "/v1.0/m/life/ha/home/devices", params, nil) + if err != nil { + return nil, err + } + + var devicesResponse DeviceResponse + if err := json.Unmarshal(body, &devicesResponse); err != nil { + return nil, err + } + + if !devicesResponse.Success { + return nil, fmt.Errorf("failed to get devices: %s", devicesResponse.Msg) + } + + return devicesResponse.Result, nil +} + +// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py +func (c *TuyaOpenApiClient) request( + method string, + path string, + params any, + body any, +) ([]byte, error) { + rid := uuid.New().String() + sid := "" + + md5Hash := md5.New() + ridRefreshToken := rid + c.refreshToken + md5Hash.Write([]byte(ridRefreshToken)) + hashKey := hex.EncodeToString(md5Hash.Sum(nil)) + secret := SecretGenerating(rid, sid, hashKey) + + queryEncdata := "" + var reqURL string + if params != nil { + jsonData := FormToJSON(params) + + encryptedData, err := AesGCMEncrypt(jsonData, secret) + if err != nil { + return nil, err + } + + queryEncdata = encryptedData + reqURL = fmt.Sprintf("https://%s%s?encdata=%s", c.baseUrl, path, queryEncdata) + } else { + reqURL = fmt.Sprintf("https://%s%s", c.baseUrl, path) + } + + bodyEncdata := "" + var reqBody io.Reader + if body != nil { + jsonData := FormToJSON(body) + + encryptedData, err := AesGCMEncrypt(jsonData, secret) + if err != nil { + return nil, err + } + + bodyEncdata = encryptedData + encBody := map[string]string{"encdata": bodyEncdata} + bodyBytes, _ := json.Marshal(encBody) + reqBody = strings.NewReader(string(bodyBytes)) + } + + req, err := http.NewRequest(method, reqURL, reqBody) + if err != nil { + return nil, err + } + + t := time.Now().Add(2*time.Second).UnixNano() / int64(time.Millisecond) + headers := map[string]string{ + "X-appKey": TUYA_CLIENT_ID, + "X-requestId": rid, + "X-sid": sid, + "X-time": fmt.Sprintf("%d", t), + "Content-Type": "application/json", + } + + if c.accessToken != "" { + headers["X-token"] = c.accessToken + } + + sign := RestfulSign(hashKey, queryEncdata, bodyEncdata, headers) + headers["X-sign"] = sign + + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var resultObj map[string]any + if err := json.Unmarshal(respBody, &resultObj); err != nil { + return nil, err + } + + if resultStr, ok := resultObj["result"].(string); ok { + decrypted, err := AesGCMDecrypt(resultStr, secret) + if err != nil { + return nil, err + } + + var decryptedObj any + if err := json.Unmarshal([]byte(decrypted), &decryptedObj); err == nil { + resultObj["result"] = decryptedObj + } else { + resultObj["result"] = decrypted + } + + updatedResponse, err := json.Marshal(resultObj) + if err != nil { + return nil, fmt.Errorf("failed to marshal updated response: %w", err) + } + + return updatedResponse, nil + } + + return respBody, nil +} + +func (c *TuyaOpenApiClient) initToken() error { + if c.refreshingToken { + return nil + } + + now := time.Now().Unix() + if (c.expireTime - 60) > now { + return nil + } + + c.refreshingToken = true + + urlPath := fmt.Sprintf("/v1.0/m/token/%s", c.refreshToken) + + body, err := c.request("GET", urlPath, nil, nil) + if err != nil { + return err + } + + var loginResponse LoginResponse + if err := json.Unmarshal(body, &loginResponse); err != nil { + return err + } + + if !loginResponse.Success { + return fmt.Errorf("failed to get token: %s", loginResponse.Msg) + } + + c.accessToken = loginResponse.Result.AccessToken + c.refreshToken = loginResponse.Result.RefreshToken + c.expireTime = loginResponse.Timestamp + loginResponse.Result.ExpireTime + c.refreshingToken = false + + return nil +} diff --git a/www/add.html b/www/add.html index c8808736..f16d4d45 100644 --- a/www/add.html +++ b/www/add.html @@ -28,6 +28,7 @@ } + @@ -280,6 +281,110 @@ document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth); + +
+

Attention: Cameras added through QR Code does not support webrtc mode!

+
+ + +
+ + + +
+
+ +