- support adding cameras via interface

- support qr code auth
- support resolution change
- support h265
- refactor code
This commit is contained in:
seydx
2025-05-22 00:05:49 +02:00
parent 67dfc942a0
commit 998c85d6f5
12 changed files with 2003 additions and 904 deletions
+40 -19
View File
@@ -568,39 +568,60 @@ Tested: KD110, KC200, KC401, KC420WS, EC71.
#### Source: Tuya #### 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/). - 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) **Open API**:
- `rtsp` - RTSP stream _(if available)_ - To get your user code, open the Tuya Smart app or Smart Life app and go to `Profile` > `Settings` > `Account and Security` > `User Code`
- `hls` - HLS stream _(if available)_ - 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.
- Use `resolution` parameter to select the stream:
**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) - `hd` - HD stream (default)
- `sd` - SD stream - `sd` - SD stream
```yaml ```yaml
streams: streams:
# Tuya WebRTC stream # Cloud API: WebRTC stream
tuya_webrtc: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX tuya_webrtc:
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX
# Tuya WebRTC stream (same as above) # 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_2:
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&mode=webrtc
# Tuya WebRTC stream (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_hd:
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=hd
# Tuya WebRTC stream (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 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) # 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 tuya_rtsp:
- tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp
# Using HLS when available (no "uid" required) # 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 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 #### Source: GoPro
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)*
+201
View File
@@ -1,13 +1,214 @@
package tuya package tuya
import ( 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/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/tuya" "github.com/AlexxIT/go2rtc/pkg/tuya"
) )
var users = make(map[string]tuya.LoginResponse)
func Init() { func Init() {
streams.HandleFunc("tuya", func(source string) (core.Producer, error) { streams.HandleFunc("tuya", func(source string) (core.Producer, error) {
return tuya.Dial(source) 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
} }
+2 -1
View File
@@ -3,6 +3,7 @@
- https://developer.tuya.com/en/docs/iot/webrtc?id=Kacsd4x2hl0se - https://developer.tuya.com/en/docs/iot/webrtc?id=Kacsd4x2hl0se
- https://github.com/tuya/webrtc-demo-go - 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/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://ipc-us.ismartlife.me/
- https://protect-us.ismartlife.me/ - https://protect-us.ismartlife.me/
- https://github.com/tuya/tuya-device-sharing-sdk
-507
View File
@@ -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
}
+103 -86
View File
@@ -15,12 +15,13 @@ import (
) )
type Client struct { type Client struct {
api *TuyaClient api TuyaAPI
conn *webrtc.Conn conn *webrtc.Conn
pc *pion.PeerConnection pc *pion.PeerConnection
dc *pion.DataChannel dc *pion.DataChannel
videoSSRC uint32 videoSSRC uint32
audioSSRC uint32 audioSSRC uint32
streamType int
isHEVC bool isHEVC bool
connected core.Waiter connected core.Waiter
closed bool closed bool
@@ -41,15 +42,6 @@ type RecvMessage struct {
} `json:"audio"` } `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) { func Dial(rawURL string) (core.Producer, error) {
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
@@ -57,85 +49,88 @@ func Dial(rawURL string) (core.Producer, error) {
} }
query := u.Query() 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") clientId := query.Get("client_id")
clientSecret := query.Get("client_secret") clientSecret := query.Get("client_secret")
// Shared params
deviceId := query.Get("device_id")
uid := query.Get("uid")
// Stream params
streamResolution := query.Get("resolution") streamResolution := query.Get("resolution")
streamMode := query.Get("mode") streamMode := query.Get("mode")
useOpenApi := deviceId != "" && uid != "" && tokenInfo != "" && terminalId != ""
useCloudApi := deviceId != "" && ((streamMode == "webrtc" || streamMode == "") && uid != "") && clientId != "" && clientSecret != ""
if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") {
streamResolution = "hd" streamResolution = "hd"
} }
useRTSP := streamMode == "rtsp" if streamMode == "" || (streamMode != "rtsp" && streamMode != "hls" && streamMode != "flv" && streamMode != "rtmp" && streamMode != "webrtc") {
useHLS := streamMode == "hls" if useOpenApi {
useWebRTC := streamMode == "webrtc" || streamMode == "" streamMode = "rtsp"
} else {
// check if host is correct streamMode = "webrtc"
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 deviceID == "" || clientId == "" || clientSecret == "" { if !useOpenApi && !useCloudApi {
return nil, errors.New("tuya: no device_id, client_id or client_secret") return nil, errors.New("tuya: wrong query params")
} }
if useWebRTC && uid == "" { client := &Client{
return nil, errors.New("tuya: no uid") handlers: make(map[uint32]func(*rtp.Packet)),
} }
if !useRTSP && !useHLS && !useWebRTC { if useOpenApi {
return nil, errors.New("tuya: wrong stream type") if client.api, err = NewTuyaOpenApiClient(u.Hostname(), uid, deviceId, terminalId, tokenInfo, streamMode); err != nil {
return nil, fmt.Errorf("tuya: %w", err)
}
} else {
if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret, streamMode); err != nil {
return nil, fmt.Errorf("tuya: %w", err)
}
} }
// Initialize Tuya API client if streamMode != "webrtc" {
tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode) streamUrl, err := client.api.GetStreamUrl(streamMode)
if err != nil { if err != nil {
return nil, fmt.Errorf("tuya: %w", err) return nil, fmt.Errorf("tuya: %w", err)
} }
client := &Client{ return streams.GetProducer(streamUrl)
api: tuyaAPI,
handlers: make(map[uint32]func(*rtp.Packet)),
} }
if useRTSP { if err := client.api.Init(); err != nil {
if client.api.rtspURL == "" { return nil, fmt.Errorf("tuya: %w", err)
return nil, errors.New("tuya: no rtsp url")
} }
return streams.GetProducer(client.api.rtspURL)
} else if useHLS { client.streamType = client.api.GetStreamType(streamResolution)
if client.api.hlsURL == "" { client.isHEVC = client.api.IsHEVC(client.streamType)
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 // Create a new PeerConnection
conf := pion.Configuration{ conf := pion.Configuration{
ICEServers: client.api.iceServers, ICEServers: client.api.GetICEServers(),
ICETransportPolicy: pion.ICETransportPolicyAll, ICETransportPolicy: pion.ICETransportPolicyAll,
BundlePolicy: pion.BundlePolicyMaxBundle, BundlePolicy: pion.BundlePolicyMaxBundle,
} }
api, err := webrtc.NewAPI() api, err := webrtc.NewAPI()
if err != nil { if err != nil {
client.Stop() client.Close(err)
return nil, err return nil, err
} }
client.pc, err = api.NewPeerConnection(conf) client.pc, err = api.NewPeerConnection(conf)
if err != nil { if err != nil {
client.Stop() client.Close(err)
return nil, err return nil, err
} }
@@ -151,8 +146,15 @@ func Dial(rawURL string) (core.Producer, error) {
client.conn.Mode = core.ModeActiveProducer client.conn.Mode = core.ModeActiveProducer
client.conn.Protocol = "mqtt" 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 // Set up MQTT handlers
client.api.mqtt.handleAnswer = func(answer AnswerFrame) { mqttClient.handleAnswer = func(answer AnswerFrame) {
// fmt.Printf("tuya: answer: %s\n", answer.Sdp) // fmt.Printf("tuya: answer: %s\n", answer.Sdp)
desc := pion.SessionDescription{ desc := pion.SessionDescription{
@@ -161,12 +163,12 @@ func Dial(rawURL string) (core.Producer, error) {
} }
if err = client.pc.SetRemoteDescription(desc); err != nil { if err = client.pc.SetRemoteDescription(desc); err != nil {
client.connected.Done(err) client.Close(err)
return return
} }
if err = client.conn.SetAnswer(answer.Sdp); err != nil { if err = client.conn.SetAnswer(answer.Sdp); err != nil {
client.connected.Done(err) client.Close(err)
return return
} }
@@ -175,7 +177,7 @@ func Dial(rawURL string) (core.Producer, error) {
for _, media := range client.conn.Medias { for _, media := range client.conn.Medias {
if media.Kind == core.KindVideo { if media.Kind == core.KindVideo {
codecs := client.api.getVideoCodecs() codecs := client.api.GetVideoCodecs()
if codecs != nil { if codecs != nil {
media.Codecs = codecs media.Codecs = codecs
} }
@@ -184,7 +186,7 @@ func Dial(rawURL string) (core.Producer, error) {
for _, media := range client.conn.Medias { for _, media := range client.conn.Medias {
if media.Kind == core.KindAudio { if media.Kind == core.KindAudio {
codecs := client.api.getAudioCodecs() codecs := client.api.GetAudioCodecs()
if codecs != nil { if codecs != nil {
media.Codecs = codecs media.Codecs = codecs
} }
@@ -193,25 +195,25 @@ func Dial(rawURL string) (core.Producer, error) {
} }
} }
client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { mqttClient.handleCandidate = func(candidate CandidateFrame) {
// fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate)
if candidate.Candidate != "" { if candidate.Candidate != "" {
client.conn.AddCandidate(candidate.Candidate) client.conn.AddCandidate(candidate.Candidate)
if err != nil { if err != nil {
client.Stop() client.Close(err)
} }
} }
} }
client.api.mqtt.handleDisconnect = func() { mqttClient.handleDisconnect = func() {
// fmt.Println("tuya: disconnect") // fmt.Println("tuya: disconnect")
client.Stop() client.Close(errors.New("mqtt: disconnect"))
} }
client.api.mqtt.handleError = func(err error) { mqttClient.handleError = func(err error) {
// fmt.Printf("tuya: error: %s\n", err.Error()) // fmt.Printf("tuya: error: %s\n", err.Error())
client.Stop() client.Close(err)
} }
// On HEVC, use DataChannel to receive video/audio // On HEVC, use DataChannel to receive video/audio
@@ -227,7 +229,11 @@ func Dial(rawURL string) (core.Producer, error) {
// Set up data channel handler // Set up data channel handler
client.dc.OnMessage(func(msg pion.DataChannelMessage) { client.dc.OnMessage(func(msg pion.DataChannelMessage) {
if msg.IsString { if msg.IsString {
client.probe(msg) if connected, err := client.probe(msg); err != nil {
client.Close(err)
} else if connected {
client.connected.Done(nil)
}
} else { } else {
packet := &rtp.Packet{} packet := &rtp.Packet{}
if err := packet.Unmarshal(msg.Data); err != nil { if err := packet.Unmarshal(msg.Data); err != nil {
@@ -243,12 +249,12 @@ func Dial(rawURL string) (core.Producer, error) {
client.dc.OnError(func(err error) { client.dc.OnError(func(err error) {
// fmt.Printf("tuya: datachannel error: %s\n", err.Error()) // fmt.Printf("tuya: datachannel error: %s\n", err.Error())
client.connected.Done(err) client.Close(err)
}) })
client.dc.OnClose(func() { client.dc.OnClose(func() {
// fmt.Println("tuya: datachannel closed") // fmt.Println("tuya: datachannel closed")
client.connected.Done(errors.New("datachannel: closed")) client.Close(errors.New("datachannel: closed"))
}) })
client.dc.OnOpen(func() { client.dc.OnOpen(func() {
@@ -260,7 +266,7 @@ func Dial(rawURL string) (core.Producer, error) {
}) })
if err := client.sendMessageToDataChannel(codecRequest); err != nil { if err := client.sendMessageToDataChannel(codecRequest); err != nil {
client.connected.Done(fmt.Errorf("failed to send codec request: %w", err)) client.Close(fmt.Errorf("failed to send codec request: %w", err))
} }
}) })
} }
@@ -270,8 +276,8 @@ func Dial(rawURL string) (core.Producer, error) {
switch msg := msg.(type) { switch msg := msg.(type) {
case *pion.ICECandidate: case *pion.ICECandidate:
_ = sendOffer.Wait() _ = sendOffer.Wait()
if err := client.api.sendCandidate("a=" + msg.ToJSON().Candidate); err != nil { if err := mqttClient.SendCandidate("a=" + msg.ToJSON().Candidate); err != nil {
client.connected.Done(err) client.Close(err)
} }
case pion.PeerConnectionState: case pion.PeerConnectionState:
@@ -283,11 +289,13 @@ func Dial(rawURL string) (core.Producer, error) {
case pion.PeerConnectionStateConnected: case pion.PeerConnectionStateConnected:
// On HEVC, wait for DataChannel to be opened and camera to send codec info // On HEVC, wait for DataChannel to be opened and camera to send codec info
if !client.isHEVC { if !client.isHEVC {
if streamResolution == "hd" {
_ = mqttClient.SendResolution(0)
}
client.connected.Done(nil) client.connected.Done(nil)
} }
default: default:
client.Stop() client.Close(errors.New("webrtc: " + msg.String()))
client.connected.Done(errors.New("webrtc: " + msg.String()))
} }
} }
}) })
@@ -301,7 +309,7 @@ func Dial(rawURL string) (core.Producer, error) {
// Create offer // Create offer
offer, err := client.conn.CreateOffer(medias) offer, err := client.conn.CreateOffer(medias)
if err != nil { if err != nil {
client.Stop() client.Close(err)
return nil, err return nil, err
} }
@@ -311,21 +319,23 @@ func Dial(rawURL string) (core.Producer, error) {
offer = re.ReplaceAllString(offer, "") offer = re.ReplaceAllString(offer, "")
// Send offer // Send offer
if err := client.api.sendOffer(offer, streamResolution); err != nil { if err := mqttClient.SendOffer(offer, streamResolution, client.streamType, client.isHEVC); err != nil {
client.Stop() err = fmt.Errorf("tuya: %w", err)
return nil, fmt.Errorf("tuya: %w", err) client.Close(err)
return nil, err
} }
sendOffer.Done(nil) sendOffer.Done(nil)
// Wait for connection // Wait for connection
if err = client.connected.Wait(); err != nil { if err = client.connected.Wait(); err != nil {
return nil, fmt.Errorf("tuya: %w", err) err = fmt.Errorf("tuya: %w", err)
client.Close(err)
return nil, err
} }
return client, nil return client, nil
} }
}
func (c *Client) GetMedias() []*core.Media { func (c *Client) GetMedias() []*core.Media {
return c.conn.GetMedias() return c.conn.GetMedias()
@@ -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") 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 payloadType := codec.PayloadType
@@ -411,22 +424,25 @@ func (c *Client) Stop() error {
return nil return nil
} }
func (c *Client) Close(err error) error {
c.connected.Done(err)
return c.Stop()
}
func (c *Client) MarshalJSON() ([]byte, error) { func (c *Client) MarshalJSON() ([]byte, error) {
return c.conn.MarshalJSON() 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)) // fmt.Printf("[tuya] Received string message: %s\n", string(msg.Data))
var message DataChannelMessage var message DataChannelMessage
if err := json.Unmarshal([]byte(msg.Data), &message); err != nil { 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 { switch message.Type {
case "codec": case "codec":
// fmt.Printf("[tuya] Codec info from camera: %s\n", message.Msg)
frameRequest, _ := json.Marshal(DataChannelMessage{ frameRequest, _ := json.Marshal(DataChannelMessage{
Type: "start", Type: "start",
Msg: "frame", Msg: "frame",
@@ -434,14 +450,13 @@ func (c *Client) probe(msg pion.DataChannelMessage) {
err := c.sendMessageToDataChannel(frameRequest) err := c.sendMessageToDataChannel(frameRequest)
if err != nil { if err != nil {
c.connected.Done(fmt.Errorf("failed to send frame request: %w", err)) return false, err
} }
case "recv": case "recv":
var recvMessage RecvMessage var recvMessage RecvMessage
if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil { if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil {
c.connected.Done(fmt.Errorf("failed to parse recv message: %w", err)) return false, err
return
} }
c.videoSSRC = recvMessage.Video.SSRC c.videoSSRC = recvMessage.Video.SSRC
@@ -454,11 +469,13 @@ func (c *Client) probe(msg pion.DataChannelMessage) {
err := c.sendMessageToDataChannel(completeMsg) err := c.sendMessageToDataChannel(completeMsg)
if err != nil { 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 { func (c *Client) sendMessageToDataChannel(message []byte) error {
+312
View File
@@ -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
}
+134
View File
@@ -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)
}
+72
View File
@@ -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 = &copyOfV
default:
return nil, fmt.Errorf("invalid type: %T", v)
}
if tokenInfo == nil {
return nil, fmt.Errorf("token info is nil")
}
return tokenInfo, nil
}
+259
View File
@@ -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
}
}
+136 -125
View File
@@ -10,13 +10,18 @@ import (
mqtt "github.com/eclipse/paho.mqtt.golang" mqtt "github.com/eclipse/paho.mqtt.golang"
) )
type TuyaMQTT struct { type TuyaMqttClient struct {
client mqtt.Client client mqtt.Client
waiter core.Waiter waiter core.Waiter
publishTopic string publishTopic string
subscribeTopic string subscribeTopic string
auth string
uid string uid string
motoId string
deviceId string
sessionId string
closed bool closed bool
webrtcVersion int
handleAnswer func(answer AnswerFrame) handleAnswer func(answer AnswerFrame)
handleCandidate func(candidate CandidateFrame) handleCandidate func(candidate CandidateFrame)
handleDisconnect func() handleDisconnect func()
@@ -56,14 +61,14 @@ type CandidateFrame struct {
Candidate string `json:"candidate"` Candidate string `json:"candidate"`
} }
// type ResolutionFrame struct { type ResolutionFrame struct {
// Mode string `json:"mode"` Mode string `json:"mode"`
// Value int `json:"value"` // 0: HD, 1: SD Value int `json:"cmdValue"` // 0: HD, 1: SD
// } }
type SpeakerFrame struct { type SpeakerFrame struct {
Mode string `json:"mode"` Mode string `json:"mode"`
Value int `json:"value"` // 0: off, 1: on Value int `json:"cmdValue"` // 0: off, 1: on
} }
type DisconnectFrame struct { type DisconnectFrame struct {
@@ -77,20 +82,27 @@ type MqttMessage struct {
Data MqttFrame `json:"data"` Data MqttFrame `json:"data"`
} }
func (c *TuyaClient) StartMQTT() error { func NewTuyaMqttClient(deviceId string) *TuyaMqttClient {
hubConfig, err := c.LoadHubConfig() return &TuyaMqttClient{
if err != nil { deviceId: deviceId,
return err sessionId: core.RandString(6, 62),
waiter: core.Waiter{},
}
} }
c.mqtt.publishTopic = hubConfig.SinkTopic.IPC func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig, webrtcVersion int) error {
c.mqtt.subscribeTopic = hubConfig.SourceSink.IPC 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.publishTopic = hubConfig.PublishTopic
c.mqtt.publishTopic = strings.Replace(c.mqtt.publishTopic, "{device_id}", c.deviceId, 1) c.subscribeTopic = hubConfig.SubscribeTopic
parts := strings.Split(c.mqtt.subscribeTopic, "/") c.publishTopic = strings.Replace(c.publishTopic, "moto_id", c.motoId, 1)
c.mqtt.uid = parts[3] 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). opts := mqtt.NewClientOptions().AddBroker(hubConfig.Url).
SetClientID(hubConfig.ClientID). SetClientID(hubConfig.ClientID).
@@ -99,113 +111,27 @@ func (c *TuyaClient) StartMQTT() error {
SetOnConnectHandler(c.onConnect). SetOnConnectHandler(c.onConnect).
SetConnectTimeout(10 * time.Second) 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() return token.Error()
} }
if err := c.mqtt.waiter.Wait(); err != nil { if err := c.waiter.Wait(); err != nil {
return err return err
} }
return nil return nil
} }
func (c *TuyaClient) StopMQTT() { func (c *TuyaMqttClient) Stop() {
if c.mqtt.client != nil { if c.client != nil {
_ = c.sendDisconnect() _ = c.SendDisconnect()
c.mqtt.client.Disconnect(1000) c.client.Disconnect(1000)
} }
} }
func (c *TuyaClient) onConnect(client mqtt.Client) { func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error {
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)
if isHEVC { if isHEVC {
// On HEVC we use streamType 0 for main stream (hd) and 1 for sub stream (sd) // On HEVC we use streamType 0 for main stream (hd) and 1 for sub stream (sd)
if streamResolution == "hd" { 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{ return c.sendMqttMessage("candidate", 302, "", CandidateFrame{
Mode: "webrtc", Mode: "webrtc",
Candidate: candidate, Candidate: candidate,
}) })
} }
// func (c *TuyaClient) sendResolution(resolution int) error { func (c *TuyaMqttClient) SendResolution(resolution int) error {
// isClaritySupperted := (c.skill.WebRTC & (1 << 5)) != 0 // isClaritySupperted := (c.webrtcVersion & (1 << 5)) != 0
// if !isClaritySupperted { // if !isClaritySupperted {
// return nil // return nil
// } // }
// return c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ // Protocol 312 is used for clarity
// Mode: "webrtc", return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{
// Value: resolution, Mode: "webrtc",
// }) Value: resolution,
// } })
}
func (c *TuyaClient) sendSpeaker(speaker int) error { func (c *TuyaMqttClient) SendSpeaker(speaker int) error {
return c.sendMqttMessage("speaker", 302, "", SpeakerFrame{ // Protocol 312 is used for speaker
return c.sendMqttMessage("speaker", 312, "", SpeakerFrame{
Mode: "webrtc", Mode: "webrtc",
Value: speaker, Value: speaker,
}) })
} }
func (c *TuyaClient) sendDisconnect() error { func (c *TuyaMqttClient) SendDisconnect() error {
return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{
Mode: "webrtc", Mode: "webrtc",
}) })
} }
func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { func (c *TuyaMqttClient) onConnect(client mqtt.Client) {
if c.mqtt.closed { 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") 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{ Data: MqttFrame{
Header: MqttFrameHeader{ Header: MqttFrameHeader{
Type: messageType, Type: messageType,
From: c.mqtt.uid, From: c.uid,
To: c.deviceId, To: c.deviceId,
SessionID: c.sessionId, SessionID: c.sessionId,
MotoID: c.motoId, MotoID: c.motoId,
@@ -288,7 +299,7 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti
return err 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 { if token.Wait() && token.Error() != nil {
return token.Error() return token.Error()
} }
+473
View File
@@ -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
}
+105
View File
@@ -28,6 +28,7 @@
} }
</style> </style>
<script src="https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js"></script>
</head> </head>
<body> <body>
<script src="main.js"></script> <script src="main.js"></script>
@@ -280,6 +281,110 @@
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth); document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
</script> </script>
<button id="tuya">Tuya</button>
<div class="module">
<p style="font-size: 0.9rem">Attention: Cameras added through QR Code does not support webrtc mode!</p>
<form id="tuya-qr-form" style="margin-bottom: 10px">
<input id="tuya-user-code" type="text" name="user_code" placeholder="User Code">
<input type="submit" value="Generate QR Code">
</form>
<form id="tuya-login-form" style="margin-bottom: 10px; display: none">
<div id="qrcode"></div>
<input type="submit" value="Login">
</form>
<table id="tuya-table"></table>
</div>
<script>
document.getElementById('tuya').addEventListener('click', async ev => {
document.getElementById('qrcode').innerHTML = '';
ev.target.nextElementSibling.style.display = 'block';
});
document.getElementById('tuya-qr-form').addEventListener('submit', async ev => {
ev.preventDefault();
const table = document.getElementById('tuya-table');
const query = new URLSearchParams(new FormData(ev.target));
if (!query.has('user_code')) {
table.innerText = 'User Code is required';
return;
}
table.innerText = 'loading...';
const url = new URL('api/tuya?' + query.toString(), location.href);
const r = await fetch(url, {cache: 'no-cache'});
if (!r.ok) {
table.innerText = await r.text()
return;
}
const response = await r.json();
new QRCode(document.getElementById('qrcode'), {
text: 'tuyaSmart--qrLogin?token=' + response.qrCode,
width: 256,
height: 256,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.Q
});
table.innerText = 'Scan the QR Code with the Tuya/Smart Life app and click "Login"';
document.getElementById('tuya-qr-form').style.display = 'none';
document.getElementById('tuya-login-form').style.display = 'block';
});
document.getElementById('tuya-login-form').addEventListener('submit', async ev => {
ev.preventDefault();
const table = document.getElementById('tuya-table');
const qrcodeEl = document.getElementById('qrcode');
const userCode = document.getElementById('tuya-user-code')?.value;
const qrcode = qrcodeEl?.title;
if (!userCode) {
table.innerText = 'User Code is required! Reload the page and generate a QR Code first.';
return;
}
if (!qrcode) {
table.innerText = 'QR Code is required! Please generate a QR Code first and scan it with the Tuya/Smart Life app.';
return;
}
table.innerText = 'loading...';
const url = new URL('api/tuya', location.href);
url.searchParams.set('user_code', userCode);
url.searchParams.set('token', qrcode.replace('tuyaSmart--qrLogin?token=', ''));
const r = await fetch(url, {cache: 'no-cache'});
if (!r.ok) {
table.innerText = await r.text();
return;
}
table.innerText = '';
const data = await r.json();
document.getElementById('tuya-qr-form').style.display = 'none';
document.getElementById('tuya-login-form').style.display = 'none';
drawTable(table, data);
});
</script>
<button id="gopro">GoPro</button> <button id="gopro">GoPro</button>
<div class="module"> <div class="module">
<table id="gopro-table"></table> <table id="gopro-table"></table>