- 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
[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)*
+201
View File
@@ -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
}
+2 -1
View File
@@ -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
-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 {
api *TuyaClient
api TuyaAPI
conn *webrtc.Conn
pc *pion.PeerConnection
dc *pion.DataChannel
videoSSRC uint32
audioSSRC uint32
streamType int
isHEVC bool
connected core.Waiter
closed bool
@@ -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,85 +49,88 @@ 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 !useOpenApi && !useCloudApi {
return nil, errors.New("tuya: wrong query params")
}
if useWebRTC && uid == "" {
return nil, errors.New("tuya: no uid")
client := &Client{
handlers: make(map[uint32]func(*rtp.Packet)),
}
if !useRTSP && !useHLS && !useWebRTC {
return nil, errors.New("tuya: wrong stream type")
if useOpenApi {
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
tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode)
if streamMode != "webrtc" {
streamUrl, err := client.api.GetStreamUrl(streamMode)
if err != nil {
return nil, fmt.Errorf("tuya: %w", err)
}
client := &Client{
api: tuyaAPI,
handlers: make(map[uint32]func(*rtp.Packet)),
return streams.GetProducer(streamUrl)
}
if useRTSP {
if client.api.rtspURL == "" {
return nil, errors.New("tuya: no rtsp url")
if err := client.api.Init(); 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))
client.streamType = client.api.GetStreamType(streamResolution)
client.isHEVC = client.api.IsHEVC(client.streamType)
// Create a new PeerConnection
conf := pion.Configuration{
ICEServers: client.api.iceServers,
ICEServers: client.api.GetICEServers(),
ICETransportPolicy: pion.ICETransportPolicyAll,
BundlePolicy: pion.BundlePolicyMaxBundle,
}
api, err := webrtc.NewAPI()
if err != nil {
client.Stop()
client.Close(err)
return nil, err
}
client.pc, err = api.NewPeerConnection(conf)
if err != nil {
client.Stop()
client.Close(err)
return nil, err
}
@@ -151,8 +146,15 @@ func Dial(rawURL string) (core.Producer, error) {
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
client.api.mqtt.handleAnswer = func(answer AnswerFrame) {
mqttClient.handleAnswer = func(answer AnswerFrame) {
// fmt.Printf("tuya: answer: %s\n", answer.Sdp)
desc := pion.SessionDescription{
@@ -161,12 +163,12 @@ func Dial(rawURL string) (core.Producer, error) {
}
if err = client.pc.SetRemoteDescription(desc); err != nil {
client.connected.Done(err)
client.Close(err)
return
}
if err = client.conn.SetAnswer(answer.Sdp); err != nil {
client.connected.Done(err)
client.Close(err)
return
}
@@ -175,7 +177,7 @@ func Dial(rawURL string) (core.Producer, error) {
for _, media := range client.conn.Medias {
if media.Kind == core.KindVideo {
codecs := client.api.getVideoCodecs()
codecs := client.api.GetVideoCodecs()
if codecs != nil {
media.Codecs = codecs
}
@@ -184,7 +186,7 @@ func Dial(rawURL string) (core.Producer, error) {
for _, media := range client.conn.Medias {
if media.Kind == core.KindAudio {
codecs := client.api.getAudioCodecs()
codecs := client.api.GetAudioCodecs()
if codecs != nil {
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)
if candidate.Candidate != "" {
client.conn.AddCandidate(candidate.Candidate)
if err != nil {
client.Stop()
client.Close(err)
}
}
}
client.api.mqtt.handleDisconnect = func() {
mqttClient.handleDisconnect = func() {
// 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())
client.Stop()
client.Close(err)
}
// On HEVC, use DataChannel to receive video/audio
@@ -227,7 +229,11 @@ func Dial(rawURL string) (core.Producer, error) {
// Set up data channel handler
client.dc.OnMessage(func(msg pion.DataChannelMessage) {
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 {
packet := &rtp.Packet{}
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) {
// fmt.Printf("tuya: datachannel error: %s\n", err.Error())
client.connected.Done(err)
client.Close(err)
})
client.dc.OnClose(func() {
// fmt.Println("tuya: datachannel closed")
client.connected.Done(errors.New("datachannel: closed"))
client.Close(errors.New("datachannel: closed"))
})
client.dc.OnOpen(func() {
@@ -260,7 +266,7 @@ func Dial(rawURL string) (core.Producer, error) {
})
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) {
case *pion.ICECandidate:
_ = sendOffer.Wait()
if err := client.api.sendCandidate("a=" + msg.ToJSON().Candidate); err != nil {
client.connected.Done(err)
if err := mqttClient.SendCandidate("a=" + msg.ToJSON().Candidate); err != nil {
client.Close(err)
}
case pion.PeerConnectionState:
@@ -283,11 +289,13 @@ func Dial(rawURL string) (core.Producer, error) {
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.Stop()
client.connected.Done(errors.New("webrtc: " + msg.String()))
client.Close(errors.New("webrtc: " + msg.String()))
}
}
})
@@ -301,7 +309,7 @@ func Dial(rawURL string) (core.Producer, error) {
// Create offer
offer, err := client.conn.CreateOffer(medias)
if err != nil {
client.Stop()
client.Close(err)
return nil, err
}
@@ -311,20 +319,22 @@ func Dial(rawURL string) (core.Producer, error) {
offer = re.ReplaceAllString(offer, "")
// Send offer
if err := client.api.sendOffer(offer, streamResolution); err != nil {
client.Stop()
return nil, fmt.Errorf("tuya: %w", err)
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 {
return nil, fmt.Errorf("tuya: %w", err)
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 {
+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
}
}
+139 -128
View File
@@ -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()
}
+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>
<script src="https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js"></script>
</head>
<body>
<script src="main.js"></script>
@@ -280,6 +281,110 @@
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
</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>
<div class="module">
<table id="gopro-table"></table>