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