From e74fc6f198bbb1d178b8ee004438c0eecd2e7a62 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 May 2025 18:34:02 +0200 Subject: [PATCH 001/241] add tuya source --- go.sum | 7 +- internal/tuya/tuya.go | 13 ++ main.go | 2 + pkg/tuya/api.go | 285 ++++++++++++++++++++++++++++++++++++++++++ pkg/tuya/client.go | 246 ++++++++++++++++++++++++++++++++++++ pkg/tuya/mqtt.go | 274 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 822 insertions(+), 5 deletions(-) create mode 100644 internal/tuya/tuya.go create mode 100644 pkg/tuya/api.go create mode 100644 pkg/tuya/client.go create mode 100644 pkg/tuya/mqtt.go diff --git a/go.sum b/go.sum index 7e1b0cee..4dfebcf6 100644 --- a/go.sum +++ b/go.sum @@ -7,9 +7,10 @@ github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwf github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -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= @@ -86,7 +87,6 @@ github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -98,8 +98,6 @@ github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfU github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= @@ -135,6 +133,5 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go new file mode 100644 index 00000000..c3b34e4a --- /dev/null +++ b/internal/tuya/tuya.go @@ -0,0 +1,13 @@ +package tuya + +import ( + "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) + }) +} diff --git a/main.go b/main.go index e85c5900..3bbf632f 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,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" @@ -88,6 +89,7 @@ func main() { roborock.Init() // roborock source homekit.Init() // homekit source ring.Init() // ring source + tuya.Init() // tuya source nest.Init() // nest source bubble.Init() // bubble source expr.Init() // expr source diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go new file mode 100644 index 00000000..4ea609ca --- /dev/null +++ b/pkg/tuya/api.go @@ -0,0 +1,285 @@ +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 + sessionID string + clientID string + deviceID string + accessToken string + refreshToken string + secret string + expireTime int64 + uid string + motoID string + auth string + 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"` + HardwareCapability []int `json:"hardware_capability"` +} + +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 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"` + Skill string `json:"skill"` + SupportsWebRTC bool `json:"supports_webrtc"` + VideoClaritiy int `json:"video_clarity"` +} + +type TokenResponse struct { + Result Token `json:"result"` +} + +type WebRTCConfigResponse struct { + Result WebRTConfig `json:"result"` +} + +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"` +} + +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"` +} + +const ( + defaultTimeout = 5 * time.Second +) + +func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string) (*TuyaClient, error) { + client := &TuyaClient{ + httpClient: &http.Client{Timeout: defaultTimeout}, + mqtt: &TuyaMQTT{waiter: core.Waiter{}}, + apiURL: openAPIURL, + sessionID: core.RandString(6, 62), + clientID: clientID, + deviceID: deviceID, + secret: secret, + uid: uid, + } + + if err := client.InitToken(); err != nil { + return nil, fmt.Errorf("failed to initialize token: %w", err) + } + + 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) 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, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", 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, fmt.Errorf("failed to send request: %w", err) + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed with status code %d: %s", response.StatusCode, string(res)) + } + + return res, nil +} + +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 fmt.Errorf("failed to get token: %w", err) + } + + var tokenResponse TokenResponse + err = json.Unmarshal(body, &tokenResponse) + if err != nil { + return fmt.Errorf("failed to unmarshal token response: %w", err) + } + + 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 fmt.Errorf("failed to get webrtc-configs: %w", err) + } + + var webRTCConfigResponse WebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return fmt.Errorf("failed to unmarshal webrtc-configs response: %w", err) + } + + c.motoID = webRTCConfigResponse.Result.MotoID + c.auth = webRTCConfigResponse.Result.Auth + + iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return fmt.Errorf("failed to marshal ICE servers: %w", err) + } + + + c.iceServers, err = webrtc.UnmarshalICEServers([]byte(iceServersBytes)) + if err != nil { + return fmt.Errorf("failed to unmarshal ICE servers: %w", err) + } + + 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", + } + + var openIoTHubConfigResponse OpenIoTHubConfigResponse + body, err := c.Request("POST", url, request) + if err != nil { + return nil, fmt.Errorf("failed to get OpenIoTHub config: %w", err) + } + + err = json.Unmarshal(body, &openIoTHubConfigResponse) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal OpenIoTHub config response: %w", err) + } + + if !openIoTHubConfigResponse.Success { + return nil, fmt.Errorf("failed to get OpenIoTHub config: %s", string(body)) + } + + return &openIoTHubConfigResponse.Result, nil +} + +func(c *TuyaClient) calBusinessSign(ts int64) string { + data := fmt.Sprintf("%s%s%s%d", c.clientID, c.accessToken, c.secret, ts) + val := md5.Sum([]byte(data)) + res := fmt.Sprintf("%X", val) + return res +} \ No newline at end of file diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go new file mode 100644 index 00000000..c36cd502 --- /dev/null +++ b/pkg/tuya/client.go @@ -0,0 +1,246 @@ +package tuya + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" +) + +type Client struct { + api *TuyaClient + prod core.Producer + done chan struct{} +} + +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) (*Client, error) { + // Parse URL and validate basic params + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + deviceID := query.Get("device_id") + uid := query.Get("uid") + clientID := query.Get("client_id") + secret := query.Get("secret") + resolution := query.Get("resolution") + + if deviceID == "" || uid == "" || clientID == "" || secret == "" { + return nil, errors.New("tuya: wrong query") + } + + // Initialize Tuya API client + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret) + if err != nil { + return nil, err + } + + client := &Client{ + api: tuyaAPI, + done: make(chan struct{}), + } + + conf := pion.Configuration{ + ICEServers: client.api.iceServers, + // ICETransportPolicy: pion.ICETransportPolicyAll, + // BundlePolicy: pion.BundlePolicyMaxBundle, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.api.Close() + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + client.api.Close() + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // waiter will wait PC error or WS error or nil (connection OK) + var connState core.Waiter + + prod := webrtc.NewConn(pc) + prod.FormatName = "tuya/webrtc" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "mqtt" + prod.URL = rawURL + + client.prod = prod + + // Set up MQTT handlers + client.api.mqtt.handleAnswer = func(answer AnswerFrame) { + desc := pion.SessionDescription{ + Type: pion.SDPTypePranswer, + SDP: answer.Sdp, + } + + if err = pc.SetRemoteDescription(desc); err != nil { + return + } + + prod.SetAnswer(answer.Sdp) + if err != nil { + client.Stop() + } + + prod.SDP = answer.Sdp + } + + client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { + if candidate.Candidate != "" { + prod.AddCandidate(candidate.Candidate) + if err != nil { + client.Stop() + } + } + } + + client.api.mqtt.handleDisconnect = func() { + client.Stop() + } + + client.api.mqtt.handleError = func(err error) { + fmt.Printf("Tuya error: %s\n", err.Error()) + client.Stop() + } + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + client.api.sendCandidate("a=" + msg.ToJSON().Candidate) + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateNew: + break + case pion.PeerConnectionStateConnecting: + break + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendRecv, + Codecs: []*core.Codec{ + { + Name: "PCMU", + ClockRate: 8000, + Channels: 1, + }, + }, + }, + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: "H264", + ClockRate: 90000, + }, + }, + }, + } + + // Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + client.api.Close() + return nil, err + } + + // Send offer + client.api.sendOffer(offer) + sendOffer.Done(nil) + + if err = connState.Wait(); err != nil { + return nil, err + } + + if resolution != "" { + value, err := strconv.Atoi(resolution) + if err == nil { + client.api.sendResolution(value) + } + } + + return client, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.prod.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.prod.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + return webrtcProd.AddTrack(media, codec, track) + } + + return fmt.Errorf("add track not supported") +} + +func (c *Client) Start() error { + return c.prod.Start() +} + +func (c *Client) Stop() error { + select { + case <-c.done: + return nil + default: + close(c.done) + } + + if c.prod != nil { + _ = c.prod.Stop() + } + + if c.api != nil { + c.api.Close() + c.api = nil + } + + return nil +} + +func (c *Client) MarshalJSON() ([]byte, error) { + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + return webrtcProd.MarshalJSON() + } + + return json.Marshal(c.prod) +} diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go new file mode 100644 index 00000000..eff9d8e7 --- /dev/null +++ b/pkg/tuya/mqtt.go @@ -0,0 +1,274 @@ +package tuya + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +type TuyaMQTT struct { + client mqtt.Client + waiter core.Waiter + publishTopic string + subscribeTopic string + uid string + closed bool + 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 uint32 `json:"stream_type"` + Auth string `json:"auth"` +} + +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:"value"` +} + +type DisconnectFrame struct { + Mode string `json:"mode"` +} + +type MqttMessage struct { + Protocol int `json:"protocol"` + Pv string `json:"pv"` + T int64 `json:"t"` + Data MqttFrame `json:"data"` +} + +func(c *TuyaClient) StartMQTT() error { + hubConfig, err := c.LoadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + c.mqtt.publishTopic = hubConfig.SinkTopic.IPC + c.mqtt.subscribeTopic = hubConfig.SourceSink.IPC + + 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) + + parts := strings.Split(c.mqtt.subscribeTopic, "/") + c.mqtt.uid = parts[3] + + opts := mqtt.NewClientOptions().AddBroker(hubConfig.Url). + SetClientID(hubConfig.ClientID). + SetUsername(hubConfig.Username). + SetPassword(hubConfig.Password). + SetOnConnectHandler(c.onConnect). + SetConnectTimeout(10 * time.Second) + + c.mqtt.client = mqtt.NewClient(opts) + + if token := c.mqtt.client.Connect(); token.Wait() && token.Error() != nil { + return fmt.Errorf("failed to connect to MQTT broker: %w", token.Error()) + } + + if err := c.mqtt.waiter.Wait(); err != nil { + return err + } + + return nil +} + +func(c *TuyaClient) StopMQTT() { + c.sendDisconnect() + + if c.mqtt.client != nil { + c.mqtt.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(fmt.Errorf("unmarshal mqtt message fail: %s, payload: %s", err.Error(), string(msg.Payload()))) + 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(fmt.Errorf("unmarshal mqtt answer frame fail: %s, session: %s, frame: %s", + err.Error(), + msg.Data.Header.SessionID, + string(msg.Data.Message))) + 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(fmt.Errorf("unmarshal mqtt candidate frame fail: %s, session: %s, frame: %s", + err.Error(), + msg.Data.Header.SessionID, + string(msg.Data.Message))) + 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) { + c.sendMqttMessage("offer", 302, "", OfferFrame{ + Mode: "webrtc", + Sdp: sdp, + StreamType: 1, + Auth: c.auth, + }) +} + +func (c *TuyaClient) sendCandidate(candidate string) { + c.sendMqttMessage("candidate", 302, "", CandidateFrame{ + Mode: "webrtc", + Candidate: candidate, + }) +} + +func (c *TuyaClient) sendResolution(resolution int) { + c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ + Mode: "webrtc", + Value: resolution, + }) +} + +func(c *TuyaClient) sendDisconnect() { + c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ + Mode: "webrtc", + }) +} + +func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) { + if c.mqtt.closed { + c.mqtt.onError(fmt.Errorf("mqtt client is closed, send mqtt message fail")) + return + } + + jsonMessage, err := json.Marshal(data) + if err != nil { + c.mqtt.onError(fmt.Errorf("marshal mqtt message fail: %s", err.Error())) + return + } + + msg := &MqttMessage{ + Protocol: protocol, + Pv: "2.2", + T: time.Now().Unix(), + Data: MqttFrame{ + Header: MqttFrameHeader{ + Type: messageType, + From: c.mqtt.uid, + To: c.deviceID, + SessionID: c.sessionID, + MotoID: c.motoID, + TransactionID: transactionID, + }, + Message: jsonMessage, + }, + } + + payload, err := json.Marshal(msg) + if err != nil { + c.mqtt.onError(fmt.Errorf("marshal mqtt message fail: %s", err.Error())) + return + } + + token := c.mqtt.client.Publish(c.mqtt.publishTopic, 1, false, payload) + if token.Wait() && token.Error() != nil { + c.mqtt.onError(fmt.Errorf("mqtt publish fail: %s, topic: %s", token.Error().Error(), c.mqtt.publishTopic)) + } +} From 30d48e139ced9ee0a1373eff26dcc3bacdf3a94c Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 11 May 2025 00:51:17 +0200 Subject: [PATCH 002/241] implement skill handling and media configuration --- pkg/tuya/api.go | 97 +++++++++++++++++++++++++++++++++++++++++--- pkg/tuya/client.go | 40 ++++-------------- pkg/webrtc/client.go | 4 +- 3 files changed, 101 insertions(+), 40 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 4ea609ca..a6290494 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -31,6 +31,7 @@ type TuyaClient struct { motoID string auth string iceServers []pionWebrtc.ICEServer + medias []*core.Media } type Token struct { @@ -41,8 +42,8 @@ type Token struct { } type AudioAttributes struct { - CallMode []int `json:"call_mode"` - HardwareCapability []int `json:"hardware_capability"` + CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way + HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker } type OpenApiICE struct { @@ -62,6 +63,24 @@ type P2PConfig struct { Ices []OpenApiICE `json:"ices"` } +type Skill struct { + WebRTC int `json:"webrtc"` + Audios []struct { + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + } `json:"audios"` + Videos []struct { + StreamType int `json:"streamType"` // streamType = 2 => H265 and streamType = 4 => H264 + ProfileId string `json:"profileId"` + Width int `json:"width"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + Height int `json:"height"` + } `json:"videos"` +} + type WebRTConfig struct { AudioAttributes AudioAttributes `json:"audio_attributes"` Auth string `json:"auth"` @@ -73,14 +92,14 @@ type WebRTConfig struct { VideoClaritiy int `json:"video_clarity"` } -type TokenResponse struct { - Result Token `json:"result"` -} - type WebRTCConfigResponse struct { Result WebRTConfig `json:"result"` } +type TokenResponse struct { + Result Token `json:"result"` +} + type OpenIoTHubConfigRequest struct { UID string `json:"uid"` UniqueID string `json:"unique_id"` @@ -234,6 +253,63 @@ func(c *TuyaClient) InitDevice() (err error) { c.motoID = webRTCConfigResponse.Result.MotoID c.auth = webRTCConfigResponse.Result.Auth + var skill Skill + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &skill) + if err != nil { + return fmt.Errorf("failed to unmarshal skill: %w", err) + } + + var audioDirection string + if contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) { + audioDirection = core.DirectionSendRecv + } else { + audioDirection = core.DirectionRecvonly + } + + c.medias = make([]*core.Media, 0) + for _, audio := range skill.Audios { + media := &core.Media{ + Kind: core.KindAudio, + Direction: audioDirection, + Codecs: []*core.Codec{ + { + Name: "PCMU", + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + }, + }, + } + + c.medias = append(c.medias, media) + } + + // take only the first video codec + video := skill.Videos[0] + + var name string + switch video.CodecType { + case 4: + name = core.CodecH265 + case 2: + name = core.CodecH264 + default: + name = core.CodecH264 + } + + media := &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: name, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }, + }, + } + + c.medias = append(c.medias, media) + iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) if err != nil { return fmt.Errorf("failed to marshal ICE servers: %w", err) @@ -282,4 +358,13 @@ func(c *TuyaClient) calBusinessSign(ts int64) string { val := md5.Sum([]byte(data)) res := fmt.Sprintf("%X", val) return res +} + +func contains(slice []int, val int) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false } \ No newline at end of file diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index c36cd502..e0cbce4c 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -58,8 +58,8 @@ func Dial(rawURL string) (*Client, error) { conf := pion.Configuration{ ICEServers: client.api.iceServers, - // ICETransportPolicy: pion.ICETransportPolicyAll, - // BundlePolicy: pion.BundlePolicyMaxBundle, + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyMaxBundle, } api, err := webrtc.NewAPI() @@ -99,14 +99,15 @@ func Dial(rawURL string) (*Client, error) { } if err = pc.SetRemoteDescription(desc); err != nil { + client.Stop() return } - - prod.SetAnswer(answer.Sdp) - if err != nil { + + if err = prod.SetAnswer(answer.Sdp); err != nil { client.Stop() + return } - + prod.SDP = answer.Sdp } @@ -148,32 +149,8 @@ func Dial(rawURL string) (*Client, error) { } }) - medias := []*core.Media{ - { - Kind: core.KindAudio, - Direction: core.DirectionSendRecv, - Codecs: []*core.Codec{ - { - Name: "PCMU", - ClockRate: 8000, - Channels: 1, - }, - }, - }, - { - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: "H264", - ClockRate: 90000, - }, - }, - }, - } - // Create offer - offer, err := prod.CreateOffer(medias) + offer, err := prod.CreateOffer(client.api.medias) if err != nil { client.api.Close() return nil, err @@ -231,7 +208,6 @@ func (c *Client) Stop() error { if c.api != nil { c.api.Close() - c.api = nil } return nil diff --git a/pkg/webrtc/client.go b/pkg/webrtc/client.go index 84e9e86b..bc2c4f87 100644 --- a/pkg/webrtc/client.go +++ b/pkg/webrtc/client.go @@ -63,12 +63,12 @@ func (c *Conn) SetAnswer(answer string) (err error) { SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer), } if err = c.pc.SetRemoteDescription(desc); err != nil { - return + return err } sd := &sdp.SessionDescription{} if err = sd.Unmarshal([]byte(answer)); err != nil { - return + return err } c.Medias = UnmarshalMedias(sd.MediaDescriptions) From 6e35f1a389c9c2f91a1d2a4a7daa9adeee7168d1 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 11 May 2025 01:21:20 +0200 Subject: [PATCH 003/241] add rtsp support --- pkg/tuya/api.go | 58 ++++++++++++++++++++++++++++++++++++++++------ pkg/tuya/client.go | 14 +++++++++-- pkg/tuya/mqtt.go | 3 +-- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index a6290494..e3c9d5b0 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -20,6 +20,7 @@ type TuyaClient struct { httpClient *http.Client mqtt *TuyaMQTT apiURL string + rtspURL string sessionID string clientID string deviceID string @@ -41,6 +42,17 @@ type Token struct { ExpireTime int64 `json:"expire_time"` } +type RTSPRequest struct { + Type string `json:"type"` +} + +type RTSPResponse struct { + Success bool `json:"success"` + Result struct { + URL string `json:"url"` + } `json:"result"` +} + type AudioAttributes struct { CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker @@ -133,7 +145,7 @@ const ( defaultTimeout = 5 * time.Second ) -func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string) (*TuyaClient, error) { +func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string, useRTSP bool) (*TuyaClient, error) { client := &TuyaClient{ httpClient: &http.Client{Timeout: defaultTimeout}, mqtt: &TuyaMQTT{waiter: core.Waiter{}}, @@ -149,12 +161,18 @@ func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID stri return nil, fmt.Errorf("failed to initialize token: %w", err) } - if err := client.InitDevice(); err != nil { - return nil, fmt.Errorf("failed to initialize device: %w", err) - } + if useRTSP { + if err := client.GetRTSP(); err != nil { + return nil, fmt.Errorf("failed to get RTSP 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) + if err := client.StartMQTT(); err != nil { + return nil, fmt.Errorf("failed to start MQTT: %w", err) + } } return client, nil @@ -324,6 +342,32 @@ func(c *TuyaClient) InitDevice() (err error) { return nil } +func(c *TuyaClient) GetRTSP() (err error) { + url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.apiURL, c.deviceID) + + request := &RTSPRequest{ + Type: "rtsp", + } + + body, err := c.Request("POST", url, request) + if err != nil { + return fmt.Errorf("failed to get rtsp url: %w", err) + } + + var rtspResponse RTSPResponse + err = json.Unmarshal(body, &rtspResponse) + if err != nil { + return fmt.Errorf("failed to unmarshal rtsp response: %w", err) + } + + if !rtspResponse.Success { + return fmt.Errorf("failed to get rtsp url: %s", string(body)) + } + + c.rtspURL = rtspResponse.Result.URL + + return nil +} func(c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.apiURL) @@ -335,12 +379,12 @@ func(c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { Topics: "ipc", } - var openIoTHubConfigResponse OpenIoTHubConfigResponse body, err := c.Request("POST", url, request) if err != nil { return nil, fmt.Errorf("failed to get OpenIoTHub config: %w", err) } + var openIoTHubConfigResponse OpenIoTHubConfigResponse err = json.Unmarshal(body, &openIoTHubConfigResponse) if err != nil { return nil, fmt.Errorf("failed to unmarshal OpenIoTHub config response: %w", err) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index e0cbce4c..1f1bc827 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -7,6 +7,7 @@ import ( "net/url" "strconv" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" pion "github.com/pion/webrtc/v4" @@ -27,7 +28,7 @@ const ( DefaultInURL = "openapi.tuyain.com" ) -func Dial(rawURL string) (*Client, error) { +func Dial(rawURL string) (core.Producer, error) { // Parse URL and validate basic params u, err := url.Parse(rawURL) if err != nil { @@ -40,13 +41,14 @@ func Dial(rawURL string) (*Client, error) { clientID := query.Get("client_id") secret := query.Get("secret") resolution := query.Get("resolution") + useRTSP := query.Get("use_rtsp") == "1" if deviceID == "" || uid == "" || clientID == "" || secret == "" { return nil, errors.New("tuya: wrong query") } // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret) + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret, useRTSP) if err != nil { return nil, err } @@ -56,6 +58,14 @@ func Dial(rawURL string) (*Client, error) { done: make(chan struct{}), } + if useRTSP { + if client.api.rtspURL == "" { + return nil, errors.New("tuya: no rtsp url") + } + + return streams.GetProducer(client.api.rtspURL) + } + conf := pion.Configuration{ ICEServers: client.api.iceServers, ICETransportPolicy: pion.ICETransportPolicyAll, diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index eff9d8e7..3da27c08 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -107,9 +107,8 @@ func(c *TuyaClient) StartMQTT() error { } func(c *TuyaClient) StopMQTT() { - c.sendDisconnect() - if c.mqtt.client != nil { + c.sendDisconnect() c.mqtt.client.Disconnect(1000) } } From b797a2fcd18007359917801b27c02f08a61d7fe6 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 May 2025 05:23:14 +0200 Subject: [PATCH 004/241] add HLS support and fix skill response --- pkg/tuya/api.go | 130 +++++++++++++++++++++++++++++---------------- pkg/tuya/client.go | 15 +++++- 2 files changed, 97 insertions(+), 48 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index e3c9d5b0..4ba4f1eb 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -21,6 +21,7 @@ type TuyaClient struct { mqtt *TuyaMQTT apiURL string rtspURL string + hlsURL string sessionID string clientID string deviceID string @@ -42,11 +43,11 @@ type Token struct { ExpireTime int64 `json:"expire_time"` } -type RTSPRequest struct { +type AllocateRequest struct { Type string `json:"type"` } -type RTSPResponse struct { +type AllocateResponse struct { Success bool `json:"success"` Result struct { URL string `json:"url"` @@ -145,7 +146,7 @@ const ( defaultTimeout = 5 * time.Second ) -func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string, useRTSP bool) (*TuyaClient, error) { +func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string, useRTSP bool, useHLS bool) (*TuyaClient, error) { client := &TuyaClient{ httpClient: &http.Client{Timeout: defaultTimeout}, mqtt: &TuyaMQTT{waiter: core.Waiter{}}, @@ -162,9 +163,13 @@ func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID stri } if useRTSP { - if err := client.GetRTSP(); err != nil { + if err := client.GetStreamUrl("rtsp"); err != nil { return nil, fmt.Errorf("failed to get RTSP URL: %w", err) } + } else if useHLS { + 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) @@ -285,48 +290,72 @@ func(c *TuyaClient) InitDevice() (err error) { } c.medias = make([]*core.Media, 0) - for _, audio := range skill.Audios { - media := &core.Media{ + if len(skill.Audios) > 0 { + for _, audio := range skill.Audios { + c.medias = append(c.medias, &core.Media{ + Kind: core.KindAudio, + Direction: audioDirection, + Codecs: []*core.Codec{ + { + Name: "PCMU", + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + }, + }, + }) + } + } else { + c.medias = append(c.medias, &core.Media{ Kind: core.KindAudio, - Direction: audioDirection, + Direction: core.DirectionRecvonly, Codecs: []*core.Codec{ { Name: "PCMU", - ClockRate: uint32(audio.SampleRate), - Channels: uint8(audio.Channels), + ClockRate: uint32(8000), + Channels: uint8(1), }, }, + }) + } + + if len(skill.Videos) > 0 { + // take only the first video codec + video := skill.Videos[0] + + var name string + switch video.CodecType { + case 4: + name = core.CodecH265 + case 2: + name = core.CodecH264 + default: + name = core.CodecH264 } - - c.medias = append(c.medias, media) - } - - // take only the first video codec - video := skill.Videos[0] - - var name string - switch video.CodecType { - case 4: - name = core.CodecH265 - case 2: - name = core.CodecH264 - default: - name = core.CodecH264 - } - - media := &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: name, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, + + c.medias = append(c.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: name, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }, }, - }, + }) + } else { + c.medias = append(c.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecH264, + ClockRate: uint32(90000), + PayloadType: 96, + }, + }, + }) } - - c.medias = append(c.medias, media) iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) if err != nil { @@ -342,11 +371,11 @@ func(c *TuyaClient) InitDevice() (err error) { return nil } -func(c *TuyaClient) GetRTSP() (err error) { +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 := &RTSPRequest{ - Type: "rtsp", + request := &AllocateRequest{ + Type: streamType, } body, err := c.Request("POST", url, request) @@ -354,17 +383,26 @@ func(c *TuyaClient) GetRTSP() (err error) { return fmt.Errorf("failed to get rtsp url: %w", err) } - var rtspResponse RTSPResponse - err = json.Unmarshal(body, &rtspResponse) + var allosResponse AllocateResponse + err = json.Unmarshal(body, &allosResponse) if err != nil { - return fmt.Errorf("failed to unmarshal rtsp response: %w", err) + return fmt.Errorf("failed to unmarshal stream response: %w", err) } - if !rtspResponse.Success { - return fmt.Errorf("failed to get rtsp url: %s", string(body)) + if !allosResponse.Success { + return fmt.Errorf("failed to get stream url: %s", string(body)) } - c.rtspURL = rtspResponse.Result.URL + switch streamType { + case "rtsp": + c.rtspURL = allosResponse.Result.URL + fmt.Printf("RTSP URL: %s\n", c.rtspURL) + case "hls": + c.hlsURL = "ffmpeg:" + allosResponse.Result.URL + "#video=copy" + fmt.Printf("HLS URL: %s\n", c.hlsURL) + default: + return fmt.Errorf("unsupported stream type: %s", streamType) + } return nil } diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 1f1bc827..9019e931 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -41,14 +41,15 @@ func Dial(rawURL string) (core.Producer, error) { clientID := query.Get("client_id") secret := query.Get("secret") resolution := query.Get("resolution") - useRTSP := query.Get("use_rtsp") == "1" + useRTSP := query.Get("rtsp") == "1" + useHLS := query.Get("hls") == "1" if deviceID == "" || uid == "" || clientID == "" || secret == "" { return nil, errors.New("tuya: wrong query") } // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret, useRTSP) + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret, useRTSP, useHLS) if err != nil { return nil, err } @@ -58,6 +59,7 @@ func Dial(rawURL string) (core.Producer, error) { done: make(chan struct{}), } + // RTSP if useRTSP { if client.api.rtspURL == "" { return nil, errors.New("tuya: no rtsp url") @@ -66,6 +68,15 @@ func Dial(rawURL string) (core.Producer, error) { return streams.GetProducer(client.api.rtspURL) } + // HLS + if useHLS { + if client.api.hlsURL == "" { + return nil, errors.New("tuya: no hls url") + } + return streams.GetProducer(client.api.hlsURL) + } + + // Default to WebRTC conf := pion.Configuration{ ICEServers: client.api.iceServers, ICETransportPolicy: pion.ICETransportPolicyAll, From a7e76db464a82bc277ca116b91e3e442cae7580b Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 May 2025 13:31:45 +0200 Subject: [PATCH 005/241] change hls url and query and add more checks --- pkg/tuya/api.go | 2 +- pkg/tuya/client.go | 246 ++++++++++++++++++++++++--------------------- 2 files changed, 130 insertions(+), 118 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 4ba4f1eb..cef83af1 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -398,7 +398,7 @@ func(c *TuyaClient) GetStreamUrl(streamType string) (err error) { c.rtspURL = allosResponse.Result.URL fmt.Printf("RTSP URL: %s\n", c.rtspURL) case "hls": - c.hlsURL = "ffmpeg:" + allosResponse.Result.URL + "#video=copy" + c.hlsURL = allosResponse.Result.URL fmt.Printf("HLS URL: %s\n", c.hlsURL) default: return fmt.Errorf("unsupported stream type: %s", streamType) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 9019e931..3a11b595 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -41,13 +41,31 @@ func Dial(rawURL string) (core.Producer, error) { clientID := query.Get("client_id") secret := query.Get("secret") resolution := query.Get("resolution") - useRTSP := query.Get("rtsp") == "1" - useHLS := query.Get("hls") == "1" + streamType := query.Get("type") + useRTSP := streamType == "rtsp" + useHLS := streamType == "hls" + useWebRTC := streamType == "webrtc" || streamType == "" + + // 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 deviceID == "" || uid == "" || clientID == "" || secret == "" { return nil, errors.New("tuya: wrong query") } + if !useRTSP && !useHLS && !useWebRTC { + return nil, errors.New("tuya: wrong stream type") + } + // Initialize Tuya API client tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret, useRTSP, useHLS) if err != nil { @@ -59,140 +77,134 @@ func Dial(rawURL string) (core.Producer, error) { done: make(chan struct{}), } - // RTSP if useRTSP { if client.api.rtspURL == "" { return nil, errors.New("tuya: no rtsp url") } - return streams.GetProducer(client.api.rtspURL) - } - - // HLS - if useHLS { + } else if useHLS { if client.api.hlsURL == "" { return nil, errors.New("tuya: no hls url") } return streams.GetProducer(client.api.hlsURL) - } - - // Default to WebRTC - conf := pion.Configuration{ - ICEServers: client.api.iceServers, - ICETransportPolicy: pion.ICETransportPolicyAll, - BundlePolicy: pion.BundlePolicyMaxBundle, - } - - api, err := webrtc.NewAPI() - if err != nil { - client.api.Close() - return nil, err - } - - pc, err := api.NewPeerConnection(conf) - if err != nil { - client.api.Close() - return nil, err - } - - // protect from sending ICE candidate before Offer - var sendOffer core.Waiter - - // protect from blocking on errors - defer sendOffer.Done(nil) - - // waiter will wait PC error or WS error or nil (connection OK) - var connState core.Waiter - - prod := webrtc.NewConn(pc) - prod.FormatName = "tuya/webrtc" - prod.Mode = core.ModeActiveProducer - prod.Protocol = "mqtt" - prod.URL = rawURL - - client.prod = prod - - // Set up MQTT handlers - client.api.mqtt.handleAnswer = func(answer AnswerFrame) { - desc := pion.SessionDescription{ - Type: pion.SDPTypePranswer, - SDP: answer.Sdp, + } else { + conf := pion.Configuration{ + ICEServers: client.api.iceServers, + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyMaxBundle, } - if err = pc.SetRemoteDescription(desc); err != nil { - client.Stop() - return + api, err := webrtc.NewAPI() + if err != nil { + client.api.Close() + return nil, err } - - if err = prod.SetAnswer(answer.Sdp); err != nil { + + pc, err := api.NewPeerConnection(conf) + if err != nil { + client.api.Close() + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // waiter will wait PC error or WS error or nil (connection OK) + var connState core.Waiter + + prod := webrtc.NewConn(pc) + prod.FormatName = "tuya/webrtc" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "mqtt" + prod.URL = rawURL + + client.prod = prod + + // Set up MQTT handlers + client.api.mqtt.handleAnswer = func(answer AnswerFrame) { + desc := pion.SessionDescription{ + Type: pion.SDPTypePranswer, + SDP: answer.Sdp, + } + + if err = pc.SetRemoteDescription(desc); err != nil { + client.Stop() + return + } + + if err = prod.SetAnswer(answer.Sdp); err != nil { + client.Stop() + return + } + + prod.SDP = answer.Sdp + } + + client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { + if candidate.Candidate != "" { + prod.AddCandidate(candidate.Candidate) + if err != nil { + client.Stop() + } + } + } + + client.api.mqtt.handleDisconnect = func() { client.Stop() - return } - prod.SDP = answer.Sdp - } + client.api.mqtt.handleError = func(err error) { + fmt.Printf("Tuya error: %s\n", err.Error()) + client.Stop() + } - client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { - if candidate.Candidate != "" { - prod.AddCandidate(candidate.Candidate) - if err != nil { - client.Stop() + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + client.api.sendCandidate("a=" + msg.ToJSON().Candidate) + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateNew: + break + case pion.PeerConnectionStateConnecting: + break + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + // Create offer + offer, err := prod.CreateOffer(client.api.medias) + if err != nil { + client.api.Close() + return nil, err + } + + // Send offer + client.api.sendOffer(offer) + sendOffer.Done(nil) + + if err = connState.Wait(); err != nil { + return nil, err + } + + if resolution != "" { + value, err := strconv.Atoi(resolution) + if err == nil { + client.api.sendResolution(value) } } + + return client, nil } - - client.api.mqtt.handleDisconnect = func() { - client.Stop() - } - - client.api.mqtt.handleError = func(err error) { - fmt.Printf("Tuya error: %s\n", err.Error()) - client.Stop() - } - - prod.Listen(func(msg any) { - switch msg := msg.(type) { - case *pion.ICECandidate: - _ = sendOffer.Wait() - client.api.sendCandidate("a=" + msg.ToJSON().Candidate) - - case pion.PeerConnectionState: - switch msg { - case pion.PeerConnectionStateNew: - break - case pion.PeerConnectionStateConnecting: - break - case pion.PeerConnectionStateConnected: - connState.Done(nil) - default: - connState.Done(errors.New("webrtc: " + msg.String())) - } - } - }) - - // Create offer - offer, err := prod.CreateOffer(client.api.medias) - if err != nil { - client.api.Close() - return nil, err - } - - // Send offer - client.api.sendOffer(offer) - sendOffer.Done(nil) - - if err = connState.Wait(); err != nil { - return nil, err - } - - if resolution != "" { - value, err := strconv.Atoi(resolution) - if err == nil { - client.api.sendResolution(value) - } - } - - return client, nil } func (c *Client) GetMedias() []*core.Media { From 43b7a662c10a1a38dc3c72b8cb127851f5cc5aab Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 May 2025 13:39:59 +0200 Subject: [PATCH 006/241] use streamType parameter --- pkg/tuya/api.go | 6 +++--- pkg/tuya/client.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index cef83af1..b66481fc 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -146,7 +146,7 @@ const ( defaultTimeout = 5 * time.Second ) -func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string, useRTSP bool, useHLS bool) (*TuyaClient, error) { +func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string, streamType string) (*TuyaClient, error) { client := &TuyaClient{ httpClient: &http.Client{Timeout: defaultTimeout}, mqtt: &TuyaMQTT{waiter: core.Waiter{}}, @@ -162,11 +162,11 @@ func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID stri return nil, fmt.Errorf("failed to initialize token: %w", err) } - if useRTSP { + if streamType == "rtsp" { if err := client.GetStreamUrl("rtsp"); err != nil { return nil, fmt.Errorf("failed to get RTSP URL: %w", err) } - } else if useHLS { + } else if streamType == "hls" { if err := client.GetStreamUrl("hls"); err != nil { return nil, fmt.Errorf("failed to get HLS URL: %w", err) } diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 3a11b595..4735c003 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -67,7 +67,7 @@ func Dial(rawURL string) (core.Producer, error) { } // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret, useRTSP, useHLS) + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret, streamType) if err != nil { return nil, err } From 05c12b34e5c0d912eedda7fa1e190679d3e92363 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 May 2025 22:54:40 +0200 Subject: [PATCH 007/241] refactor --- pkg/tuya/api.go | 480 ++++++++++++++++++++++++++------------------- pkg/tuya/client.go | 51 ++--- pkg/tuya/mqtt.go | 68 +++---- 3 files changed, 339 insertions(+), 260 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index b66481fc..4ee0bf8f 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -22,151 +22,165 @@ type TuyaClient struct { apiURL string rtspURL string hlsURL string - sessionID string - clientID string - deviceID string + sessionId string + clientId string + clientSecret string + deviceId string accessToken string refreshToken string - secret string expireTime int64 uid string - motoID string + motoId string auth string + skill *Skill iceServers []pionWebrtc.ICEServer medias []*core.Media + hasBackchannel bool } type Token struct { - UID string `json:"uid"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpireTime int64 `json:"expire_time"` -} - -type AllocateRequest struct { - Type string `json:"type"` -} - -type AllocateResponse struct { - Success bool `json:"success"` - Result struct { - URL string `json:"url"` - } `json:"result"` + 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 + 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"` + 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"` + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` } type P2PConfig struct { - Ices []OpenApiICE `json:"ices"` + Ices []OpenApiICE `json:"ices"` } type Skill struct { - WebRTC int `json:"webrtc"` + WebRTC int `json:"webrtc"` Audios []struct { - Channels int `json:"channels"` - DataBit int `json:"dataBit"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` } `json:"audios"` Videos []struct { - StreamType int `json:"streamType"` // streamType = 2 => H265 and streamType = 4 => H264 - ProfileId string `json:"profileId"` - Width int `json:"width"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` - Height int `json:"height"` + StreamType int `json:"streamType"` // streamType = 2 => main stream - streamType = 4 => sub stream + ProfileId string `json:"profileId"` + Width int `json:"width"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + Height int `json:"height"` } `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"` - Skill string `json:"skill"` - SupportsWebRTC bool `json:"supports_webrtc"` - VideoClaritiy int `json:"video_clarity"` -} - -type WebRTCConfigResponse struct { - Result WebRTConfig `json:"result"` -} - -type TokenResponse struct { - Result Token `json:"result"` -} - -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"` + 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"` - + 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"` +} - 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"` } const ( defaultTimeout = 5 * time.Second ) -func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string, streamType string) (*TuyaClient, error) { +func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId string, clientSecret string, streamMode string) (*TuyaClient, error) { client := &TuyaClient{ httpClient: &http.Client{Timeout: defaultTimeout}, mqtt: &TuyaMQTT{waiter: core.Waiter{}}, apiURL: openAPIURL, - sessionID: core.RandString(6, 62), - clientID: clientID, - deviceID: deviceID, - secret: secret, + sessionId: core.RandString(6, 62), + clientId: clientId, + deviceId: deviceId, + clientSecret: clientSecret, uid: uid, + hasBackchannel: false, } if err := client.InitToken(); err != nil { return nil, fmt.Errorf("failed to initialize token: %w", err) } - if streamType == "rtsp" { + if streamMode == "rtsp" { if err := client.GetStreamUrl("rtsp"); err != nil { return nil, fmt.Errorf("failed to get RTSP URL: %w", err) } - } else if streamType == "hls" { + } else if streamMode == "hls" { if err := client.GetStreamUrl("hls"); err != nil { return nil, fmt.Errorf("failed to get HLS URL: %w", err) } @@ -193,14 +207,14 @@ func(c *TuyaClient) Request(method string, url string, body any) ([]byte, error) if body != nil { jsonBody, err := json.Marshal(body) if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) + return nil, err } bodyReader = bytes.NewReader(jsonBody) } req, err := http.NewRequest(method, url, bodyReader) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return nil, err } ts := time.Now().UnixNano() / 1000000 @@ -212,24 +226,24 @@ func(c *TuyaClient) Request(method string, url string, body any) ([]byte, error) 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("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, fmt.Errorf("failed to send request: %w", err) + return nil, err } defer response.Body.Close() res, err := io.ReadAll(response.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, err } if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("request failed with status code %d: %s", response.StatusCode, string(res)) + return nil, err } return res, nil @@ -243,13 +257,17 @@ func(c *TuyaClient) InitToken() (err error) { body, err := c.Request("GET", url, nil) if err != nil { - return fmt.Errorf("failed to get token: %w", err) + return err } var tokenResponse TokenResponse err = json.Unmarshal(body, &tokenResponse) if err != nil { - return fmt.Errorf("failed to unmarshal token response: %w", err) + return err + } + + if !tokenResponse.Success { + return fmt.Errorf("error: %s", tokenResponse.Msg) } c.accessToken = tokenResponse.Result.AccessToken @@ -260,119 +278,139 @@ func(c *TuyaClient) InitToken() (err error) { } 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) + 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 fmt.Errorf("failed to get webrtc-configs: %w", err) - } - - var webRTCConfigResponse WebRTCConfigResponse - err = json.Unmarshal(body, &webRTCConfigResponse) - if err != nil { - return fmt.Errorf("failed to unmarshal webrtc-configs response: %w", err) - } - - c.motoID = webRTCConfigResponse.Result.MotoID - c.auth = webRTCConfigResponse.Result.Auth - - var skill Skill - err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &skill) + body, err := c.Request("GET", url, nil) if err != nil { - return fmt.Errorf("failed to unmarshal skill: %w", err) + return err } - var audioDirection string - if contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) { - audioDirection = core.DirectionSendRecv - } else { - audioDirection = core.DirectionRecvonly + var webRTCConfigResponse WebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return err + } + + if !webRTCConfigResponse.Success { + return fmt.Errorf("error: %s", webRTCConfigResponse.Msg) } - - c.medias = make([]*core.Media, 0) - if len(skill.Audios) > 0 { - for _, audio := range skill.Audios { - c.medias = append(c.medias, &core.Media{ - Kind: core.KindAudio, - Direction: audioDirection, - Codecs: []*core.Codec{ - { - Name: "PCMU", - ClockRate: uint32(audio.SampleRate), - Channels: uint8(audio.Channels), - }, - }, - }) - } - } else { - c.medias = append(c.medias, &core.Media{ - Kind: core.KindAudio, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ + + c.motoId = webRTCConfigResponse.Result.MotoID + c.auth = webRTCConfigResponse.Result.Auth + + c.skill = &Skill{ + Audios: []struct { + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + }{}, + Videos: []struct { + StreamType int `json:"streamType"` + ProfileId string `json:"profileId"` + Width int `json:"width"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + Height int `json:"height"` + }{}, + } + + if webRTCConfigResponse.Result.Skill != "" { + _ = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), c.skill) + } + + var audioDirection string + if contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && + contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) { + audioDirection = core.DirectionSendRecv + c.hasBackchannel = true + } else { + audioDirection = core.DirectionRecvonly + c.hasBackchannel = false + } + + c.medias = make([]*core.Media, 0) + + if len(c.skill.Audios) > 0 { + // Use the first Audio-Codec + audio := c.skill.Audios[0] + + c.medias = append(c.medias, &core.Media{ + Kind: core.KindAudio, + Direction: audioDirection, + Codecs: []*core.Codec{ + { + Name: "PCMU", + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + }, + }, + }) + } else { + // Use default values for Audio + c.medias = append(c.medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: "PCMU", + ClockRate: uint32(8000), + Channels: uint8(1), + }, + }, + }) + } + + if len(c.skill.Videos) > 0 { + // Use the first Video-Codec + video := c.skill.Videos[0] + + c.medias = append(c.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecH265, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }, { - Name: "PCMU", - ClockRate: uint32(8000), - Channels: uint8(1), - }, - }, - }) - } + Name: core.CodecH264, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }, + }, + }) + } else { + // Use default values for Video + c.medias = append(c.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecH264, + ClockRate: uint32(90000), + PayloadType: 96, + }, + }, + }) + } - if len(skill.Videos) > 0 { - // take only the first video codec - video := skill.Videos[0] - - var name string - switch video.CodecType { - case 4: - name = core.CodecH265 - case 2: - name = core.CodecH264 - default: - name = core.CodecH264 - } - - c.medias = append(c.medias, &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: name, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, - }, - }, - }) - } else { - c.medias = append(c.medias, &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecH264, - ClockRate: uint32(90000), - PayloadType: 96, - }, - }, - }) - } + iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return err + } - iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) - if err != nil { - return fmt.Errorf("failed to marshal ICE servers: %w", err) - } + c.iceServers, err = webrtc.UnmarshalICEServers([]byte(iceServersBytes)) + if err != nil { + return err + } - - c.iceServers, err = webrtc.UnmarshalICEServers([]byte(iceServersBytes)) - if err != nil { - return fmt.Errorf("failed to unmarshal ICE servers: %w", err) - } - - return nil + 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) + url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.apiURL, c.deviceId) request := &AllocateRequest{ Type: streamType, @@ -380,26 +418,24 @@ func(c *TuyaClient) GetStreamUrl(streamType string) (err error) { body, err := c.Request("POST", url, request) if err != nil { - return fmt.Errorf("failed to get rtsp url: %w", err) + return err } var allosResponse AllocateResponse err = json.Unmarshal(body, &allosResponse) if err != nil { - return fmt.Errorf("failed to unmarshal stream response: %w", err) + return err } if !allosResponse.Success { - return fmt.Errorf("failed to get stream url: %s", string(body)) + return fmt.Errorf("error: %s", allosResponse.Msg) } switch streamType { case "rtsp": c.rtspURL = allosResponse.Result.URL - fmt.Printf("RTSP URL: %s\n", c.rtspURL) case "hls": c.hlsURL = allosResponse.Result.URL - fmt.Printf("HLS URL: %s\n", c.hlsURL) default: return fmt.Errorf("unsupported stream type: %s", streamType) } @@ -419,24 +455,66 @@ func(c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { body, err := c.Request("POST", url, request) if err != nil { - return nil, fmt.Errorf("failed to get OpenIoTHub config: %w", err) + return nil, err } var openIoTHubConfigResponse OpenIoTHubConfigResponse err = json.Unmarshal(body, &openIoTHubConfigResponse) if err != nil { - return nil, fmt.Errorf("failed to unmarshal OpenIoTHub config response: %w", err) + return nil, err } if !openIoTHubConfigResponse.Success { - return nil, fmt.Errorf("failed to get OpenIoTHub config: %s", string(body)) + return nil, fmt.Errorf("error: %s", openIoTHubConfigResponse.Msg) } return &openIoTHubConfigResponse.Result, nil } +// Search the streamType based on the selection "main" or "sub" +func (c *TuyaClient) getStreamType(streamChoice string) uint32 { + // Default streamType if nothing is found + defaultStreamType := uint32(1) + + if c.skill == nil || len(c.skill.Videos) == 0 { + return defaultStreamType + } + + // Find the highest and lowest resolution + var highestResType uint32 = defaultStreamType + var highestRes int = 0 + var lowestResType uint32 = defaultStreamType + var lowestRes int = 0 + + for _, video := range c.skill.Videos { + res := video.Width * video.Height + + // Highest Resolution + if res > highestRes { + highestRes = res + highestResType = uint32(video.StreamType) + } + + // Lower Resolution (or first if not set yet) + if lowestRes == 0 || res < lowestRes { + lowestRes = res + lowestResType = uint32(video.StreamType) + } + } + + // Return the streamType based on the selection + switch streamChoice { + case "main": + return highestResType + case "sub": + return lowestResType + default: + return defaultStreamType + } +} + func(c *TuyaClient) calBusinessSign(ts int64) string { - data := fmt.Sprintf("%s%s%s%d", c.clientID, c.accessToken, c.secret, ts) + data := fmt.Sprintf("%s%s%s%d", c.clientId, c.accessToken, c.clientSecret, ts) val := md5.Sum([]byte(data)) res := fmt.Sprintf("%X", val) return res diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 4735c003..7b1406ea 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" "net/url" - "strconv" + "regexp" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" @@ -14,9 +14,9 @@ import ( ) type Client struct { - api *TuyaClient - prod core.Producer - done chan struct{} + api *TuyaClient + prod core.Producer + done chan struct{} } const ( @@ -38,13 +38,13 @@ func Dial(rawURL string) (core.Producer, error) { query := u.Query() deviceID := query.Get("device_id") uid := query.Get("uid") - clientID := query.Get("client_id") - secret := query.Get("secret") - resolution := query.Get("resolution") + clientId := query.Get("client_id") + clientSecret := query.Get("client_secret") streamType := query.Get("type") - useRTSP := streamType == "rtsp" - useHLS := streamType == "hls" - useWebRTC := streamType == "webrtc" || streamType == "" + streamMode := query.Get("mode") + useRTSP := streamMode == "rtsp" + useHLS := streamMode == "hls" + useWebRTC := streamMode == "webrtc" || streamMode == "" // check if host is correct switch u.Hostname() { @@ -58,8 +58,12 @@ func Dial(rawURL string) (core.Producer, error) { return nil, fmt.Errorf("tuya: wrong host %s", u.Hostname()) } - if deviceID == "" || uid == "" || clientID == "" || secret == "" { - return nil, errors.New("tuya: wrong query") + if deviceID == "" || clientId == "" || clientSecret == "" { + return nil, errors.New("tuya: no device_id, client_id or client_secret") + } + + if useWebRTC && uid == "" { + return nil, errors.New("tuya: no uid") } if !useRTSP && !useHLS && !useWebRTC { @@ -67,7 +71,7 @@ func Dial(rawURL string) (core.Producer, error) { } // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientID, secret, streamType) + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamType) if err != nil { return nil, err } @@ -157,7 +161,7 @@ func Dial(rawURL string) (core.Producer, error) { } client.api.mqtt.handleError = func(err error) { - fmt.Printf("Tuya error: %s\n", err.Error()) + // fmt.Printf("tuya: error: %s\n", err.Error()) client.Stop() } @@ -188,21 +192,18 @@ func Dial(rawURL string) (core.Producer, error) { return nil, err } + // horter sdp, remove a=extmap... line, device ONLY allow 8KB json payload + re := regexp.MustCompile(`\r\na=extmap[^\r\n]*`) + offer = re.ReplaceAllString(offer, "") + // Send offer - client.api.sendOffer(offer) + client.api.sendOffer(offer, tuyaAPI.getStreamType(streamType)) sendOffer.Done(nil) if err = connState.Wait(); err != nil { return nil, err } - if resolution != "" { - value, err := strconv.Atoi(resolution) - if err == nil { - client.api.sendResolution(value) - } - } - return client, nil } } @@ -216,11 +217,11 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { - return webrtcProd.AddTrack(media, codec, track) + if prod, ok := c.prod.(*webrtc.Conn); ok { + return prod.AddTrack(media, codec, track) } - return fmt.Errorf("add track not supported") + return nil } func (c *Client) Start() error { diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 3da27c08..fbd50021 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -24,64 +24,64 @@ type TuyaMQTT struct { } 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 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"` + Header MqttFrameHeader `json:"header"` + Message json.RawMessage `json:"msg"` } type OfferFrame struct { - Mode string `json:"mode"` - Sdp string `json:"sdp"` - StreamType uint32 `json:"stream_type"` - Auth string `json:"auth"` + Mode string `json:"mode"` + Sdp string `json:"sdp"` + StreamType uint32 `json:"stream_type"` + Auth string `json:"auth"` } type AnswerFrame struct { - Mode string `json:"mode"` - Sdp string `json:"sdp"` + Mode string `json:"mode"` + Sdp string `json:"sdp"` } type CandidateFrame struct { - Mode string `json:"mode"` - Candidate string `json:"candidate"` + Mode string `json:"mode"` + Candidate string `json:"candidate"` } type ResolutionFrame struct { - Mode string `json:"mode"` - Value int `json:"value"` + Mode string `json:"mode"` + Value int `json:"value"` } type DisconnectFrame struct { - Mode string `json:"mode"` + Mode string `json:"mode"` } type MqttMessage struct { - Protocol int `json:"protocol"` - Pv string `json:"pv"` - T int64 `json:"t"` - Data MqttFrame `json:"data"` + Protocol int `json:"protocol"` + Pv string `json:"pv"` + T int64 `json:"t"` + Data MqttFrame `json:"data"` } func(c *TuyaClient) StartMQTT() error { hubConfig, err := c.LoadHubConfig() if err != nil { - return fmt.Errorf("failed to load hub config: %w", err) + return err } c.mqtt.publishTopic = hubConfig.SinkTopic.IPC c.mqtt.subscribeTopic = hubConfig.SourceSink.IPC - 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.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) parts := strings.Split(c.mqtt.subscribeTopic, "/") c.mqtt.uid = parts[3] @@ -96,7 +96,7 @@ func(c *TuyaClient) StartMQTT() error { c.mqtt.client = mqtt.NewClient(opts) if token := c.mqtt.client.Connect(); token.Wait() && token.Error() != nil { - return fmt.Errorf("failed to connect to MQTT broker: %w", token.Error()) + return token.Error() } if err := c.mqtt.waiter.Wait(); err != nil { @@ -129,7 +129,7 @@ func(c *TuyaClient) consume(client mqtt.Client, msg mqtt.Message) { return } - if rmqtt.Data.Header.SessionID != c.sessionID { + if rmqtt.Data.Header.SessionID != c.sessionId { return } @@ -202,11 +202,11 @@ func(c *TuyaMQTT) onError(err error) { } } -func (c *TuyaClient) sendOffer(sdp string) { +func (c *TuyaClient) sendOffer(sdp string, streamType uint32) { c.sendMqttMessage("offer", 302, "", OfferFrame{ Mode: "webrtc", Sdp: sdp, - StreamType: 1, + StreamType: streamType, Auth: c.auth, }) } @@ -251,9 +251,9 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti Header: MqttFrameHeader{ Type: messageType, From: c.mqtt.uid, - To: c.deviceID, - SessionID: c.sessionID, - MotoID: c.motoID, + To: c.deviceId, + SessionID: c.sessionId, + MotoID: c.motoId, TransactionID: transactionID, }, Message: jsonMessage, From 6d8d6a91ef89f887e142285123958880df67cd91 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 May 2025 22:55:55 +0200 Subject: [PATCH 008/241] format --- main.go | 2 +- pkg/hap/camera/accessory.go | 2 +- pkg/tapo/client.go | 2 +- pkg/tuya/api.go | 470 ++++++++++++++++++------------------ pkg/tuya/client.go | 26 +- pkg/tuya/mqtt.go | 98 ++++---- 6 files changed, 300 insertions(+), 300 deletions(-) diff --git a/main.go b/main.go index 3bbf632f..e2698331 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,7 @@ func main() { roborock.Init() // roborock source homekit.Init() // homekit source ring.Init() // ring source - tuya.Init() // tuya source + tuya.Init() // tuya source nest.Init() // nest source bubble.Init() // bubble source expr.Init() // expr source diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 42037d96..886b035d 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -62,7 +62,7 @@ func ServiceCameraRTPStreamManagement() *hap.Service { VideoAttrs: []VideoAttrs{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones - {Width: 320, Height: 240, Framerate: 15}, // apple watch + {Width: 320, Height: 240, Framerate: 15}, // apple watch }, }, }, diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go index c19267ff..5a9af501 100644 --- a/pkg/tapo/client.go +++ b/pkg/tapo/client.go @@ -292,7 +292,7 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http. return nil, nil, err } _, _ = io.Copy(io.Discard, res.Body) // discard leftovers - _ = res.Body.Close() // ignore response body + _ = res.Body.Close() // ignore response body auth := res.Header.Get("WWW-Authenticate") diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 4ee0bf8f..29152951 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -17,146 +17,146 @@ import ( ) 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 - medias []*core.Media - hasBackchannel bool + 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 + medias []*core.Media + hasBackchannel bool } type Token struct { - UID string `json:"uid"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpireTime int64 `json:"expire_time"` + 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 + 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"` + 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"` + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` } type P2PConfig struct { - Ices []OpenApiICE `json:"ices"` + Ices []OpenApiICE `json:"ices"` } type Skill struct { - WebRTC int `json:"webrtc"` + WebRTC int `json:"webrtc"` Audios []struct { - Channels int `json:"channels"` - DataBit int `json:"dataBit"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` } `json:"audios"` Videos []struct { - StreamType int `json:"streamType"` // streamType = 2 => main stream - streamType = 4 => sub stream - ProfileId string `json:"profileId"` - Width int `json:"width"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` - Height int `json:"height"` + StreamType int `json:"streamType"` // streamType = 2 => main stream - streamType = 4 => sub stream + ProfileId string `json:"profileId"` + Width int `json:"width"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + Height int `json:"height"` } `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"` + 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"` + 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"` + 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"` + 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"` + 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 string `json:"type"` } type AllocateResponse struct { - Success bool `json:"success"` + Success bool `json:"success"` Result struct { URL string `json:"url"` } `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` + 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"` + 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"` + Success bool `json:"success"` + Result OpenIoTHubConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` } const ( - defaultTimeout = 5 * time.Second + defaultTimeout = 5 * time.Second ) func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId string, clientSecret string, streamMode string) (*TuyaClient, error) { @@ -164,7 +164,7 @@ func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId stri httpClient: &http.Client{Timeout: defaultTimeout}, mqtt: &TuyaMQTT{waiter: core.Waiter{}}, apiURL: openAPIURL, - sessionId: core.RandString(6, 62), + sessionId: core.RandString(6, 62), clientId: clientId, deviceId: deviceId, clientSecret: clientSecret, @@ -202,7 +202,7 @@ func (c *TuyaClient) Close() { c.httpClient.CloseIdleConnections() } -func(c *TuyaClient) Request(method string, url string, body any) ([]byte, error) { +func (c *TuyaClient) Request(method string, url string, body any) ([]byte, error) { var bodyReader io.Reader if body != nil { jsonBody, err := json.Marshal(body) @@ -249,7 +249,7 @@ func(c *TuyaClient) Request(method string, url string, body any) ([]byte, error) return res, nil } -func(c *TuyaClient) InitToken() (err error) { +func (c *TuyaClient) InitToken() (err error) { url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.apiURL) c.accessToken = "" @@ -259,7 +259,7 @@ func(c *TuyaClient) InitToken() (err error) { if err != nil { return err } - + var tokenResponse TokenResponse err = json.Unmarshal(body, &tokenResponse) if err != nil { @@ -277,139 +277,139 @@ func(c *TuyaClient) InitToken() (err error) { 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) +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 - } + 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 - } + var webRTCConfigResponse WebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return err + } if !webRTCConfigResponse.Success { return fmt.Errorf("error: %s", webRTCConfigResponse.Msg) } c.motoId = webRTCConfigResponse.Result.MotoID - c.auth = webRTCConfigResponse.Result.Auth + c.auth = webRTCConfigResponse.Result.Auth - c.skill = &Skill{ - Audios: []struct { - Channels int `json:"channels"` - DataBit int `json:"dataBit"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` - }{}, - Videos: []struct { - StreamType int `json:"streamType"` - ProfileId string `json:"profileId"` - Width int `json:"width"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` - Height int `json:"height"` - }{}, - } + c.skill = &Skill{ + Audios: []struct { + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + }{}, + Videos: []struct { + StreamType int `json:"streamType"` + ProfileId string `json:"profileId"` + Width int `json:"width"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` + Height int `json:"height"` + }{}, + } - if webRTCConfigResponse.Result.Skill != "" { - _ = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), c.skill) - } + if webRTCConfigResponse.Result.Skill != "" { + _ = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), c.skill) + } - var audioDirection string - if contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && - contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) { - audioDirection = core.DirectionSendRecv + var audioDirection string + if contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && + contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) { + audioDirection = core.DirectionSendRecv c.hasBackchannel = true - } else { - audioDirection = core.DirectionRecvonly + } else { + audioDirection = core.DirectionRecvonly c.hasBackchannel = false - } - - c.medias = make([]*core.Media, 0) - - if len(c.skill.Audios) > 0 { - // Use the first Audio-Codec - audio := c.skill.Audios[0] + } - c.medias = append(c.medias, &core.Media{ - Kind: core.KindAudio, - Direction: audioDirection, - Codecs: []*core.Codec{ - { - Name: "PCMU", - ClockRate: uint32(audio.SampleRate), - Channels: uint8(audio.Channels), - }, - }, - }) - } else { - // Use default values for Audio - c.medias = append(c.medias, &core.Media{ - Kind: core.KindAudio, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: "PCMU", - ClockRate: uint32(8000), - Channels: uint8(1), - }, - }, - }) - } + c.medias = make([]*core.Media, 0) - if len(c.skill.Videos) > 0 { - // Use the first Video-Codec - video := c.skill.Videos[0] + if len(c.skill.Audios) > 0 { + // Use the first Audio-Codec + audio := c.skill.Audios[0] - c.medias = append(c.medias, &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecH265, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, - }, + c.medias = append(c.medias, &core.Media{ + Kind: core.KindAudio, + Direction: audioDirection, + Codecs: []*core.Codec{ { - Name: core.CodecH264, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, - }, - }, - }) - } else { - // Use default values for Video - c.medias = append(c.medias, &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecH264, - ClockRate: uint32(90000), - PayloadType: 96, - }, - }, - }) - } + Name: "PCMU", + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + }, + }, + }) + } else { + // Use default values for Audio + c.medias = append(c.medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: "PCMU", + ClockRate: uint32(8000), + Channels: uint8(1), + }, + }, + }) + } - iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) - if err != nil { - return err - } + if len(c.skill.Videos) > 0 { + // Use the first Video-Codec + video := c.skill.Videos[0] - c.iceServers, err = webrtc.UnmarshalICEServers([]byte(iceServersBytes)) - if err != nil { - return err - } + c.medias = append(c.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecH265, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }, + { + Name: core.CodecH264, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }, + }, + }) + } else { + // Use default values for Video + c.medias = append(c.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecH264, + ClockRate: uint32(90000), + PayloadType: 96, + }, + }, + }) + } - return nil + iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return err + } + + c.iceServers, err = webrtc.UnmarshalICEServers([]byte(iceServersBytes)) + if err != nil { + return err + } + + return nil } -func(c *TuyaClient) GetStreamUrl(streamType string) (err error) { +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{ @@ -443,7 +443,7 @@ func(c *TuyaClient) GetStreamUrl(streamType string) (err error) { return nil } -func(c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { +func (c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.apiURL) request := &OpenIoTHubConfigRequest{ @@ -467,53 +467,53 @@ func(c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { if !openIoTHubConfigResponse.Success { return nil, fmt.Errorf("error: %s", openIoTHubConfigResponse.Msg) } - + return &openIoTHubConfigResponse.Result, nil } // Search the streamType based on the selection "main" or "sub" func (c *TuyaClient) getStreamType(streamChoice string) uint32 { // Default streamType if nothing is found - defaultStreamType := uint32(1) + defaultStreamType := uint32(1) + + if c.skill == nil || len(c.skill.Videos) == 0 { + return defaultStreamType + } - if c.skill == nil || len(c.skill.Videos) == 0 { - return defaultStreamType - } - // Find the highest and lowest resolution - var highestResType uint32 = defaultStreamType - var highestRes int = 0 - var lowestResType uint32 = defaultStreamType - var lowestRes int = 0 - - for _, video := range c.skill.Videos { - res := video.Width * video.Height - - // Highest Resolution - if res > highestRes { - highestRes = res - highestResType = uint32(video.StreamType) - } - + var highestResType uint32 = defaultStreamType + var highestRes int = 0 + var lowestResType uint32 = defaultStreamType + var lowestRes int = 0 + + for _, video := range c.skill.Videos { + res := video.Width * video.Height + + // Highest Resolution + if res > highestRes { + highestRes = res + highestResType = uint32(video.StreamType) + } + // Lower Resolution (or first if not set yet) - if lowestRes == 0 || res < lowestRes { - lowestRes = res - lowestResType = uint32(video.StreamType) - } - } - - // Return the streamType based on the selection - switch streamChoice { - case "main": - return highestResType - case "sub": - return lowestResType - default: - return defaultStreamType - } + if lowestRes == 0 || res < lowestRes { + lowestRes = res + lowestResType = uint32(video.StreamType) + } + } + + // Return the streamType based on the selection + switch streamChoice { + case "main": + return highestResType + case "sub": + return lowestResType + default: + return defaultStreamType + } } -func(c *TuyaClient) calBusinessSign(ts int64) string { +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) @@ -527,4 +527,4 @@ func contains(slice []int, val int) bool { } } return false -} \ No newline at end of file +} diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 7b1406ea..ef9cbcc2 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -14,18 +14,18 @@ import ( ) type Client struct { - api *TuyaClient - prod core.Producer - done chan struct{} + api *TuyaClient + prod core.Producer + done chan struct{} } const ( - DefaultCnURL = "openapi.tuyacn.com" - DefaultWestUsURL = "openapi.tuyaus.com" - DefaultEastUsURL = "openapi-ueaz.tuyaus.com" + 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" + DefaultWestEuURL = "openapi-weaz.tuyaeu.com" + DefaultInURL = "openapi.tuyain.com" ) func Dial(rawURL string) (core.Producer, error) { @@ -77,7 +77,7 @@ func Dial(rawURL string) (core.Producer, error) { } client := &Client{ - api: tuyaAPI, + api: tuyaAPI, done: make(chan struct{}), } @@ -93,7 +93,7 @@ func Dial(rawURL string) (core.Producer, error) { return streams.GetProducer(client.api.hlsURL) } else { conf := pion.Configuration{ - ICEServers: client.api.iceServers, + ICEServers: client.api.iceServers, ICETransportPolicy: pion.ICETransportPolicyAll, BundlePolicy: pion.BundlePolicyMaxBundle, } @@ -138,12 +138,12 @@ func Dial(rawURL string) (core.Producer, error) { client.Stop() return } - + if err = prod.SetAnswer(answer.Sdp); err != nil { client.Stop() return } - + prod.SDP = answer.Sdp } @@ -159,7 +159,7 @@ func Dial(rawURL string) (core.Producer, error) { client.api.mqtt.handleDisconnect = func() { client.Stop() } - + client.api.mqtt.handleError = func(err error) { // fmt.Printf("tuya: error: %s\n", err.Error()) client.Stop() diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index fbd50021..bf9badd7 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -11,67 +11,67 @@ import ( ) type TuyaMQTT struct { - client mqtt.Client - waiter core.Waiter - publishTopic string - subscribeTopic string - uid string - closed bool - handleAnswer func(answer AnswerFrame) - handleCandidate func(candidate CandidateFrame) - handleDisconnect func() - handleError func(err error) + client mqtt.Client + waiter core.Waiter + publishTopic string + subscribeTopic string + uid string + closed bool + 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 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"` + Header MqttFrameHeader `json:"header"` + Message json.RawMessage `json:"msg"` } type OfferFrame struct { - Mode string `json:"mode"` - Sdp string `json:"sdp"` - StreamType uint32 `json:"stream_type"` - Auth string `json:"auth"` + Mode string `json:"mode"` + Sdp string `json:"sdp"` + StreamType uint32 `json:"stream_type"` + Auth string `json:"auth"` } type AnswerFrame struct { - Mode string `json:"mode"` - Sdp string `json:"sdp"` + Mode string `json:"mode"` + Sdp string `json:"sdp"` } type CandidateFrame struct { - Mode string `json:"mode"` - Candidate string `json:"candidate"` + Mode string `json:"mode"` + Candidate string `json:"candidate"` } type ResolutionFrame struct { - Mode string `json:"mode"` - Value int `json:"value"` + Mode string `json:"mode"` + Value int `json:"value"` } type DisconnectFrame struct { - Mode string `json:"mode"` + Mode string `json:"mode"` } type MqttMessage struct { - Protocol int `json:"protocol"` - Pv string `json:"pv"` - T int64 `json:"t"` - Data MqttFrame `json:"data"` + Protocol int `json:"protocol"` + Pv string `json:"pv"` + T int64 `json:"t"` + Data MqttFrame `json:"data"` } -func(c *TuyaClient) StartMQTT() error { +func (c *TuyaClient) StartMQTT() error { hubConfig, err := c.LoadHubConfig() if err != nil { return err @@ -106,14 +106,14 @@ func(c *TuyaClient) StartMQTT() error { return nil } -func(c *TuyaClient) StopMQTT() { +func (c *TuyaClient) StopMQTT() { if c.mqtt.client != nil { c.sendDisconnect() c.mqtt.client.Disconnect(1000) } } -func(c *TuyaClient) onConnect(client mqtt.Client) { +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 @@ -122,7 +122,7 @@ func(c *TuyaClient) onConnect(client mqtt.Client) { c.mqtt.waiter.Done(nil) } -func(c *TuyaClient) consume(client mqtt.Client, msg mqtt.Message) { +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(fmt.Errorf("unmarshal mqtt message fail: %s, payload: %s", err.Error(), string(msg.Payload()))) @@ -143,7 +143,7 @@ func(c *TuyaClient) consume(client mqtt.Client, msg mqtt.Message) { } } -func(c *TuyaMQTT) onMqttAnswer(msg *MqttMessage) { +func (c *TuyaMQTT) onMqttAnswer(msg *MqttMessage) { var answerFrame AnswerFrame if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { c.onError(fmt.Errorf("unmarshal mqtt answer frame fail: %s, session: %s, frame: %s", @@ -152,11 +152,11 @@ func(c *TuyaMQTT) onMqttAnswer(msg *MqttMessage) { string(msg.Data.Message))) return } - + c.onAnswer(answerFrame) } -func(c *TuyaMQTT) onMqttCandidate(msg *MqttMessage) { +func (c *TuyaMQTT) onMqttCandidate(msg *MqttMessage) { var candidateFrame CandidateFrame if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { c.onError(fmt.Errorf("unmarshal mqtt candidate frame fail: %s, session: %s, frame: %s", @@ -173,30 +173,30 @@ func(c *TuyaMQTT) onMqttCandidate(msg *MqttMessage) { c.onCandidate(candidateFrame) } -func(c *TuyaMQTT) onMqttDisconnect() { +func (c *TuyaMQTT) onMqttDisconnect() { c.closed = true c.onDisconnect() } -func(c *TuyaMQTT) onAnswer(answer AnswerFrame) { +func (c *TuyaMQTT) onAnswer(answer AnswerFrame) { if c.handleAnswer != nil { c.handleAnswer(answer) } } -func(c *TuyaMQTT) onCandidate(candidate CandidateFrame) { +func (c *TuyaMQTT) onCandidate(candidate CandidateFrame) { if c.handleCandidate != nil { c.handleCandidate(candidate) } } -func(c *TuyaMQTT) onDisconnect() { +func (c *TuyaMQTT) onDisconnect() { if c.handleDisconnect != nil { c.handleDisconnect() } } -func(c *TuyaMQTT) onError(err error) { +func (c *TuyaMQTT) onError(err error) { if c.handleError != nil { c.handleError(err) } @@ -220,12 +220,12 @@ func (c *TuyaClient) sendCandidate(candidate string) { func (c *TuyaClient) sendResolution(resolution int) { c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ - Mode: "webrtc", - Value: resolution, + Mode: "webrtc", + Value: resolution, }) } -func(c *TuyaClient) sendDisconnect() { +func (c *TuyaClient) sendDisconnect() { c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ Mode: "webrtc", }) From bd2cbe20e04284eabff7a0053b1a7ca10837159c Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 May 2025 14:25:10 +0200 Subject: [PATCH 009/241] add useful links --- pkg/tuya/README.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 pkg/tuya/README.md diff --git a/pkg/tuya/README.md b/pkg/tuya/README.md new file mode 100644 index 00000000..f5cbc814 --- /dev/null +++ b/pkg/tuya/README.md @@ -0,0 +1,6 @@ +## 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://ipc-us.ismartlife.me/ \ No newline at end of file From 4f969d750aef486389a43b216f706ba347462e34 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 May 2025 14:25:22 +0200 Subject: [PATCH 010/241] readme --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 9712bbde..43e1c83b 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,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) @@ -565,6 +566,41 @@ streams: Tested: KD110, KC200, KC401, KC420WS, EC71. +#### Source: Tuya + +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. + +- Obtain `client_id`, `client_secret`, `uid` and `device_id` from [Tuya IoT Platform](https://iot.tuya.com/) +- Use `mode` parameter to select the stream type: + - `webrtc` - WebRTC stream (default) + - `rtsp` - RTSP stream _(if available)_ + - `hls` - HLS stream _(if available)_ +- Use `type` parameter to select the stream type: _(if available)_ + - `main` - Main stream (default) + - `sub` - Sub stream + +```yaml +streams: + # Tuya 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 + + # Tuya WebRTC stream (HD) + tuya_webrtc_hd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&type=main + + # Tuya WebRTC stream (SD) + tuya_webrtc_sd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&type=sub + + # Using RTSP 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 +``` + + #### Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* From 6c255cd2f2684ecd4ee6d5f215992512f9ddb99b Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 May 2025 14:26:12 +0200 Subject: [PATCH 011/241] fix two way audio --- pkg/tuya/api.go | 19 +++++------- pkg/tuya/client.go | 66 +++++++++++++++++++++++++----------------- pkg/webrtc/conn.go | 22 +++++++------- pkg/webrtc/consumer.go | 2 +- 4 files changed, 59 insertions(+), 50 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 29152951..2f7cd194 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -319,15 +319,8 @@ func (c *TuyaClient) InitDevice() (err error) { _ = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), c.skill) } - var audioDirection string - if contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && - contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) { - audioDirection = core.DirectionSendRecv - c.hasBackchannel = true - } else { - audioDirection = core.DirectionRecvonly - c.hasBackchannel = false - } + c.hasBackchannel = contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && + contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) c.medias = make([]*core.Media, 0) @@ -335,9 +328,14 @@ func (c *TuyaClient) InitDevice() (err error) { // Use the first Audio-Codec audio := c.skill.Audios[0] + direction := core.DirectionRecvonly + if c.hasBackchannel { + direction = core.DirectionSendRecv + } + c.medias = append(c.medias, &core.Media{ Kind: core.KindAudio, - Direction: audioDirection, + Direction: direction, Codecs: []*core.Codec{ { Name: "PCMU", @@ -471,7 +469,6 @@ func (c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { return &openIoTHubConfigResponse.Result, nil } -// Search the streamType based on the selection "main" or "sub" func (c *TuyaClient) getStreamType(streamChoice string) uint32 { // Default streamType if nothing is found defaultStreamType := uint32(1) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index ef9cbcc2..586a8bbd 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -1,7 +1,6 @@ package tuya import ( - "encoding/json" "errors" "fmt" "net/url" @@ -10,12 +9,13 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "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 *TuyaClient - prod core.Producer + conn *webrtc.Conn done chan struct{} } @@ -95,7 +95,7 @@ func Dial(rawURL string) (core.Producer, error) { conf := pion.Configuration{ ICEServers: client.api.iceServers, ICETransportPolicy: pion.ICETransportPolicyAll, - BundlePolicy: pion.BundlePolicyMaxBundle, + BundlePolicy: pion.BundlePolicyBalanced, } api, err := webrtc.NewAPI() @@ -119,16 +119,16 @@ func Dial(rawURL string) (core.Producer, error) { // waiter will wait PC error or WS error or nil (connection OK) var connState core.Waiter - prod := webrtc.NewConn(pc) - prod.FormatName = "tuya/webrtc" - prod.Mode = core.ModeActiveProducer - prod.Protocol = "mqtt" - prod.URL = rawURL - - client.prod = prod + client.conn = webrtc.NewConn(pc) + client.conn.FormatName = "tuya/webrtc" + client.conn.Mode = core.ModeActiveProducer + client.conn.Protocol = "mqtt" + client.conn.URL = rawURL // Set up MQTT handlers client.api.mqtt.handleAnswer = func(answer AnswerFrame) { + // fmt.Printf("tuya: answer: %s\n", answer.Sdp) + desc := pion.SessionDescription{ Type: pion.SDPTypePranswer, SDP: answer.Sdp, @@ -139,17 +139,17 @@ func Dial(rawURL string) (core.Producer, error) { return } - if err = prod.SetAnswer(answer.Sdp); err != nil { + if err = client.conn.SetAnswer(answer.Sdp); err != nil { client.Stop() return } - prod.SDP = answer.Sdp + client.conn.SDP = answer.Sdp } client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { if candidate.Candidate != "" { - prod.AddCandidate(candidate.Candidate) + client.conn.AddCandidate(candidate.Candidate) if err != nil { client.Stop() } @@ -165,7 +165,7 @@ func Dial(rawURL string) (core.Producer, error) { client.Stop() } - prod.Listen(func(msg any) { + client.conn.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: _ = sendOffer.Wait() @@ -186,13 +186,14 @@ func Dial(rawURL string) (core.Producer, error) { }) // Create offer - offer, err := prod.CreateOffer(client.api.medias) + offer, err := client.conn.CreateOffer(client.api.medias) if err != nil { client.api.Close() 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, "") @@ -209,23 +210,38 @@ func Dial(rawURL string) (core.Producer, error) { } func (c *Client) GetMedias() []*core.Media { - return c.prod.GetMedias() + return c.conn.GetMedias() } func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - return c.prod.GetTrack(media, codec) + return c.conn.GetTrack(media, codec) } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - if prod, ok := c.prod.(*webrtc.Conn); ok { - return prod.AddTrack(media, codec, track) + // RepackG711 will not work, so add default logic without repacking + + payloadType := codec.PayloadType + + localTrack := c.conn.GetSenderTrack(media.ID) + if localTrack == nil { + return errors.New("webrtc: can't get track") } + sender := core.NewSender(media, codec) + sender.Handler = func(packet *rtp.Packet) { + c.conn.Send += packet.MarshalSize() + //important to send with remote PayloadType + _ = localTrack.WriteRTP(payloadType, packet) + } + + sender.HandleRTP(track) + c.conn.Senders = append(c.conn.Senders, sender) + return nil } func (c *Client) Start() error { - return c.prod.Start() + return c.conn.Start() } func (c *Client) Stop() error { @@ -236,8 +252,8 @@ func (c *Client) Stop() error { close(c.done) } - if c.prod != nil { - _ = c.prod.Stop() + if c.conn != nil { + _ = c.conn.Stop() } if c.api != nil { @@ -248,9 +264,5 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { - return webrtcProd.MarshalJSON() - } - - return json.Marshal(c.prod) + return c.conn.MarshalJSON() } diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 092b05c8..f853bf43 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -161,16 +161,7 @@ func (c *Conn) AddCandidate(candidate string) error { return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate}) } -func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { - for _, tr := range c.pc.GetTransceivers() { - if tr.Mid() == mid { - return tr - } - } - return nil -} - -func (c *Conn) getSenderTrack(mid string) *Track { +func (c *Conn) GetSenderTrack(mid string) *Track { if tr := c.getTranseiver(mid); tr != nil { if s := tr.Sender(); s != nil { if t := s.Track().(*Track); t != nil { @@ -181,6 +172,15 @@ func (c *Conn) getSenderTrack(mid string) *Track { return nil } +func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { + for _, tr := range c.pc.GetTransceivers() { + if tr.Mid() == mid { + return tr + } + } + return nil +} + func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) { for _, tr := range c.pc.GetTransceivers() { // search Transeiver for this TrackRemote @@ -209,7 +209,7 @@ func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Cod // check GetTrack panic(core.Caller()) - return nil, nil + // return nil, nil } func sanitizeIP6(host string) string { diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index ebc3a008..767394df 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -32,7 +32,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv panic(core.Caller()) } - localTrack := c.getSenderTrack(media.ID) + localTrack := c.GetSenderTrack(media.ID) if localTrack == nil { return errors.New("webrtc: can't get track") } From 5ec942cb5ea46d0f005e969fc99f1611394ae135 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 May 2025 15:09:27 +0200 Subject: [PATCH 012/241] fix stream mode --- pkg/tuya/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 586a8bbd..8a0cc221 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -71,7 +71,7 @@ func Dial(rawURL string) (core.Producer, error) { } // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamType) + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode) if err != nil { return nil, err } From e7bd3d401f8473771866ec8d2f068fb083732586 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 May 2025 22:25:35 +0200 Subject: [PATCH 013/241] wip h265 datachannel --- pkg/tuya/api.go | 96 +++++++++++++++++++++++++++---------------- pkg/tuya/client.go | 4 +- pkg/tuya/mqtt.go | 100 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 154 insertions(+), 46 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 2f7cd194..13e8265e 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -76,10 +76,10 @@ type Skill struct { SampleRate int `json:"sampleRate"` } `json:"audios"` Videos []struct { - StreamType int `json:"streamType"` // streamType = 2 => main stream - streamType = 4 => sub stream + StreamType int `json:"streamType"` // 2 = main stream, 4 = sub stream ProfileId string `json:"profileId"` Width int `json:"width"` - CodecType int `json:"codecType"` + CodecType int `json:"codecType"` // 2 = H264, 4 = H265 SampleRate int `json:"sampleRate"` Height int `json:"height"` } `json:"videos"` @@ -325,24 +325,24 @@ func (c *TuyaClient) InitDevice() (err error) { c.medias = make([]*core.Media, 0) if len(c.skill.Audios) > 0 { - // Use the first Audio-Codec - audio := c.skill.Audios[0] - direction := core.DirectionRecvonly if c.hasBackchannel { direction = core.DirectionSendRecv } + codecs := make([]*core.Codec, 0) + for _, audio := range c.skill.Audios { + codecs = append(codecs, &core.Codec{ + Name: getAudioCodec(audio.CodecType), + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + }) + } + c.medias = append(c.medias, &core.Media{ Kind: core.KindAudio, Direction: direction, - Codecs: []*core.Codec{ - { - Name: "PCMU", - ClockRate: uint32(audio.SampleRate), - Channels: uint8(audio.Channels), - }, - }, + Codecs: codecs, }) } else { // Use default values for Audio @@ -351,7 +351,7 @@ func (c *TuyaClient) InitDevice() (err error) { Direction: core.DirectionRecvonly, Codecs: []*core.Codec{ { - Name: "PCMU", + Name: core.CodecPCMU, ClockRate: uint32(8000), Channels: uint8(1), }, @@ -360,24 +360,27 @@ func (c *TuyaClient) InitDevice() (err error) { } if len(c.skill.Videos) > 0 { - // Use the first Video-Codec - video := c.skill.Videos[0] + codecs := make([]*core.Codec, 0) + for _, video := range c.skill.Videos { + if video.CodecType == 2 { + codecs = append(codecs, &core.Codec{ + Name: core.CodecH264, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }) + } else if video.CodecType == 4 { + codecs = append(codecs, &core.Codec{ + Name: core.CodecH265, + ClockRate: uint32(video.SampleRate), + PayloadType: 96, + }) + } + } c.medias = append(c.medias, &core.Media{ Kind: core.KindVideo, Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecH265, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, - }, - { - Name: core.CodecH264, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, - }, - }, + Codecs: codecs, }) } else { // Use default values for Video @@ -469,19 +472,19 @@ func (c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { return &openIoTHubConfigResponse.Result, nil } -func (c *TuyaClient) getStreamType(streamChoice string) uint32 { +func (c *TuyaClient) getStreamType(streamChoice string) int { // Default streamType if nothing is found - defaultStreamType := uint32(1) + defaultStreamType := 1 if c.skill == nil || len(c.skill.Videos) == 0 { return defaultStreamType } // Find the highest and lowest resolution - var highestResType uint32 = defaultStreamType - var highestRes int = 0 - var lowestResType uint32 = defaultStreamType - var lowestRes int = 0 + var highestResType = defaultStreamType + var highestRes = 0 + var lowestResType = defaultStreamType + var lowestRes = 0 for _, video := range c.skill.Videos { res := video.Width * video.Height @@ -489,13 +492,13 @@ func (c *TuyaClient) getStreamType(streamChoice string) uint32 { // Highest Resolution if res > highestRes { highestRes = res - highestResType = uint32(video.StreamType) + highestResType = video.StreamType } // Lower Resolution (or first if not set yet) if lowestRes == 0 || res < lowestRes { lowestRes = res - lowestResType = uint32(video.StreamType) + lowestResType = video.StreamType } } @@ -510,6 +513,29 @@ func (c *TuyaClient) getStreamType(streamChoice string) uint32 { } } +func getAudioCodec(codecType int) string { + switch codecType { + // case 100: + // return "ADPCM" + case 101: + return core.CodecPCM + 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.CodecPCMU + } +} + 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)) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 8a0cc221..3fb7bf07 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -95,7 +95,7 @@ func Dial(rawURL string) (core.Producer, error) { conf := pion.Configuration{ ICEServers: client.api.iceServers, ICETransportPolicy: pion.ICETransportPolicyAll, - BundlePolicy: pion.BundlePolicyBalanced, + BundlePolicy: pion.BundlePolicyMaxBundle, } api, err := webrtc.NewAPI() @@ -148,6 +148,8 @@ func Dial(rawURL string) (core.Producer, error) { } client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { + // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) + if candidate.Candidate != "" { client.conn.AddCandidate(candidate.Candidate) if err != nil { diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index bf9badd7..eb1c719e 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -39,10 +39,11 @@ type MqttFrame struct { } type OfferFrame struct { - Mode string `json:"mode"` - Sdp string `json:"sdp"` - StreamType uint32 `json:"stream_type"` - Auth string `json:"auth"` + Mode string `json:"mode"` + Sdp string `json:"sdp"` + StreamType int `json:"stream_type"` + Auth string `json:"auth"` + DatachannelEnable bool `json:"datachannel_enable"` } type AnswerFrame struct { @@ -57,7 +58,12 @@ type CandidateFrame struct { type ResolutionFrame struct { Mode string `json:"mode"` - Value int `json:"value"` + Value int `json:"value"` // 0: HD, 1: SD +} + +type SpeakerFrame struct { + Mode string `json:"mode"` + Value int `json:"value"` // 0: off, 1: on } type DisconnectFrame struct { @@ -202,12 +208,61 @@ func (c *TuyaMQTT) onError(err error) { } } -func (c *TuyaClient) sendOffer(sdp string, streamType uint32) { +func (c *TuyaClient) sendOffer(sdp string, streamType int) { + // H265 is currently not supported because Tuya does not send H265 data, and therefore also no audio over the normal WebRTC connection. + // The WebRTC connection is used only for sending audio back to the device (backchannel). + // Tuya expects a separate WebRTC DataChannel for H265 data and sends the H265 video and audio data packaged as fMP4 data back. + // These must then be processed separately (WIP - Work In Progress) + + // Example Answer (H265/PCMU with backchannel): + + /* + v=0 + o=- 1747174385 1 IN IP4 127.0.0.1 + s=- + t=0 0 + a=group:BUNDLE 0 1 + a=msid-semantic: WMS UMSklk + m=audio 9 UDP/TLS/RTP/SAVPF 0 + c=IN IP4 0.0.0.0 + a=rtcp:9 IN IP4 0.0.0.0 + a=ice-ufrag:zuRr + a=ice-pwd:EDeWXz847P810fyDyKxbmTdX + a=ice-options:trickle + a=fingerprint:sha-256 02:f5:44:8e:c6:5d:5c:59:49:50:a3:84:d5:e5:b9:35:bb:51:5a:0c:4d:a5:60:89:0f:e6:cb:0e:57:21:a0:14 + a=setup:active + a=mid:0 + a=sendrecv + a=msid:UMSklk NiNNboEn1rJWoQYtpguoKr1GBwpvPST + a=rtcp-mux + a=rtpmap:0 PCMU/8000 + a=ssrc:832759612 cname:bfa87264438073154dhdek + m=video 9 UDP/TLS/RTP/SAVPF 0 + c=IN IP4 0.0.0.0 + a=rtcp:9 IN IP4 0.0.0.0 + a=ice-ufrag:zuRr + a=ice-pwd:EDeWXz847P810fyDyKxbmTdX + a=ice-options:trickle + a=fingerprint:sha-256 02:f5:44:8e:c6:5d:5c:59:49:50:a3:84:d5:e5:b9:35:bb:51:5a:0c:4d:a5:60:89:0f:e6:cb:0e:57:21:a0:14 + a=setup:active + a=mid:1 + a=sendonly + a=msid:UMSklk l9o6icIVb7n7vDdp0KhocYnsijhd774 + a=rtcp-mux + a=rtpmap:0 /0 + a=rtcp-fb:0 ccm fir + a=rtcp-fb:0 nack + a=rtcp-fb:0 nack pli + a=fmtp:0 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id= + a=ssrc:0 cname:bfa87264438073154dhdek + */ + c.sendMqttMessage("offer", 302, "", OfferFrame{ - Mode: "webrtc", - Sdp: sdp, - StreamType: streamType, - Auth: c.auth, + Mode: "webrtc", + Sdp: sdp, + StreamType: streamType, + Auth: c.auth, + DatachannelEnable: c.isHEVC(streamType), }) } @@ -219,12 +274,23 @@ func (c *TuyaClient) sendCandidate(candidate string) { } func (c *TuyaClient) sendResolution(resolution int) { + if !c.isClaritySupported(resolution) { + return + } + c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ Mode: "webrtc", Value: resolution, }) } +func (c *TuyaClient) sendSpeaker(speaker int) { + c.sendMqttMessage("speaker", 302, "", SpeakerFrame{ + Mode: "webrtc", + Value: speaker, + }) +} + func (c *TuyaClient) sendDisconnect() { c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ Mode: "webrtc", @@ -271,3 +337,17 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti c.mqtt.onError(fmt.Errorf("mqtt publish fail: %s, topic: %s", token.Error().Error(), c.mqtt.publishTopic)) } } + +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) isClaritySupported(webrtcValue int) bool { + return (webrtcValue & (1 << 5)) != 0 +} From 499dc103901914a4d3fbddd293e836cb448cf026 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 May 2025 22:41:07 +0200 Subject: [PATCH 014/241] comments --- pkg/tuya/mqtt.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index eb1c719e..88f3a199 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -209,11 +209,18 @@ func (c *TuyaMQTT) onError(err error) { } func (c *TuyaClient) sendOffer(sdp string, streamType int) { + // Note: // H265 is currently not supported because Tuya does not send H265 data, and therefore also no audio over the normal WebRTC connection. // The WebRTC connection is used only for sending audio back to the device (backchannel). // Tuya expects a separate WebRTC DataChannel for H265 data and sends the H265 video and audio data packaged as fMP4 data back. // These must then be processed separately (WIP - Work In Progress) + // Note 2: + // Even if we don't receive any data, the peer connection is correctly established and connected + + // Note 3: + // It seems that if even one stream is HEVC, we also need to use the datachannel for the substream, even if that substream is using H264. + // Example Answer (H265/PCMU with backchannel): /* From 524cdb7176e1cc1e665ce137029664a5958bdb1d Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 May 2025 10:42:34 +0200 Subject: [PATCH 015/241] demuxer: support hvc and pcm --- pkg/iso/reader.go | 14 +++- pkg/mp4/demuxer.go | 195 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 3 deletions(-) diff --git a/pkg/iso/reader.go b/pkg/iso/reader.go index 175e2563..501a4eac 100644 --- a/pkg/iso/reader.go +++ b/pkg/iso/reader.go @@ -86,7 +86,7 @@ func DecodeAtom(b []byte) (any, error) { return DecodeAtom(data[1+3+4:]) } - case "avc1", "hev1": + case "avc1", "hev1", "hvc1": b = data[6+2+2+2+4+4+4+2+2+4+4+4+2+32+2+2:] atom, err := DecodeAtom(b) if err != nil { @@ -141,7 +141,17 @@ func DecodeAtom(b []byte) (any, error) { return atom, nil case MoofTrafTfdt: - return &AtomTfdt{DecodeTime: binary.BigEndian.Uint64(data[4:])}, nil + // Check version to determine field size + version := data[0] // First byte is version + if version == 0 { + // Version 0 uses 32-bit time + decodeTime := uint64(binary.BigEndian.Uint32(data[4:])) + return &AtomTfdt{DecodeTime: decodeTime}, nil + } else { + // Version 1 uses 64-bit time + decodeTime := binary.BigEndian.Uint64(data[4:]) + return &AtomTfdt{DecodeTime: decodeTime}, nil + } case MoofTrafTrun: rd := bits.NewReader(data) diff --git a/pkg/mp4/demuxer.go b/pkg/mp4/demuxer.go index 25c8c70e..67da93de 100644 --- a/pkg/mp4/demuxer.go +++ b/pkg/mp4/demuxer.go @@ -1,9 +1,12 @@ package mp4 import ( + "fmt" + "github.com/AlexxIT/go2rtc/pkg/aac" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" "github.com/AlexxIT/go2rtc/pkg/iso" "github.com/pion/rtp" ) @@ -13,6 +16,16 @@ type Demuxer struct { timeScales map[uint32]float32 } +type TrackPackets struct { + TrackID uint32 + Packets []*core.Packet +} + +type TrackData struct { + DecodeTime uint32 + Trun *iso.AtomTrun +} + func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { var trackID, timeScale uint32 @@ -34,11 +47,23 @@ func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { switch atom.Name { case "avc1": codec = h264.ConfigToCodec(atom.Config) + case "hvc1", "hev1": + codec = h265.ConfigToCodec(atom.Config) } case *iso.AtomAudio: switch atom.Name { case "mp4a": - codec = aac.ConfigToCodec(atom.Config) + // G.711 PCMU audio detection for 8kHz mono (Tuya...) + if atom.SampleRate == 8000 && atom.Channels == 1 { + codec = &core.Codec{ + Name: core.CodecPCMU, + ClockRate: 8000, + Channels: 1, + PayloadType: 0, + } + } else { + codec = aac.ConfigToCodec(atom.Config) + } } } @@ -47,6 +72,7 @@ func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { d.timeScales[trackID] = float32(codec.ClockRate) / float32(timeScale) medias = append(medias, &core.Media{ + ID: fmt.Sprintf("trackID=%d", trackID), Kind: codec.Kind(), Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec}, @@ -114,3 +140,170 @@ func (d *Demuxer) Demux(data2 []byte) (trackID uint32, packets []*core.Packet) { return } + +// DemuxAll returns packets from all tracks found in the fragment +func (d *Demuxer) DemuxAll(data []byte) []TrackPackets { + atoms, err := iso.DecodeAtoms(data) + if err != nil { + return nil + } + + // Map to store track-specific data + trackData := make(map[uint32]TrackData) + var mdat []byte + + // First pass: collect all track data + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomMdat: + mdat = atom.Data + } + } + + // Temporary variables to track current track ID while parsing + var currentTrackID uint32 + + // Second pass: process traf boxes + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomTfhd: + currentTrackID = atom.TrackID + + // Initialize track data if not exists + if _, ok := trackData[currentTrackID]; !ok { + trackData[currentTrackID] = TrackData{} + } + + case *iso.AtomTfdt: + if currentTrackID != 0 { + td := trackData[currentTrackID] + td.DecodeTime = uint32(atom.DecodeTime) + trackData[currentTrackID] = td + } + + case *iso.AtomTrun: + if currentTrackID != 0 { + td := trackData[currentTrackID] + td.Trun = atom + trackData[currentTrackID] = td + } + } + } + + // Process all tracks and collect results + var results []TrackPackets + + for tid, td := range trackData { + if td.Trun == nil || mdat == nil || len(td.Trun.SamplesSize) == 0 { + continue + } + + codec := d.codecs[tid] + if codec == nil { + continue + } + + timeScale := d.timeScales[tid] + + var packets []*core.Packet + switch codec.Kind() { + case "video": + packets = createVideoPackets(td.Trun, mdat, td.DecodeTime, timeScale) + case "audio": + packets = createAudioPackets(td.Trun, mdat, td.DecodeTime, timeScale, codec) + } + + if len(packets) > 0 { + results = append(results, TrackPackets{ + TrackID: tid, + Packets: packets, + }) + } + } + + return results +} + +// Creates video packets (H.264/H.265) +func createVideoPackets(trun *iso.AtomTrun, mdat []byte, decodeTime uint32, timeScale float32) []*core.Packet { + n := len(trun.SamplesSize) + hasDurations := len(trun.SamplesDuration) > 0 + + packets := make([]*core.Packet, n) + offset := uint32(0) + ts := decodeTime + + for i := 0; i < n; i++ { + // Get duration from array or use default + var duration uint32 + if hasDurations && i < len(trun.SamplesDuration) { + duration = trun.SamplesDuration[i] + } else { + duration = 1000 // Default for video + } + + size := trun.SamplesSize[i] + + if offset+size > uint32(len(mdat)) { + return packets[:i] + } + + timestamp := uint32(float32(ts) * timeScale) + packets[i] = &rtp.Packet{ + Header: rtp.Header{Timestamp: timestamp}, + Payload: mdat[offset : offset+size], + } + + offset += size + ts += duration + } + + return packets +} + +// Creates audio packets (G.711, AAC, etc.) +func createAudioPackets(trun *iso.AtomTrun, mdat []byte, decodeTime uint32, timeScale float32, codec *core.Codec) []*core.Packet { + n := len(trun.SamplesSize) + hasDurations := len(trun.SamplesDuration) > 0 + + packets := make([]*core.Packet, n) + offset := uint32(0) + ts := decodeTime + isPCM := codec.Name == core.CodecPCMU || codec.Name == core.CodecPCMA || codec.Name == core.CodecPCM || codec.Name == core.CodecPCML + + for i := 0; i < n; i++ { + size := trun.SamplesSize[i] + + // Calculate duration based on codec + var duration uint32 + if hasDurations && i < len(trun.SamplesDuration) { + duration = trun.SamplesDuration[i] + } else if isPCM { + duration = size + } else { + duration = 1024 + } + + if offset+size > uint32(len(mdat)) { + return packets[:i] + } + + // Calculate timestamp based on codec + var timestamp uint32 + if isPCM { + timestamp = ts + } else { + timestamp = uint32(float32(ts) * timeScale) + } + + packets[i] = &rtp.Packet{ + Header: rtp.Header{Timestamp: timestamp}, + Payload: mdat[offset : offset+size], + } + + offset += size + ts += duration + } + + return packets +} From 3d222136f95c4de1f219f4db1c7a95ba57720198 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 May 2025 10:45:04 +0200 Subject: [PATCH 016/241] support h265 --- pkg/tuya/api.go | 70 ++++++----- pkg/tuya/client.go | 45 +++++-- pkg/tuya/dc.go | 253 ++++++++++++++++++++++++++++++++++++++++ pkg/tuya/frameBuffer.go | 86 ++++++++++++++ pkg/tuya/mqtt.go | 83 +++---------- 5 files changed, 428 insertions(+), 109 deletions(-) create mode 100644 pkg/tuya/dc.go create mode 100644 pkg/tuya/frameBuffer.go diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 13e8265e..6ffa3dd7 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -67,22 +67,26 @@ 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, 4 = sub stream + 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 []struct { - Channels int `json:"channels"` - DataBit int `json:"dataBit"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` - } `json:"audios"` - Videos []struct { - StreamType int `json:"streamType"` // 2 = main stream, 4 = sub stream - ProfileId string `json:"profileId"` - Width int `json:"width"` - CodecType int `json:"codecType"` // 2 = H264, 4 = H265 - SampleRate int `json:"sampleRate"` - Height int `json:"height"` - } `json:"videos"` + WebRTC int `json:"webrtc"` + Audios []AudioSkill `json:"audios"` + Videos []VideoSkill `json:"videos"` } type WebRTConfig struct { @@ -299,20 +303,9 @@ func (c *TuyaClient) InitDevice() (err error) { c.auth = webRTCConfigResponse.Result.Auth c.skill = &Skill{ - Audios: []struct { - Channels int `json:"channels"` - DataBit int `json:"dataBit"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` - }{}, - Videos: []struct { - StreamType int `json:"streamType"` - ProfileId string `json:"profileId"` - Width int `json:"width"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` - Height int `json:"height"` - }{}, + WebRTC: 3, // basic webrtc + Audios: make([]AudioSkill, 0), + Videos: make([]VideoSkill, 0), } if webRTCConfigResponse.Result.Skill != "" { @@ -393,6 +386,11 @@ func (c *TuyaClient) InitDevice() (err error) { ClockRate: uint32(90000), PayloadType: 96, }, + { + Name: core.CodecH265, + ClockRate: uint32(90000), + PayloadType: 96, + }, }, }) } @@ -536,6 +534,20 @@ func getAudioCodec(codecType int) string { } } +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) isClaritySupported(webrtcValue int) bool { + return (webrtcValue & (1 << 5)) != 0 +} + 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)) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 3fb7bf07..37697310 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -14,9 +14,10 @@ import ( ) type Client struct { - api *TuyaClient - conn *webrtc.Conn - done chan struct{} + api *TuyaClient + conn *webrtc.Conn + dcConn *DCConn + done chan struct{} } const ( @@ -92,6 +93,8 @@ func Dial(rawURL string) (core.Producer, error) { } return streams.GetProducer(client.api.hlsURL) } else { + isHEVC := client.api.isHEVC(client.api.getStreamType(streamType)) + conf := pion.Configuration{ ICEServers: client.api.iceServers, ICETransportPolicy: pion.ICETransportPolicyAll, @@ -116,7 +119,7 @@ func Dial(rawURL string) (core.Producer, error) { // protect from blocking on errors defer sendOffer.Done(nil) - // waiter will wait PC error or WS error or nil (connection OK) + // waiter will wait PC error var connState core.Waiter client.conn = webrtc.NewConn(pc) @@ -167,6 +170,15 @@ func Dial(rawURL string) (core.Producer, error) { client.Stop() } + // Set up data channel for HEVC + if isHEVC { + client.dcConn, err = NewDCConn(pc, client) + if err != nil { + client.api.Close() + return nil, err + } + } + client.conn.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -182,6 +194,7 @@ func Dial(rawURL string) (core.Producer, error) { case pion.PeerConnectionStateConnected: connState.Done(nil) default: + client.Stop() connState.Done(errors.New("webrtc: " + msg.String())) } } @@ -200,9 +213,18 @@ func Dial(rawURL string) (core.Producer, error) { offer = re.ReplaceAllString(offer, "") // Send offer - client.api.sendOffer(offer, tuyaAPI.getStreamType(streamType)) + client.api.sendOffer(offer, streamType) sendOffer.Done(nil) + if client.dcConn != nil { + if err = client.dcConn.connected.Wait(); err != nil { + client.Stop() + return nil, err + } + + return client.dcConn, nil + } + if err = connState.Wait(); err != nil { return nil, err } @@ -223,12 +245,7 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece // RepackG711 will not work, so add default logic without repacking payloadType := codec.PayloadType - localTrack := c.conn.GetSenderTrack(media.ID) - if localTrack == nil { - return errors.New("webrtc: can't get track") - } - sender := core.NewSender(media, codec) sender.Handler = func(packet *rtp.Packet) { c.conn.Send += packet.MarshalSize() @@ -243,6 +260,10 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece } func (c *Client) Start() error { + if c.dcConn != nil { + c.dcConn.Start() + } + return c.conn.Start() } @@ -258,6 +279,10 @@ func (c *Client) Stop() error { _ = c.conn.Stop() } + if c.dcConn != nil { + _ = c.dcConn.Stop() + } + if c.api != nil { c.api.Close() } diff --git a/pkg/tuya/dc.go b/pkg/tuya/dc.go new file mode 100644 index 00000000..2ccd1313 --- /dev/null +++ b/pkg/tuya/dc.go @@ -0,0 +1,253 @@ +package tuya + +import ( + "encoding/json" + "errors" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" + "github.com/pion/rtp" + pion "github.com/pion/webrtc/v4" +) + +type DCConn struct { + core.Connection + + client *Client + dc *pion.DataChannel + dem *mp4.Demuxer + queue *FrameBufferQueue + msgs chan pion.DataChannelMessage + connected core.Waiter + closed core.Waiter + initialized bool +} + +type DataChannelMessage struct { + Type string `json:"type"` + Msg string `json:"msg"` +} + +func NewDCConn(pc *pion.PeerConnection, c *Client) (*DCConn, error) { + maxRetransmits := uint16(5) + ordered := true + dc, err := pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ + MaxRetransmits: &maxRetransmits, + Ordered: &ordered, + }) + + if err != nil { + return nil, err + } + + conn := &DCConn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "webrtc/fmp4", + Transport: dc, + }, + client: c, + dc: dc, + dem: &mp4.Demuxer{}, + queue: NewFrameBufferQueue(), + msgs: make(chan pion.DataChannelMessage, 10), // Saw max 4 messages in a row, 10 should be enough + initialized: false, + } + + dc.OnMessage(func(msg pion.DataChannelMessage) { + conn.msgs <- msg + }) + + dc.OnError(func(err error) { + conn.connected.Done(err) + }) + + dc.OnClose(func() { + close(conn.msgs) + conn.connected.Done(errors.New("datachannel: closed")) + }) + + go conn.initializationLoop() + + return conn, nil +} + +func (c *DCConn) initializationLoop() { + for msg := range c.msgs { + if c.initialized { + return + } + + err := c.probe(msg) + if err != nil { + c.connected.Done(err) + return + } + + if c.initialized { + c.connected.Done(nil) + return + } + } +} + +func (c *DCConn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + if media.Direction == core.DirectionSendRecv || media.Direction == core.DirectionSendonly { + return c.client.GetTrack(media, codec) + } + + for _, receiver := range c.Receivers { + if receiver.Codec == codec { + return receiver, nil + } + } + receiver := core.NewReceiver(media, codec) + c.Receivers = append(c.Receivers, receiver) + return receiver, nil +} + +func (c *DCConn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + payloadType := codec.PayloadType + localTrack := c.client.conn.GetSenderTrack(media.ID) + sender := core.NewSender(media, codec) + sender.Handler = func(packet *rtp.Packet) { + c.Send += packet.MarshalSize() + //important to send with remote PayloadType + _ = localTrack.WriteRTP(payloadType, packet) + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + + return nil +} + +func (c *DCConn) Start() error { + receivers := make(map[uint32]*core.Receiver) + for _, receiver := range c.Receivers { + trackID := c.dem.GetTrackID(receiver.Codec) + receivers[trackID] = receiver + } + + ch := make(chan []byte, 10) + defer close(ch) + + go func() { + for data := range ch { + allTracks := c.dem.DemuxAll(data) + for _, trackData := range allTracks { + trackID := trackData.TrackID + packets := trackData.Packets + receiver := receivers[trackID] + if receiver == nil { + continue + } + + for _, packet := range packets { + receiver.WriteRTP(packet) + } + } + } + }() + + go func() { + for msg := range c.msgs { + if len(msg.Data) >= 4 { + segmentNum := int(msg.Data[1]) + fragmentCount := int(msg.Data[2]) + fragmentSeq := int(msg.Data[3]) + mp4Data := msg.Data[4:] + + c.queue.AddFragment(segmentNum, fragmentCount, fragmentSeq, mp4Data) + + if c.queue.IsSegmentComplete(segmentNum, fragmentCount) { + b := c.queue.GetCombinedBuffer(segmentNum) + c.Recv += len(b) + ch <- b + } + } + } + }() + + c.closed.Wait() + return nil +} + +func (c *DCConn) sendMessageToDataChannel(message string) error { + if c.dc != nil { + return c.dc.SendText(message) + } + + return nil +} + +func (c *DCConn) probe(msg pion.DataChannelMessage) (err error) { + if msg.IsString { + var message DataChannelMessage + if err = json.Unmarshal(msg.Data, &message); err != nil { + return err + } + + switch message.Type { + case "codec": + response, _ := json.Marshal(DataChannelMessage{ + Type: "start", + Msg: "fmp4", + }) + + err = c.sendMessageToDataChannel(string(response)) + if err != nil { + return err + } + + case "recv": + response, _ := json.Marshal(DataChannelMessage{ + Type: "complete", + Msg: "", + }) + + err = c.sendMessageToDataChannel(string(response)) + if err != nil { + return err + } + } + + } else { + if len(msg.Data) >= 4 { + messageType := msg.Data[0] + segmentNum := int(msg.Data[1]) + fragmentCount := int(msg.Data[2]) + fragmentSeq := int(msg.Data[3]) + mp4Data := msg.Data[4:] + + // initialization segment + if messageType == 0 && segmentNum == 1 && fragmentCount == 1 && fragmentSeq == 1 { + medias := c.dem.Probe(mp4Data) + c.Medias = append(c.Medias, medias...) + + // Add backchannel + webrtcMedias := c.client.GetMedias() + for _, media := range webrtcMedias { + if media.Kind == core.KindAudio { + if media.Direction == core.DirectionSendRecv || media.Direction == core.DirectionSendonly { + c.Medias = append(c.Medias, media) + } + } + } + + c.initialized = true + } + } + } + + return nil +} + +func (c *DCConn) Stop() error { + if c.dc != nil && c.dc.ReadyState() == pion.DataChannelStateOpen { + _ = c.dc.Close() + } + + c.closed.Done(nil) + return nil +} diff --git a/pkg/tuya/frameBuffer.go b/pkg/tuya/frameBuffer.go new file mode 100644 index 00000000..bbcb4ff5 --- /dev/null +++ b/pkg/tuya/frameBuffer.go @@ -0,0 +1,86 @@ +package tuya + +import ( + "sort" + "sync" +) + +type FrameBufferQueue struct { + segments map[int]map[int][]byte // segNum -> fragSeq -> data + mu sync.Mutex +} + +func NewFrameBufferQueue() *FrameBufferQueue { + return &FrameBufferQueue{ + segments: make(map[int]map[int][]byte), + } +} + +func (q *FrameBufferQueue) AddFragment(segmentNum, fragmentCount, fragmentSeq int, data []byte) { + q.mu.Lock() + defer q.mu.Unlock() + + if _, ok := q.segments[segmentNum]; !ok { + q.segments[segmentNum] = make(map[int][]byte) + } + + q.segments[segmentNum][fragmentSeq] = data +} + +func (q *FrameBufferQueue) IsSegmentComplete(segmentNum, fragmentCount int) bool { + q.mu.Lock() + defer q.mu.Unlock() + + if frags, ok := q.segments[segmentNum]; ok { + // Make sure we have the right number of fragments + if len(frags) != fragmentCount { + return false + } + + // Check if we have all sequences from 1 to fragmentCount + for i := 1; i <= fragmentCount; i++ { + if _, ok := frags[i]; !ok { + return false + } + } + + return true + } + + return false +} + +func (q *FrameBufferQueue) GetCombinedBuffer(segNum int) []byte { + q.mu.Lock() + defer q.mu.Unlock() + + if frags, ok := q.segments[segNum]; ok { + // Sort fragments by sequence number + var keys []int + for k := range frags { + keys = append(keys, k) + } + sort.Ints(keys) + + // Calculate total size for pre-allocation + totalSize := 0 + for _, k := range keys { + totalSize += len(frags[k]) + } + + // Pre-allocate buffer for better performance + combined := make([]byte, 0, totalSize) + + // Combine fragments in sequence order + for _, k := range keys { + combined = append(combined, frags[k]...) + } + + // Remove this segment to free memory + delete(q.segments, segNum) + + return combined + } + + return nil +} diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 88f3a199..9eb25b49 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -208,68 +208,25 @@ func (c *TuyaMQTT) onError(err error) { } } -func (c *TuyaClient) sendOffer(sdp string, streamType int) { - // Note: - // H265 is currently not supported because Tuya does not send H265 data, and therefore also no audio over the normal WebRTC connection. - // The WebRTC connection is used only for sending audio back to the device (backchannel). - // Tuya expects a separate WebRTC DataChannel for H265 data and sends the H265 video and audio data packaged as fMP4 data back. - // These must then be processed separately (WIP - Work In Progress) +func (c *TuyaClient) sendOffer(sdp string, streamType string) { + fixedStreamType := c.getStreamType(streamType) + isHEVC := c.isHEVC(fixedStreamType) - // Note 2: - // Even if we don't receive any data, the peer connection is correctly established and connected - - // Note 3: - // It seems that if even one stream is HEVC, we also need to use the datachannel for the substream, even if that substream is using H264. - - // Example Answer (H265/PCMU with backchannel): - - /* - v=0 - o=- 1747174385 1 IN IP4 127.0.0.1 - s=- - t=0 0 - a=group:BUNDLE 0 1 - a=msid-semantic: WMS UMSklk - m=audio 9 UDP/TLS/RTP/SAVPF 0 - c=IN IP4 0.0.0.0 - a=rtcp:9 IN IP4 0.0.0.0 - a=ice-ufrag:zuRr - a=ice-pwd:EDeWXz847P810fyDyKxbmTdX - a=ice-options:trickle - a=fingerprint:sha-256 02:f5:44:8e:c6:5d:5c:59:49:50:a3:84:d5:e5:b9:35:bb:51:5a:0c:4d:a5:60:89:0f:e6:cb:0e:57:21:a0:14 - a=setup:active - a=mid:0 - a=sendrecv - a=msid:UMSklk NiNNboEn1rJWoQYtpguoKr1GBwpvPST - a=rtcp-mux - a=rtpmap:0 PCMU/8000 - a=ssrc:832759612 cname:bfa87264438073154dhdek - m=video 9 UDP/TLS/RTP/SAVPF 0 - c=IN IP4 0.0.0.0 - a=rtcp:9 IN IP4 0.0.0.0 - a=ice-ufrag:zuRr - a=ice-pwd:EDeWXz847P810fyDyKxbmTdX - a=ice-options:trickle - a=fingerprint:sha-256 02:f5:44:8e:c6:5d:5c:59:49:50:a3:84:d5:e5:b9:35:bb:51:5a:0c:4d:a5:60:89:0f:e6:cb:0e:57:21:a0:14 - a=setup:active - a=mid:1 - a=sendonly - a=msid:UMSklk l9o6icIVb7n7vDdp0KhocYnsijhd774 - a=rtcp-mux - a=rtpmap:0 /0 - a=rtcp-fb:0 ccm fir - a=rtcp-fb:0 nack - a=rtcp-fb:0 nack pli - a=fmtp:0 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id= - a=ssrc:0 cname:bfa87264438073154dhdek - */ + if isHEVC { + // On HEVC we use streamType 0 for main stream and 1 for sub stream + if streamType == "main" { + fixedStreamType = 0 + } else { + fixedStreamType = 1 + } + } c.sendMqttMessage("offer", 302, "", OfferFrame{ Mode: "webrtc", Sdp: sdp, - StreamType: streamType, + StreamType: fixedStreamType, Auth: c.auth, - DatachannelEnable: c.isHEVC(streamType), + DatachannelEnable: isHEVC, }) } @@ -344,17 +301,3 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti c.mqtt.onError(fmt.Errorf("mqtt publish fail: %s, topic: %s", token.Error().Error(), c.mqtt.publishTopic)) } } - -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) isClaritySupported(webrtcValue int) bool { - return (webrtcValue & (1 << 5)) != 0 -} From 27fe2622ec6c2bd20e141f61dbf6be76ee6973ab Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 11:40:27 +0200 Subject: [PATCH 017/241] revert demuxer --- pkg/iso/reader.go | 14 +--- pkg/mp4/demuxer.go | 195 +-------------------------------------------- 2 files changed, 3 insertions(+), 206 deletions(-) diff --git a/pkg/iso/reader.go b/pkg/iso/reader.go index 501a4eac..175e2563 100644 --- a/pkg/iso/reader.go +++ b/pkg/iso/reader.go @@ -86,7 +86,7 @@ func DecodeAtom(b []byte) (any, error) { return DecodeAtom(data[1+3+4:]) } - case "avc1", "hev1", "hvc1": + case "avc1", "hev1": b = data[6+2+2+2+4+4+4+2+2+4+4+4+2+32+2+2:] atom, err := DecodeAtom(b) if err != nil { @@ -141,17 +141,7 @@ func DecodeAtom(b []byte) (any, error) { return atom, nil case MoofTrafTfdt: - // Check version to determine field size - version := data[0] // First byte is version - if version == 0 { - // Version 0 uses 32-bit time - decodeTime := uint64(binary.BigEndian.Uint32(data[4:])) - return &AtomTfdt{DecodeTime: decodeTime}, nil - } else { - // Version 1 uses 64-bit time - decodeTime := binary.BigEndian.Uint64(data[4:]) - return &AtomTfdt{DecodeTime: decodeTime}, nil - } + return &AtomTfdt{DecodeTime: binary.BigEndian.Uint64(data[4:])}, nil case MoofTrafTrun: rd := bits.NewReader(data) diff --git a/pkg/mp4/demuxer.go b/pkg/mp4/demuxer.go index 67da93de..25c8c70e 100644 --- a/pkg/mp4/demuxer.go +++ b/pkg/mp4/demuxer.go @@ -1,12 +1,9 @@ package mp4 import ( - "fmt" - "github.com/AlexxIT/go2rtc/pkg/aac" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" - "github.com/AlexxIT/go2rtc/pkg/h265" "github.com/AlexxIT/go2rtc/pkg/iso" "github.com/pion/rtp" ) @@ -16,16 +13,6 @@ type Demuxer struct { timeScales map[uint32]float32 } -type TrackPackets struct { - TrackID uint32 - Packets []*core.Packet -} - -type TrackData struct { - DecodeTime uint32 - Trun *iso.AtomTrun -} - func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { var trackID, timeScale uint32 @@ -47,23 +34,11 @@ func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { switch atom.Name { case "avc1": codec = h264.ConfigToCodec(atom.Config) - case "hvc1", "hev1": - codec = h265.ConfigToCodec(atom.Config) } case *iso.AtomAudio: switch atom.Name { case "mp4a": - // G.711 PCMU audio detection for 8kHz mono (Tuya...) - if atom.SampleRate == 8000 && atom.Channels == 1 { - codec = &core.Codec{ - Name: core.CodecPCMU, - ClockRate: 8000, - Channels: 1, - PayloadType: 0, - } - } else { - codec = aac.ConfigToCodec(atom.Config) - } + codec = aac.ConfigToCodec(atom.Config) } } @@ -72,7 +47,6 @@ func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { d.timeScales[trackID] = float32(codec.ClockRate) / float32(timeScale) medias = append(medias, &core.Media{ - ID: fmt.Sprintf("trackID=%d", trackID), Kind: codec.Kind(), Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec}, @@ -140,170 +114,3 @@ func (d *Demuxer) Demux(data2 []byte) (trackID uint32, packets []*core.Packet) { return } - -// DemuxAll returns packets from all tracks found in the fragment -func (d *Demuxer) DemuxAll(data []byte) []TrackPackets { - atoms, err := iso.DecodeAtoms(data) - if err != nil { - return nil - } - - // Map to store track-specific data - trackData := make(map[uint32]TrackData) - var mdat []byte - - // First pass: collect all track data - for _, atom := range atoms { - switch atom := atom.(type) { - case *iso.AtomMdat: - mdat = atom.Data - } - } - - // Temporary variables to track current track ID while parsing - var currentTrackID uint32 - - // Second pass: process traf boxes - for _, atom := range atoms { - switch atom := atom.(type) { - case *iso.AtomTfhd: - currentTrackID = atom.TrackID - - // Initialize track data if not exists - if _, ok := trackData[currentTrackID]; !ok { - trackData[currentTrackID] = TrackData{} - } - - case *iso.AtomTfdt: - if currentTrackID != 0 { - td := trackData[currentTrackID] - td.DecodeTime = uint32(atom.DecodeTime) - trackData[currentTrackID] = td - } - - case *iso.AtomTrun: - if currentTrackID != 0 { - td := trackData[currentTrackID] - td.Trun = atom - trackData[currentTrackID] = td - } - } - } - - // Process all tracks and collect results - var results []TrackPackets - - for tid, td := range trackData { - if td.Trun == nil || mdat == nil || len(td.Trun.SamplesSize) == 0 { - continue - } - - codec := d.codecs[tid] - if codec == nil { - continue - } - - timeScale := d.timeScales[tid] - - var packets []*core.Packet - switch codec.Kind() { - case "video": - packets = createVideoPackets(td.Trun, mdat, td.DecodeTime, timeScale) - case "audio": - packets = createAudioPackets(td.Trun, mdat, td.DecodeTime, timeScale, codec) - } - - if len(packets) > 0 { - results = append(results, TrackPackets{ - TrackID: tid, - Packets: packets, - }) - } - } - - return results -} - -// Creates video packets (H.264/H.265) -func createVideoPackets(trun *iso.AtomTrun, mdat []byte, decodeTime uint32, timeScale float32) []*core.Packet { - n := len(trun.SamplesSize) - hasDurations := len(trun.SamplesDuration) > 0 - - packets := make([]*core.Packet, n) - offset := uint32(0) - ts := decodeTime - - for i := 0; i < n; i++ { - // Get duration from array or use default - var duration uint32 - if hasDurations && i < len(trun.SamplesDuration) { - duration = trun.SamplesDuration[i] - } else { - duration = 1000 // Default for video - } - - size := trun.SamplesSize[i] - - if offset+size > uint32(len(mdat)) { - return packets[:i] - } - - timestamp := uint32(float32(ts) * timeScale) - packets[i] = &rtp.Packet{ - Header: rtp.Header{Timestamp: timestamp}, - Payload: mdat[offset : offset+size], - } - - offset += size - ts += duration - } - - return packets -} - -// Creates audio packets (G.711, AAC, etc.) -func createAudioPackets(trun *iso.AtomTrun, mdat []byte, decodeTime uint32, timeScale float32, codec *core.Codec) []*core.Packet { - n := len(trun.SamplesSize) - hasDurations := len(trun.SamplesDuration) > 0 - - packets := make([]*core.Packet, n) - offset := uint32(0) - ts := decodeTime - isPCM := codec.Name == core.CodecPCMU || codec.Name == core.CodecPCMA || codec.Name == core.CodecPCM || codec.Name == core.CodecPCML - - for i := 0; i < n; i++ { - size := trun.SamplesSize[i] - - // Calculate duration based on codec - var duration uint32 - if hasDurations && i < len(trun.SamplesDuration) { - duration = trun.SamplesDuration[i] - } else if isPCM { - duration = size - } else { - duration = 1024 - } - - if offset+size > uint32(len(mdat)) { - return packets[:i] - } - - // Calculate timestamp based on codec - var timestamp uint32 - if isPCM { - timestamp = ts - } else { - timestamp = uint32(float32(ts) * timeScale) - } - - packets[i] = &rtp.Packet{ - Header: rtp.Header{Timestamp: timestamp}, - Payload: mdat[offset : offset+size], - } - - offset += size - ts += duration - } - - return packets -} From 16a812c8b8f1558cba8608f7530092483decb5eb Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 11:42:06 +0200 Subject: [PATCH 018/241] revert webrtc --- pkg/webrtc/conn.go | 24 ++++++++++++------------ pkg/webrtc/consumer.go | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index f853bf43..063831f0 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -161,21 +161,21 @@ func (c *Conn) AddCandidate(candidate string) error { return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate}) } -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 { - return t - } +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) getTranseiver(mid string) *webrtc.RTPTransceiver { - for _, tr := range c.pc.GetTransceivers() { - if tr.Mid() == mid { - return tr +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 { + return t + } } } return nil @@ -209,7 +209,7 @@ func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Cod // check GetTrack panic(core.Caller()) - // return nil, nil + return nil, nil } func sanitizeIP6(host string) string { @@ -217,4 +217,4 @@ func sanitizeIP6(host string) string { return "[" + host + "]" } return host -} +} \ No newline at end of file diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index 767394df..1efb1507 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -32,7 +32,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv panic(core.Caller()) } - localTrack := c.GetSenderTrack(media.ID) + localTrack := c.getSenderTrack(media.ID) if localTrack == nil { return errors.New("webrtc: can't get track") } @@ -87,4 +87,4 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv c.Senders = append(c.Senders, sender) return nil -} +} \ No newline at end of file From a9bcb46f387bd8fe1bc46dde556cf710a264f821 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 14:25:18 +0200 Subject: [PATCH 019/241] refactor --- pkg/tuya/api.go | 447 ++++++++++++++-------------------------- pkg/tuya/client.go | 277 ++++++++++++++++++++----- pkg/tuya/dc.go | 253 ----------------------- pkg/tuya/frameBuffer.go | 86 -------- pkg/tuya/mqtt.go | 58 +++--- pkg/webrtc/conn.go | 2 +- pkg/webrtc/consumer.go | 2 +- 7 files changed, 415 insertions(+), 710 deletions(-) delete mode 100644 pkg/tuya/dc.go delete mode 100644 pkg/tuya/frameBuffer.go diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 6ffa3dd7..d08a68dc 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -17,25 +17,23 @@ import ( ) 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 - medias []*core.Media - hasBackchannel bool + 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 { @@ -159,21 +157,16 @@ type OpenIoTHubConfigResponse struct { Code int `json:"code,omitempty"` } -const ( - defaultTimeout = 5 * time.Second -) - -func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId string, clientSecret string, streamMode string) (*TuyaClient, error) { +func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId string, clientSecret string, streamMode string, streamRole string) (*TuyaClient, error) { client := &TuyaClient{ - httpClient: &http.Client{Timeout: defaultTimeout}, - mqtt: &TuyaMQTT{waiter: core.Waiter{}}, - apiURL: openAPIURL, - sessionId: core.RandString(6, 62), - clientId: clientId, - deviceId: deviceId, - clientSecret: clientSecret, - uid: uid, - hasBackchannel: false, + 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 { @@ -189,7 +182,7 @@ func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId stri return nil, fmt.Errorf("failed to get HLS URL: %w", err) } } else { - if err := client.InitDevice(); err != nil { + if err := client.InitDevice(streamRole); err != nil { return nil, fmt.Errorf("failed to initialize device: %w", err) } @@ -206,6 +199,135 @@ func (c *TuyaClient) Close() { 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(streamRole string) (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 { @@ -253,224 +375,7 @@ func (c *TuyaClient) Request(method string, url string, body any) ([]byte, error return res, nil } -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("error: %s", 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("error: %s", webRTCConfigResponse.Msg) - } - - c.motoId = webRTCConfigResponse.Result.MotoID - c.auth = webRTCConfigResponse.Result.Auth - - c.skill = &Skill{ - WebRTC: 3, // basic webrtc - Audios: make([]AudioSkill, 0), - Videos: make([]VideoSkill, 0), - } - - if webRTCConfigResponse.Result.Skill != "" { - _ = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), c.skill) - } - - c.hasBackchannel = contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && - contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) - - c.medias = make([]*core.Media, 0) - - if len(c.skill.Audios) > 0 { - direction := core.DirectionRecvonly - if c.hasBackchannel { - direction = core.DirectionSendRecv - } - - codecs := make([]*core.Codec, 0) - for _, audio := range c.skill.Audios { - codecs = append(codecs, &core.Codec{ - Name: getAudioCodec(audio.CodecType), - ClockRate: uint32(audio.SampleRate), - Channels: uint8(audio.Channels), - }) - } - - c.medias = append(c.medias, &core.Media{ - Kind: core.KindAudio, - Direction: direction, - Codecs: codecs, - }) - } else { - // Use default values for Audio - c.medias = append(c.medias, &core.Media{ - Kind: core.KindAudio, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecPCMU, - ClockRate: uint32(8000), - Channels: uint8(1), - }, - }, - }) - } - - if len(c.skill.Videos) > 0 { - codecs := make([]*core.Codec, 0) - for _, video := range c.skill.Videos { - if video.CodecType == 2 { - codecs = append(codecs, &core.Codec{ - Name: core.CodecH264, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, - }) - } else if video.CodecType == 4 { - codecs = append(codecs, &core.Codec{ - Name: core.CodecH265, - ClockRate: uint32(video.SampleRate), - PayloadType: 96, - }) - } - } - - c.medias = append(c.medias, &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: codecs, - }) - } else { - // Use default values for Video - c.medias = append(c.medias, &core.Media{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecH264, - ClockRate: uint32(90000), - PayloadType: 96, - }, - { - Name: core.CodecH265, - ClockRate: uint32(90000), - PayloadType: 96, - }, - }, - }) - } - - iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) - if err != nil { - return err - } - - c.iceServers, err = webrtc.UnmarshalICEServers([]byte(iceServersBytes)) - if err != nil { - return err - } - - 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("error: %s", 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("error: %s", openIoTHubConfigResponse.Msg) - } - - return &openIoTHubConfigResponse.Result, nil -} - -func (c *TuyaClient) getStreamType(streamChoice string) int { +func (c *TuyaClient) getStreamType(streamRole string) int { // Default streamType if nothing is found defaultStreamType := 1 @@ -501,7 +406,7 @@ func (c *TuyaClient) getStreamType(streamChoice string) int { } // Return the streamType based on the selection - switch streamChoice { + switch streamRole { case "main": return highestResType case "sub": @@ -511,29 +416,6 @@ func (c *TuyaClient) getStreamType(streamChoice string) int { } } -func getAudioCodec(codecType int) string { - switch codecType { - // case 100: - // return "ADPCM" - case 101: - return core.CodecPCM - 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.CodecPCMU - } -} - func (c *TuyaClient) isHEVC(streamType int) bool { for _, video := range c.skill.Videos { if video.StreamType == streamType { @@ -544,22 +426,9 @@ func (c *TuyaClient) isHEVC(streamType int) bool { return false } -func (c *TuyaClient) isClaritySupported(webrtcValue int) bool { - return (webrtcValue & (1 << 5)) != 0 -} - 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 } - -func contains(slice []int, val int) bool { - for _, item := range slice { - if item == val { - return true - } - } - return false -} diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 37697310..ecf4d480 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -1,6 +1,7 @@ package tuya import ( + "encoding/json" "errors" "fmt" "net/url" @@ -14,10 +15,30 @@ import ( ) type Client struct { - api *TuyaClient - conn *webrtc.Conn - dcConn *DCConn - done chan struct{} + api *TuyaClient + conn *webrtc.Conn + pc *pion.PeerConnection + dc *pion.DataChannel + videoSSRC uint32 + audioSSRC uint32 + isHEVC bool + connected core.Waiter + closed bool + handlers map[uint32]func(*rtp.Packet) +} + +type DataChannelMessage struct { + Type string `json:"type"` + Msg string `json:"msg"` +} + +type RecvMessage struct { + Video struct { + SSRC uint32 `json:"ssrc"` + } `json:"video"` + Audio struct { + SSRC uint32 `json:"ssrc"` + } `json:"audio"` } const ( @@ -30,7 +51,6 @@ const ( ) func Dial(rawURL string) (core.Producer, error) { - // Parse URL and validate basic params u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -41,8 +61,13 @@ func Dial(rawURL string) (core.Producer, error) { uid := query.Get("uid") clientId := query.Get("client_id") clientSecret := query.Get("client_secret") - streamType := query.Get("type") + streamRole := query.Get("role") streamMode := query.Get("mode") + + if streamRole == "" || (streamRole != "main" && streamRole != "sub") { + streamRole = "main" + } + useRTSP := streamMode == "rtsp" useHLS := streamMode == "hls" useWebRTC := streamMode == "webrtc" || streamMode == "" @@ -72,14 +97,14 @@ func Dial(rawURL string) (core.Producer, error) { } // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode) + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode, streamRole) if err != nil { - return nil, err + return nil, fmt.Errorf("tuya: %w", err) } client := &Client{ - api: tuyaAPI, - done: make(chan struct{}), + api: tuyaAPI, + handlers: make(map[uint32]func(*rtp.Packet)), } if useRTSP { @@ -93,8 +118,9 @@ func Dial(rawURL string) (core.Producer, error) { } return streams.GetProducer(client.api.hlsURL) } else { - isHEVC := client.api.isHEVC(client.api.getStreamType(streamType)) + client.isHEVC = client.api.isHEVC(client.api.getStreamType(streamRole)) + // Create a new PeerConnection conf := pion.Configuration{ ICEServers: client.api.iceServers, ICETransportPolicy: pion.ICETransportPolicyAll, @@ -107,7 +133,7 @@ func Dial(rawURL string) (core.Producer, error) { return nil, err } - pc, err := api.NewPeerConnection(conf) + client.pc, err = api.NewPeerConnection(conf) if err != nil { client.api.Close() return nil, err @@ -119,10 +145,8 @@ func Dial(rawURL string) (core.Producer, error) { // protect from blocking on errors defer sendOffer.Done(nil) - // waiter will wait PC error - var connState core.Waiter - - client.conn = webrtc.NewConn(pc) + // Create new WebRTC connection + client.conn = webrtc.NewConn(client.pc) client.conn.FormatName = "tuya/webrtc" client.conn.Mode = core.ModeActiveProducer client.conn.Protocol = "mqtt" @@ -137,8 +161,8 @@ func Dial(rawURL string) (core.Producer, error) { SDP: answer.Sdp, } - if err = pc.SetRemoteDescription(desc); err != nil { - client.Stop() + if err = client.pc.SetRemoteDescription(desc); err != nil { + client.connected.Done(err) return } @@ -147,7 +171,18 @@ func Dial(rawURL string) (core.Producer, error) { return } - client.conn.SDP = answer.Sdp + if client.isHEVC { + // Tuya answers always with H264 codec, replace with HEVC + for _, media := range client.conn.Medias { + if media.Kind == core.KindVideo { + for _, codec := range media.Codecs { + if codec.Name == core.CodecH264 { + codec.Name = core.CodecH265 + } + } + } + } + } } client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { @@ -170,15 +205,57 @@ func Dial(rawURL string) (core.Producer, error) { client.Stop() } - // Set up data channel for HEVC - if isHEVC { - client.dcConn, err = NewDCConn(pc, client) - if err != nil { - client.api.Close() - return nil, err - } + // On HEVC, use DataChannel to receive video/audio + if client.isHEVC { + // Create a new DataChannel + maxRetransmits := uint16(5) + ordered := true + client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ + MaxRetransmits: &maxRetransmits, + Ordered: &ordered, + }) + + // Set up data channel handler + client.dc.OnMessage(func(msg pion.DataChannelMessage) { + if msg.IsString { + client.probe(msg) + } else { + packet := &rtp.Packet{} + if err := packet.Unmarshal(msg.Data); err != nil { + return + } + + if handler, ok := client.handlers[packet.SSRC]; ok { + handler(packet) + } + } + }) + + client.dc.OnError(func(err error) { + // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) + client.connected.Done(err) + }) + + client.dc.OnClose(func() { + // fmt.Println("tuya: datachannel closed") + client.connected.Done(errors.New("datachannel: closed")) + }) + + client.dc.OnOpen(func() { + // fmt.Println("tuya: datachannel opened") + + codecRequest, _ := json.Marshal(DataChannelMessage{ + Type: "codec", + Msg: "", + }) + + if err := client.sendMessageToDataChannel(codecRequest); err != nil { + client.connected.Done(fmt.Errorf("failed to send codec request: %w", err)) + } + }) } + // Set up pc handler client.conn.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -192,16 +269,25 @@ func Dial(rawURL string) (core.Producer, error) { case pion.PeerConnectionStateConnecting: break case pion.PeerConnectionStateConnected: - connState.Done(nil) + // On HEVC, wait for DataChannel to be opened and camera to send codec info + if !client.isHEVC { + client.connected.Done(nil) + } default: client.Stop() - connState.Done(errors.New("webrtc: " + msg.String())) + client.connected.Done(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(client.api.medias) + offer, err := client.conn.CreateOffer(medias) if err != nil { client.api.Close() return nil, err @@ -213,20 +299,12 @@ func Dial(rawURL string) (core.Producer, error) { offer = re.ReplaceAllString(offer, "") // Send offer - client.api.sendOffer(offer, streamType) + client.api.sendOffer(offer, streamRole) sendOffer.Done(nil) - if client.dcConn != nil { - if err = client.dcConn.connected.Wait(); err != nil { - client.Stop() - return nil, err - } - - return client.dcConn, nil - } - - if err = connState.Wait(); err != nil { - return nil, err + // Wait for connection + if err = client.connected.Wait(); err != nil { + return nil, fmt.Errorf("tuya: %w", err) } return client, nil @@ -242,10 +320,15 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - // RepackG711 will not work, so add default logic without repacking + // Manually handle backchannel, because repacking audio through go2rtc does not work + + localTrack := c.getSender() + if localTrack == nil { + return errors.New("webrtc: can't get track") + } payloadType := codec.PayloadType - localTrack := c.conn.GetSenderTrack(media.ID) + sender := core.NewSender(media, codec) sender.Handler = func(packet *rtp.Packet) { c.conn.Send += packet.MarshalSize() @@ -260,29 +343,47 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece } func (c *Client) Start() error { - if c.dcConn != nil { - c.dcConn.Start() + 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 + } + } + + c.handlers[c.videoSSRC] = func(packet *rtp.Packet) { + if video != nil { + video.WriteRTP(packet) + } + } + + c.handlers[c.audioSSRC] = func(packet *rtp.Packet) { + if audio != nil { + audio.WriteRTP(packet) + } } return c.conn.Start() } func (c *Client) Stop() error { - select { - case <-c.done: + if c.closed { return nil - default: - close(c.done) + } + + for ssrc := range c.handlers { + delete(c.handlers, ssrc) } if c.conn != nil { _ = c.conn.Stop() } - if c.dcConn != nil { - _ = c.dcConn.Stop() - } - if c.api != nil { c.api.Close() } @@ -293,3 +394,73 @@ func (c *Client) Stop() error { func (c *Client) MarshalJSON() ([]byte, error) { return c.conn.MarshalJSON() } + +func (c *Client) probe(msg pion.DataChannelMessage) { + // 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)) + } + + switch message.Type { + case "codec": + // fmt.Printf("[tuya] Codec info from camera: %s\n", message.Msg) + + frameRequest, _ := json.Marshal(DataChannelMessage{ + Type: "start", + Msg: "frame", + }) + + err := c.sendMessageToDataChannel(frameRequest) + if err != nil { + c.connected.Done(fmt.Errorf("failed to send frame request: %w", 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 + } + + c.videoSSRC = recvMessage.Video.SSRC + c.audioSSRC = recvMessage.Audio.SSRC + + completeMsg, _ := json.Marshal(DataChannelMessage{ + Type: "complete", + Msg: "", + }) + + err := c.sendMessageToDataChannel(completeMsg) + if err != nil { + c.connected.Done(fmt.Errorf("failed to send complete message: %w", err)) + } + + c.connected.Done(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 +} + +func (c *Client) getSender() *webrtc.Track { + for _, tr := range c.pc.GetTransceivers() { + if tr.Kind() == pion.RTPCodecTypeAudio { + if tr.Kind() == pion.RTPCodecType(pion.RTPTransceiverDirectionSendonly) || tr.Kind() == pion.RTPCodecType(pion.RTPTransceiverDirectionSendrecv) { + if s := tr.Sender(); s != nil { + if t := s.Track().(*webrtc.Track); t != nil { + return t + } + } + } + } + } + return nil +} diff --git a/pkg/tuya/dc.go b/pkg/tuya/dc.go deleted file mode 100644 index 2ccd1313..00000000 --- a/pkg/tuya/dc.go +++ /dev/null @@ -1,253 +0,0 @@ -package tuya - -import ( - "encoding/json" - "errors" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/mp4" - "github.com/pion/rtp" - pion "github.com/pion/webrtc/v4" -) - -type DCConn struct { - core.Connection - - client *Client - dc *pion.DataChannel - dem *mp4.Demuxer - queue *FrameBufferQueue - msgs chan pion.DataChannelMessage - connected core.Waiter - closed core.Waiter - initialized bool -} - -type DataChannelMessage struct { - Type string `json:"type"` - Msg string `json:"msg"` -} - -func NewDCConn(pc *pion.PeerConnection, c *Client) (*DCConn, error) { - maxRetransmits := uint16(5) - ordered := true - dc, err := pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ - MaxRetransmits: &maxRetransmits, - Ordered: &ordered, - }) - - if err != nil { - return nil, err - } - - conn := &DCConn{ - Connection: core.Connection{ - ID: core.NewID(), - FormatName: "webrtc/fmp4", - Transport: dc, - }, - client: c, - dc: dc, - dem: &mp4.Demuxer{}, - queue: NewFrameBufferQueue(), - msgs: make(chan pion.DataChannelMessage, 10), // Saw max 4 messages in a row, 10 should be enough - initialized: false, - } - - dc.OnMessage(func(msg pion.DataChannelMessage) { - conn.msgs <- msg - }) - - dc.OnError(func(err error) { - conn.connected.Done(err) - }) - - dc.OnClose(func() { - close(conn.msgs) - conn.connected.Done(errors.New("datachannel: closed")) - }) - - go conn.initializationLoop() - - return conn, nil -} - -func (c *DCConn) initializationLoop() { - for msg := range c.msgs { - if c.initialized { - return - } - - err := c.probe(msg) - if err != nil { - c.connected.Done(err) - return - } - - if c.initialized { - c.connected.Done(nil) - return - } - } -} - -func (c *DCConn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - if media.Direction == core.DirectionSendRecv || media.Direction == core.DirectionSendonly { - return c.client.GetTrack(media, codec) - } - - for _, receiver := range c.Receivers { - if receiver.Codec == codec { - return receiver, nil - } - } - receiver := core.NewReceiver(media, codec) - c.Receivers = append(c.Receivers, receiver) - return receiver, nil -} - -func (c *DCConn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - payloadType := codec.PayloadType - localTrack := c.client.conn.GetSenderTrack(media.ID) - sender := core.NewSender(media, codec) - sender.Handler = func(packet *rtp.Packet) { - c.Send += packet.MarshalSize() - //important to send with remote PayloadType - _ = localTrack.WriteRTP(payloadType, packet) - } - - sender.HandleRTP(track) - c.Senders = append(c.Senders, sender) - - return nil -} - -func (c *DCConn) Start() error { - receivers := make(map[uint32]*core.Receiver) - for _, receiver := range c.Receivers { - trackID := c.dem.GetTrackID(receiver.Codec) - receivers[trackID] = receiver - } - - ch := make(chan []byte, 10) - defer close(ch) - - go func() { - for data := range ch { - allTracks := c.dem.DemuxAll(data) - for _, trackData := range allTracks { - trackID := trackData.TrackID - packets := trackData.Packets - receiver := receivers[trackID] - if receiver == nil { - continue - } - - for _, packet := range packets { - receiver.WriteRTP(packet) - } - } - } - }() - - go func() { - for msg := range c.msgs { - if len(msg.Data) >= 4 { - segmentNum := int(msg.Data[1]) - fragmentCount := int(msg.Data[2]) - fragmentSeq := int(msg.Data[3]) - mp4Data := msg.Data[4:] - - c.queue.AddFragment(segmentNum, fragmentCount, fragmentSeq, mp4Data) - - if c.queue.IsSegmentComplete(segmentNum, fragmentCount) { - b := c.queue.GetCombinedBuffer(segmentNum) - c.Recv += len(b) - ch <- b - } - } - } - }() - - c.closed.Wait() - return nil -} - -func (c *DCConn) sendMessageToDataChannel(message string) error { - if c.dc != nil { - return c.dc.SendText(message) - } - - return nil -} - -func (c *DCConn) probe(msg pion.DataChannelMessage) (err error) { - if msg.IsString { - var message DataChannelMessage - if err = json.Unmarshal(msg.Data, &message); err != nil { - return err - } - - switch message.Type { - case "codec": - response, _ := json.Marshal(DataChannelMessage{ - Type: "start", - Msg: "fmp4", - }) - - err = c.sendMessageToDataChannel(string(response)) - if err != nil { - return err - } - - case "recv": - response, _ := json.Marshal(DataChannelMessage{ - Type: "complete", - Msg: "", - }) - - err = c.sendMessageToDataChannel(string(response)) - if err != nil { - return err - } - } - - } else { - if len(msg.Data) >= 4 { - messageType := msg.Data[0] - segmentNum := int(msg.Data[1]) - fragmentCount := int(msg.Data[2]) - fragmentSeq := int(msg.Data[3]) - mp4Data := msg.Data[4:] - - // initialization segment - if messageType == 0 && segmentNum == 1 && fragmentCount == 1 && fragmentSeq == 1 { - medias := c.dem.Probe(mp4Data) - c.Medias = append(c.Medias, medias...) - - // Add backchannel - webrtcMedias := c.client.GetMedias() - for _, media := range webrtcMedias { - if media.Kind == core.KindAudio { - if media.Direction == core.DirectionSendRecv || media.Direction == core.DirectionSendonly { - c.Medias = append(c.Medias, media) - } - } - } - - c.initialized = true - } - } - } - - return nil -} - -func (c *DCConn) Stop() error { - if c.dc != nil && c.dc.ReadyState() == pion.DataChannelStateOpen { - _ = c.dc.Close() - } - - c.closed.Done(nil) - return nil -} diff --git a/pkg/tuya/frameBuffer.go b/pkg/tuya/frameBuffer.go deleted file mode 100644 index bbcb4ff5..00000000 --- a/pkg/tuya/frameBuffer.go +++ /dev/null @@ -1,86 +0,0 @@ -package tuya - -import ( - "sort" - "sync" -) - -type FrameBufferQueue struct { - segments map[int]map[int][]byte // segNum -> fragSeq -> data - mu sync.Mutex -} - -func NewFrameBufferQueue() *FrameBufferQueue { - return &FrameBufferQueue{ - segments: make(map[int]map[int][]byte), - } -} - -func (q *FrameBufferQueue) AddFragment(segmentNum, fragmentCount, fragmentSeq int, data []byte) { - q.mu.Lock() - defer q.mu.Unlock() - - if _, ok := q.segments[segmentNum]; !ok { - q.segments[segmentNum] = make(map[int][]byte) - } - - q.segments[segmentNum][fragmentSeq] = data -} - -func (q *FrameBufferQueue) IsSegmentComplete(segmentNum, fragmentCount int) bool { - q.mu.Lock() - defer q.mu.Unlock() - - if frags, ok := q.segments[segmentNum]; ok { - // Make sure we have the right number of fragments - if len(frags) != fragmentCount { - return false - } - - // Check if we have all sequences from 1 to fragmentCount - for i := 1; i <= fragmentCount; i++ { - if _, ok := frags[i]; !ok { - return false - } - } - - return true - } - - return false -} - -func (q *FrameBufferQueue) GetCombinedBuffer(segNum int) []byte { - q.mu.Lock() - defer q.mu.Unlock() - - if frags, ok := q.segments[segNum]; ok { - // Sort fragments by sequence number - var keys []int - for k := range frags { - keys = append(keys, k) - } - sort.Ints(keys) - - // Calculate total size for pre-allocation - totalSize := 0 - for _, k := range keys { - totalSize += len(frags[k]) - } - - // Pre-allocate buffer for better performance - combined := make([]byte, 0, totalSize) - - // Combine fragments in sequence order - for _, k := range keys { - combined = append(combined, frags[k]...) - } - - // Remove this segment to free memory - delete(q.segments, segNum) - - return combined - } - - return nil -} diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 9eb25b49..ea69f60e 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -131,7 +131,7 @@ func (c *TuyaClient) onConnect(client mqtt.Client) { 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(fmt.Errorf("unmarshal mqtt message fail: %s, payload: %s", err.Error(), string(msg.Payload()))) + c.mqtt.onError(err) return } @@ -152,10 +152,7 @@ func (c *TuyaClient) consume(client mqtt.Client, msg mqtt.Message) { func (c *TuyaMQTT) onMqttAnswer(msg *MqttMessage) { var answerFrame AnswerFrame if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { - c.onError(fmt.Errorf("unmarshal mqtt answer frame fail: %s, session: %s, frame: %s", - err.Error(), - msg.Data.Header.SessionID, - string(msg.Data.Message))) + c.onError(err) return } @@ -165,10 +162,7 @@ func (c *TuyaMQTT) onMqttAnswer(msg *MqttMessage) { func (c *TuyaMQTT) onMqttCandidate(msg *MqttMessage) { var candidateFrame CandidateFrame if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { - c.onError(fmt.Errorf("unmarshal mqtt candidate frame fail: %s, session: %s, frame: %s", - err.Error(), - msg.Data.Header.SessionID, - string(msg.Data.Message))) + c.onError(err) return } @@ -208,37 +202,48 @@ func (c *TuyaMQTT) onError(err error) { } } -func (c *TuyaClient) sendOffer(sdp string, streamType string) { - fixedStreamType := c.getStreamType(streamType) - isHEVC := c.isHEVC(fixedStreamType) +func (c *TuyaClient) sendOffer(sdp string, streamRole string) { + streamType := c.getStreamType(streamRole) + isHEVC := c.isHEVC(streamType) if isHEVC { // On HEVC we use streamType 0 for main stream and 1 for sub stream - if streamType == "main" { - fixedStreamType = 0 + if streamRole == "main" { + streamType = 0 } else { - fixedStreamType = 1 + streamType = 1 } } - c.sendMqttMessage("offer", 302, "", OfferFrame{ + err := c.sendMqttMessage("offer", 302, "", OfferFrame{ Mode: "webrtc", Sdp: sdp, - StreamType: fixedStreamType, + StreamType: streamType, Auth: c.auth, DatachannelEnable: isHEVC, }) + + if err != nil { + c.mqtt.onError(err) + return + } } func (c *TuyaClient) sendCandidate(candidate string) { - c.sendMqttMessage("candidate", 302, "", CandidateFrame{ + err := c.sendMqttMessage("candidate", 302, "", CandidateFrame{ Mode: "webrtc", Candidate: candidate, }) + + if err != nil { + c.mqtt.onError(err) + return + } } func (c *TuyaClient) sendResolution(resolution int) { - if !c.isClaritySupported(resolution) { + isClaritySupperted := (c.skill.WebRTC & (1 << 5)) != 0 + if !isClaritySupperted { return } @@ -261,16 +266,14 @@ func (c *TuyaClient) sendDisconnect() { }) } -func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) { +func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { if c.mqtt.closed { - c.mqtt.onError(fmt.Errorf("mqtt client is closed, send mqtt message fail")) - return + return fmt.Errorf("mqtt client is closed, send mqtt message fail") } jsonMessage, err := json.Marshal(data) if err != nil { - c.mqtt.onError(fmt.Errorf("marshal mqtt message fail: %s", err.Error())) - return + return err } msg := &MqttMessage{ @@ -292,12 +295,13 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti payload, err := json.Marshal(msg) if err != nil { - c.mqtt.onError(fmt.Errorf("marshal mqtt message fail: %s", err.Error())) - return + return err } token := c.mqtt.client.Publish(c.mqtt.publishTopic, 1, false, payload) if token.Wait() && token.Error() != nil { - c.mqtt.onError(fmt.Errorf("mqtt publish fail: %s, topic: %s", token.Error().Error(), c.mqtt.publishTopic)) + return token.Error() } + + return nil } diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 063831f0..092b05c8 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -217,4 +217,4 @@ func sanitizeIP6(host string) string { return "[" + host + "]" } return host -} \ No newline at end of file +} diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index 1efb1507..ebc3a008 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -87,4 +87,4 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv c.Senders = append(c.Senders, sender) return nil -} \ No newline at end of file +} From e7044a93f62b2b4004f18b6de45b890d61032275 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 15:23:54 +0200 Subject: [PATCH 020/241] fix video/audio and minor improvements --- pkg/tuya/api.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++ pkg/tuya/client.go | 43 ++++++++++++++++++++------- pkg/tuya/mqtt.go | 56 +++++++++++++++-------------------- 3 files changed, 128 insertions(+), 44 deletions(-) diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index d08a68dc..3842ed02 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -375,6 +375,79 @@ func (c *TuyaClient) Request(method string, url string, body any) ([]byte, error 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(streamRole string) int { // Default streamType if nothing is found defaultStreamType := 1 diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index ecf4d480..bebdacd5 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -129,13 +129,13 @@ func Dial(rawURL string) (core.Producer, error) { api, err := webrtc.NewAPI() if err != nil { - client.api.Close() + client.Stop() return nil, err } client.pc, err = api.NewPeerConnection(conf) if err != nil { - client.api.Close() + client.Stop() return nil, err } @@ -167,18 +167,27 @@ func Dial(rawURL string) (core.Producer, error) { } if err = client.conn.SetAnswer(answer.Sdp); err != nil { - client.Stop() + client.connected.Done(err) return } if client.isHEVC { - // Tuya answers always with H264 codec, replace with HEVC + // Tuya seems to answers always with H264 and PCMU/8000 and PCMA/8000 codecs, replace with real codecs + for _, media := range client.conn.Medias { if media.Kind == core.KindVideo { - for _, codec := range media.Codecs { - if codec.Name == core.CodecH264 { - codec.Name = core.CodecH265 - } + codecs := client.api.getVideoCodecs() + if codecs != nil { + media.Codecs = codecs + } + } + } + + for _, media := range client.conn.Medias { + if media.Kind == core.KindAudio { + codecs := client.api.getAudioCodecs() + if codecs != nil { + media.Codecs = codecs } } } @@ -197,6 +206,7 @@ func Dial(rawURL string) (core.Producer, error) { } client.api.mqtt.handleDisconnect = func() { + // fmt.Println("tuya: disconnect") client.Stop() } @@ -222,6 +232,7 @@ func Dial(rawURL string) (core.Producer, error) { } else { packet := &rtp.Packet{} if err := packet.Unmarshal(msg.Data); err != nil { + // skip return } @@ -260,7 +271,9 @@ func Dial(rawURL string) (core.Producer, error) { switch msg := msg.(type) { case *pion.ICECandidate: _ = sendOffer.Wait() - client.api.sendCandidate("a=" + msg.ToJSON().Candidate) + if err := client.api.sendCandidate("a=" + msg.ToJSON().Candidate); err != nil { + client.connected.Done(err) + } case pion.PeerConnectionState: switch msg { @@ -289,7 +302,7 @@ func Dial(rawURL string) (core.Producer, error) { // Create offer offer, err := client.conn.CreateOffer(medias) if err != nil { - client.api.Close() + client.Stop() return nil, err } @@ -299,7 +312,11 @@ func Dial(rawURL string) (core.Producer, error) { offer = re.ReplaceAllString(offer, "") // Send offer - client.api.sendOffer(offer, streamRole) + if err := client.api.sendOffer(offer, streamRole); err != nil { + client.Stop() + return nil, fmt.Errorf("tuya: %w", err) + } + sendOffer.Done(nil) // Wait for connection @@ -327,6 +344,8 @@ 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) + payloadType := codec.PayloadType sender := core.NewSender(media, codec) @@ -376,6 +395,8 @@ func (c *Client) Stop() error { return nil } + c.closed = true + for ssrc := range c.handlers { delete(c.handlers, ssrc) } diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index ea69f60e..ebc27894 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -56,10 +56,10 @@ 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:"value"` // 0: HD, 1: SD +// } type SpeakerFrame struct { Mode string `json:"mode"` @@ -114,7 +114,7 @@ func (c *TuyaClient) StartMQTT() error { func (c *TuyaClient) StopMQTT() { if c.mqtt.client != nil { - c.sendDisconnect() + _ = c.sendDisconnect() c.mqtt.client.Disconnect(1000) } } @@ -202,7 +202,7 @@ func (c *TuyaMQTT) onError(err error) { } } -func (c *TuyaClient) sendOffer(sdp string, streamRole string) { +func (c *TuyaClient) sendOffer(sdp string, streamRole string) error { streamType := c.getStreamType(streamRole) isHEVC := c.isHEVC(streamType) @@ -215,53 +215,43 @@ func (c *TuyaClient) sendOffer(sdp string, streamRole string) { } } - err := c.sendMqttMessage("offer", 302, "", OfferFrame{ + return c.sendMqttMessage("offer", 302, "", OfferFrame{ Mode: "webrtc", Sdp: sdp, StreamType: streamType, Auth: c.auth, DatachannelEnable: isHEVC, }) - - if err != nil { - c.mqtt.onError(err) - return - } } -func (c *TuyaClient) sendCandidate(candidate string) { - err := c.sendMqttMessage("candidate", 302, "", CandidateFrame{ +func (c *TuyaClient) sendCandidate(candidate string) error { + return c.sendMqttMessage("candidate", 302, "", CandidateFrame{ Mode: "webrtc", Candidate: candidate, }) - - if err != nil { - c.mqtt.onError(err) - return - } } -func (c *TuyaClient) sendResolution(resolution int) { - isClaritySupperted := (c.skill.WebRTC & (1 << 5)) != 0 - if !isClaritySupperted { - return - } +// func (c *TuyaClient) sendResolution(resolution int) error { +// isClaritySupperted := (c.skill.WebRTC & (1 << 5)) != 0 +// if !isClaritySupperted { +// return nil +// } - c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ - Mode: "webrtc", - Value: resolution, - }) -} +// return c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ +// Mode: "webrtc", +// Value: resolution, +// }) +// } -func (c *TuyaClient) sendSpeaker(speaker int) { - c.sendMqttMessage("speaker", 302, "", SpeakerFrame{ +func (c *TuyaClient) sendSpeaker(speaker int) error { + return c.sendMqttMessage("speaker", 302, "", SpeakerFrame{ Mode: "webrtc", Value: speaker, }) } -func (c *TuyaClient) sendDisconnect() { - c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ +func (c *TuyaClient) sendDisconnect() error { + return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ Mode: "webrtc", }) } From 8a8fb66eeb3de0dffb31c9bb5a1eb6d4b3b66345 Mon Sep 17 00:00:00 2001 From: seydx <34152761+seydx@users.noreply.github.com> Date: Sat, 17 May 2025 15:55:43 +0200 Subject: [PATCH 021/241] Update README.md Co-authored-by: Felipe Santos --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43e1c83b..7982cbf2 100644 --- a/README.md +++ b/README.md @@ -570,7 +570,7 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. [Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. -- Obtain `client_id`, `client_secret`, `uid` and `device_id` from [Tuya IoT Platform](https://iot.tuya.com/) +- 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)_ From 691f6d9cddc273a900fa738e1cf95e097ec8aa84 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 15:57:06 +0200 Subject: [PATCH 022/241] update README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7982cbf2..ed2b96d2 100644 --- a/README.md +++ b/README.md @@ -575,7 +575,7 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. - `webrtc` - WebRTC stream (default) - `rtsp` - RTSP stream _(if available)_ - `hls` - HLS stream _(if available)_ -- Use `type` parameter to select the stream type: _(if available)_ +- Use `role` parameter to select the stream: - `main` - Main stream (default) - `sub` - Sub stream @@ -588,10 +588,10 @@ streams: 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&type=main + tuya_webrtc_hd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&role=main # Tuya WebRTC stream (SD) - tuya_webrtc_sd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&type=sub + tuya_webrtc_sd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&role=sub # Using RTSP when available (no "uid" required) tuya_rtsp: tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp From f045f3fccd0cbc28b64a9f43328bdc4f022472bf Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 18:19:59 +0200 Subject: [PATCH 023/241] dont expose raw url in stream info --- pkg/tuya/client.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index bebdacd5..c67f8c47 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -150,7 +150,6 @@ func Dial(rawURL string) (core.Producer, error) { client.conn.FormatName = "tuya/webrtc" client.conn.Mode = core.ModeActiveProducer client.conn.Protocol = "mqtt" - client.conn.URL = rawURL // Set up MQTT handlers client.api.mqtt.handleAnswer = func(answer AnswerFrame) { From 1cc8b373dec7769318065e9c849e5a66f2ae1b57 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 18 May 2025 05:11:41 +0200 Subject: [PATCH 024/241] change query --- README.md | 10 +++++----- pkg/tuya/README.md | 3 ++- pkg/tuya/api.go | 16 ++++++++-------- pkg/tuya/client.go | 12 ++++++------ pkg/tuya/mqtt.go | 8 ++++---- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ed2b96d2..05d940fd 100644 --- a/README.md +++ b/README.md @@ -575,9 +575,9 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. - `webrtc` - WebRTC stream (default) - `rtsp` - RTSP stream _(if available)_ - `hls` - HLS stream _(if available)_ -- Use `role` parameter to select the stream: - - `main` - Main stream (default) - - `sub` - Sub stream +- Use `resolution` parameter to select the stream: + - `hd` - HD stream (default) + - `sd` - SD stream ```yaml streams: @@ -588,10 +588,10 @@ streams: 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&role=main + 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&role=sub + 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 diff --git a/pkg/tuya/README.md b/pkg/tuya/README.md index f5cbc814..137bd155 100644 --- a/pkg/tuya/README.md +++ b/pkg/tuya/README.md @@ -3,4 +3,5 @@ - 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://ipc-us.ismartlife.me/ \ No newline at end of file +- https://ipc-us.ismartlife.me/ +- https://protect-us.ismartlife.me/ \ No newline at end of file diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go index 3842ed02..3fb72c62 100644 --- a/pkg/tuya/api.go +++ b/pkg/tuya/api.go @@ -73,7 +73,7 @@ type AudioSkill struct { } type VideoSkill struct { - StreamType int `json:"streamType"` // 2 = main stream, 4 = sub stream + 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"` @@ -157,7 +157,7 @@ type OpenIoTHubConfigResponse struct { Code int `json:"code,omitempty"` } -func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId string, clientSecret string, streamMode string, streamRole string) (*TuyaClient, error) { +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{}}, @@ -182,7 +182,7 @@ func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId stri return nil, fmt.Errorf("failed to get HLS URL: %w", err) } } else { - if err := client.InitDevice(streamRole); err != nil { + if err := client.InitDevice(); err != nil { return nil, fmt.Errorf("failed to initialize device: %w", err) } @@ -227,7 +227,7 @@ func (c *TuyaClient) InitToken() (err error) { return nil } -func (c *TuyaClient) InitDevice(streamRole string) (err error) { +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) @@ -448,7 +448,7 @@ func getAudioCodecName(audioSkill *AudioSkill) string { } } -func (c *TuyaClient) getStreamType(streamRole string) int { +func (c *TuyaClient) getStreamType(streamResolution string) int { // Default streamType if nothing is found defaultStreamType := 1 @@ -479,10 +479,10 @@ func (c *TuyaClient) getStreamType(streamRole string) int { } // Return the streamType based on the selection - switch streamRole { - case "main": + switch streamResolution { + case "hd": return highestResType - case "sub": + case "sd": return lowestResType default: return defaultStreamType diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index c67f8c47..5a865690 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -61,11 +61,11 @@ func Dial(rawURL string) (core.Producer, error) { uid := query.Get("uid") clientId := query.Get("client_id") clientSecret := query.Get("client_secret") - streamRole := query.Get("role") + streamResolution := query.Get("resolution") streamMode := query.Get("mode") - if streamRole == "" || (streamRole != "main" && streamRole != "sub") { - streamRole = "main" + if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { + streamResolution = "hd" } useRTSP := streamMode == "rtsp" @@ -97,7 +97,7 @@ func Dial(rawURL string) (core.Producer, error) { } // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode, streamRole) + tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode) if err != nil { return nil, fmt.Errorf("tuya: %w", err) } @@ -118,7 +118,7 @@ func Dial(rawURL string) (core.Producer, error) { } return streams.GetProducer(client.api.hlsURL) } else { - client.isHEVC = client.api.isHEVC(client.api.getStreamType(streamRole)) + client.isHEVC = client.api.isHEVC(client.api.getStreamType(streamResolution)) // Create a new PeerConnection conf := pion.Configuration{ @@ -311,7 +311,7 @@ func Dial(rawURL string) (core.Producer, error) { offer = re.ReplaceAllString(offer, "") // Send offer - if err := client.api.sendOffer(offer, streamRole); err != nil { + if err := client.api.sendOffer(offer, streamResolution); err != nil { client.Stop() return nil, fmt.Errorf("tuya: %w", err) } diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index ebc27894..6fd3d4a0 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -202,13 +202,13 @@ func (c *TuyaMQTT) onError(err error) { } } -func (c *TuyaClient) sendOffer(sdp string, streamRole string) error { - streamType := c.getStreamType(streamRole) +func (c *TuyaClient) sendOffer(sdp string, streamResolution string) error { + streamType := c.getStreamType(streamResolution) isHEVC := c.isHEVC(streamType) if isHEVC { - // On HEVC we use streamType 0 for main stream and 1 for sub stream - if streamRole == "main" { + // On HEVC we use streamType 0 for main stream (hd) and 1 for sub stream (sd) + if streamResolution == "hd" { streamType = 0 } else { streamType = 1 From 67dfc942a0ab8f854feaa56391b5ed3e087e6a4d Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 19 May 2025 15:41:37 +0200 Subject: [PATCH 025/241] update useful links --- pkg/tuya/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/tuya/README.md b/pkg/tuya/README.md index 137bd155..cc213a66 100644 --- a/pkg/tuya/README.md +++ b/pkg/tuya/README.md @@ -4,4 +4,5 @@ - 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://ipc-us.ismartlife.me/ -- https://protect-us.ismartlife.me/ \ No newline at end of file +- https://protect-us.ismartlife.me/ +- https://github.com/tuya/tuya-device-sharing-sdk \ No newline at end of file From 998c85d6f5598f08d0e23c241756c8ef8c700ae6 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 22 May 2025 00:05:49 +0200 Subject: [PATCH 026/241] - support adding cameras via interface - support qr code auth - support resolution change - support h265 - refactor code --- README.md | 59 +++-- internal/tuya/tuya.go | 201 ++++++++++++++++ pkg/tuya/README.md | 5 +- pkg/tuya/api.go | 507 --------------------------------------- pkg/tuya/client.go | 513 +++++++++++++++++++++------------------- pkg/tuya/cloud_api.go | 312 ++++++++++++++++++++++++ pkg/tuya/crypto.go | 134 +++++++++++ pkg/tuya/helper.go | 72 ++++++ pkg/tuya/interface.go | 259 ++++++++++++++++++++ pkg/tuya/mqtt.go | 267 +++++++++++---------- pkg/tuya/sharing_api.go | 473 ++++++++++++++++++++++++++++++++++++ www/add.html | 105 ++++++++ 12 files changed, 2003 insertions(+), 904 deletions(-) delete mode 100644 pkg/tuya/api.go create mode 100644 pkg/tuya/cloud_api.go create mode 100644 pkg/tuya/crypto.go create mode 100644 pkg/tuya/helper.go create mode 100644 pkg/tuya/interface.go create mode 100644 pkg/tuya/sharing_api.go diff --git a/README.md b/README.md index 05d940fd..00b9cc45 100644 --- a/README.md +++ b/README.md @@ -568,39 +568,60 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. #### Source: Tuya -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Open API`. +The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Open API` does not require a cloud project and the cameras can be added through the interface via QR code (user code required), but it does not support webrtc mode and two way audio. + +**Cloud API**: - Obtain `device_id`, `client_id`, `client_secret`, and `uid` (if using `mode=webrtc`) from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). -- Use `mode` parameter to select the stream type: - - `webrtc` - WebRTC stream (default) - - `rtsp` - RTSP stream _(if available)_ - - `hls` - HLS stream _(if available)_ -- Use `resolution` parameter to select the stream: + +**Open API**: +- To get your user code, open the Tuya Smart app or Smart Life app and go to `Profile` > `Settings` > `Account and Security` > `User Code` +- Open the Go2rtc interface and go to `Add` > `Tuya` and enter your `User Code` in the `User Code` field. Click on `Generate QR Code` and scan it with the Tuya Smart app or Smart Life app. After scanning, click on `Login`. All cameras in your home (not shared ones) will be listed in the Go2rtc interface. Copy/Paste stream URLs to your `go2rtc.yaml` file. + +**Configuring the stream:** +- Use `mode` parameter to select the stream type (not all cameras support all modes): + - `webrtc` - WebRTC stream _(default for `Cloud API`)_ + - `rtsp` - RTSP stream _(default for `Open API`)_ + - `hls` - HLS stream + - `flv` - FLV stream _(only available for `Open API`)_ + - `rtmp` - RTMP stream _(only available for `Open API`)_ + +- Use `resolution` parameter to select the stream (only available for `Cloud API` and not all cameras support `hd` stream): - `hd` - HD stream (default) - `sd` - SD stream ```yaml streams: - # Tuya WebRTC stream - tuya_webrtc: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX + # Cloud API: WebRTC stream + tuya_webrtc: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX - # Tuya WebRTC stream (same as above) - tuya_webrtc_2: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&mode=webrtc + # Cloud API: WebRTC stream (same as above) + tuya_webrtc_2: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&mode=webrtc - # Tuya WebRTC stream (HD) - tuya_webrtc_hd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=hd + # Cloud API: WebRTC stream (HD) + tuya_webrtc_hd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=hd - # Tuya WebRTC stream (SD) - tuya_webrtc_sd: tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd + # Cloud API: WebRTC stream (SD) + tuya_webrtc_sd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd - # Using RTSP when available (no "uid" required) - tuya_rtsp: tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp + # Cloud API: RTSP stream when available (no "uid" required) + tuya_rtsp: + - tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp - # Using HLS when available (no "uid" required) - tuya_hls: tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=hls + # Cloud API: HLS stream when available (no "uid" required) + tuya_hls: + - tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=hls + + # Open API: RTSP stream + tuya_openapi: + - tuya://apigw.tuyaeu.com?device_id=XXX&terminal_id=XXX&token=XXX&uid=XXX ``` - #### Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go index c3b34e4a..b5457253 100644 --- a/internal/tuya/tuya.go +++ b/internal/tuya/tuya.go @@ -1,13 +1,214 @@ package tuya import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tuya" ) +var users = make(map[string]tuya.LoginResponse) + func Init() { streams.HandleFunc("tuya", func(source string) (core.Producer, error) { return tuya.Dial(source) }) + + api.HandleFunc("api/tuya", apiTuya) +} + +func apiTuya(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + userCode := query.Get("user_code") + token := query.Get("token") + + if userCode == "" { + http.Error(w, "user_code is required", http.StatusBadRequest) + return + } + + var auth *tuya.LoginResponse + if loginResponse, ok := users[userCode]; ok { + expireTime := loginResponse.Timestamp + loginResponse.Result.ExpireTime + + if expireTime > time.Now().Unix() { + auth = &loginResponse + } else { + delete(users, userCode) + token = "" + } + } + + if auth == nil && token == "" { + qrCode, err := getQRCode(userCode) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // response qrCode + json.NewEncoder(w).Encode(map[string]interface{}{ + "qrCode": qrCode, + }) + + return + } + + if auth == nil && token != "" { + authResponse, err := login(userCode, token) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + auth = authResponse + } + + if auth == nil { + http.Error(w, "failed to get auth", http.StatusInternalServerError) + return + } + + tokenInfo := tuya.TokenInfo{ + AccessToken: auth.Result.AccessToken, + ExpireTime: auth.Timestamp + auth.Result.ExpireTime, + RefreshToken: auth.Result.RefreshToken, + } + + tokenInfoBase64, err := tuya.ToBase64(&tokenInfo) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tuyaAPI, err := tuya.NewTuyaOpenApiClient( + strings.Replace(auth.Result.Endpoint, "https://", "", 1), + auth.Result.UID, + "", + auth.Result.TerminalID, + tokenInfo, + "", + ) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := tuyaAPI.GetAllDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + for _, device := range devices { + cleanQuery := url.Values{} + cleanQuery.Set("uid", auth.Result.UID) + cleanQuery.Set("token", tokenInfoBase64) + cleanQuery.Set("terminal_id", auth.Result.TerminalID) + cleanQuery.Set("device_id", device.ID) + + endpoint := strings.Replace(auth.Result.Endpoint, "https://", "tuya://", 1) + url := fmt.Sprintf("%s?%s", endpoint, cleanQuery.Encode()) + + items = append(items, &api.Source{ + Name: device.Name, + URL: url, + }) + } + + api.ResponseSources(w, items) +} + +func login(userCode string, qrCode string) (*tuya.LoginResponse, error) { + url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens/%s?clientid=%s&usercode=%s", tuya.TUYA_HOST, qrCode, tuya.TUYA_CLIENT_ID, userCode) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + response, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get QR code: %s", string(res)) + } + + var loginResponse tuya.LoginResponse + err = json.Unmarshal(res, &loginResponse) + if err != nil { + return nil, err + } + + if !loginResponse.Success { + return nil, fmt.Errorf("failed to login: %s", loginResponse.Msg) + } + + users[userCode] = loginResponse + + return &loginResponse, nil +} + +func getQRCode(userCode string) (string, error) { + url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens?clientid=%s&schema=%s&usercode=%s", tuya.TUYA_HOST, tuya.TUYA_CLIENT_ID, tuya.TUYA_SCHEMA, userCode) + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "text/plain") + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + response, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + if response.StatusCode != http.StatusOK { + return "", err + } + + var qrResponse tuya.QRResponse + err = json.Unmarshal(res, &qrResponse) + if err != nil { + return "", err + } + + if !qrResponse.Success { + return "", fmt.Errorf("failed to get QR code: %s", qrResponse.Msg) + } + + return qrResponse.Result.Code, nil } diff --git a/pkg/tuya/README.md b/pkg/tuya/README.md index cc213a66..f1936404 100644 --- a/pkg/tuya/README.md +++ b/pkg/tuya/README.md @@ -3,6 +3,7 @@ - https://developer.tuya.com/en/docs/iot/webrtc?id=Kacsd4x2hl0se - https://github.com/tuya/webrtc-demo-go - https://github.com/bacco007/HomeAssistantConfig/blob/master/custom_components/xtend_tuya/multi_manager/tuya_iot/ipc/webrtc/xt_tuya_iot_webrtc_manager.py +- https://github.com/tuya/tuya-device-sharing-sdk +- https://github.com/make-all/tuya-local/blob/main/custom_components/tuya_local/cloud.py - https://ipc-us.ismartlife.me/ -- https://protect-us.ismartlife.me/ -- https://github.com/tuya/tuya-device-sharing-sdk \ No newline at end of file +- https://protect-us.ismartlife.me/ \ No newline at end of file diff --git a/pkg/tuya/api.go b/pkg/tuya/api.go deleted file mode 100644 index 3fb72c62..00000000 --- a/pkg/tuya/api.go +++ /dev/null @@ -1,507 +0,0 @@ -package tuya - -import ( - "bytes" - "crypto/md5" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "time" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/webrtc" - "github.com/google/uuid" - pionWebrtc "github.com/pion/webrtc/v4" -) - -type TuyaClient struct { - httpClient *http.Client - mqtt *TuyaMQTT - apiURL string - rtspURL string - hlsURL string - sessionId string - clientId string - clientSecret string - deviceId string - accessToken string - refreshToken string - expireTime int64 - uid string - motoId string - auth string - skill *Skill - iceServers []pionWebrtc.ICEServer -} - -type Token struct { - UID string `json:"uid"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpireTime int64 `json:"expire_time"` -} - -type AudioAttributes struct { - CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way - HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker -} - -type OpenApiICE struct { - Urls string `json:"urls"` - Username string `json:"username"` - Credential string `json:"credential"` - TTL int `json:"ttl"` -} - -type WebICE struct { - Urls string `json:"urls"` - Username string `json:"username,omitempty"` - Credential string `json:"credential,omitempty"` -} - -type P2PConfig struct { - Ices []OpenApiICE `json:"ices"` -} - -type AudioSkill struct { - Channels int `json:"channels"` - DataBit int `json:"dataBit"` - CodecType int `json:"codecType"` - SampleRate int `json:"sampleRate"` -} - -type VideoSkill struct { - StreamType int `json:"streamType"` // 2 = main stream (hd), 4 = sub stream (sd) - ProfileId string `json:"profileId,omitempty"` - CodecType int `json:"codecType"` // 2 = H264, 4 = H265 - Width int `json:"width"` - Height int `json:"height"` - SampleRate int `json:"sampleRate"` -} - -type Skill struct { - WebRTC int `json:"webrtc"` - Audios []AudioSkill `json:"audios"` - Videos []VideoSkill `json:"videos"` -} - -type WebRTConfig struct { - AudioAttributes AudioAttributes `json:"audio_attributes"` - Auth string `json:"auth"` - ID string `json:"id"` - MotoID string `json:"moto_id"` - P2PConfig P2PConfig `json:"p2p_config"` - ProtocolVersion string `json:"protocol_version"` - Skill string `json:"skill"` - SupportsWebRTCRecord bool `json:"supports_webrtc_record"` - SupportsWebRTC bool `json:"supports_webrtc"` - VedioClaritiy int `json:"vedio_clarity"` - VideoClaritiy int `json:"video_clarity"` - VideoClarities []int `json:"video_clarities"` -} - -type OpenIoTHubConfig struct { - Url string `json:"url"` - ClientID string `json:"client_id"` - Username string `json:"username"` - Password string `json:"password"` - SinkTopic struct { - IPC string `json:"ipc"` - } `json:"sink_topic"` - SourceSink struct { - IPC string `json:"ipc"` - } `json:"source_topic"` - ExpireTime int `json:"expire_time"` -} - -type WebRTCConfigResponse struct { - Success bool `json:"success"` - Result WebRTConfig `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` -} - -type TokenResponse struct { - Success bool `json:"success"` - Result Token `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` -} - -type AllocateRequest struct { - Type string `json:"type"` -} - -type AllocateResponse struct { - Success bool `json:"success"` - Result struct { - URL string `json:"url"` - } `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` -} - -type OpenIoTHubConfigRequest struct { - UID string `json:"uid"` - UniqueID string `json:"unique_id"` - LinkType string `json:"link_type"` - Topics string `json:"topics"` -} - -type OpenIoTHubConfigResponse struct { - Success bool `json:"success"` - Result OpenIoTHubConfig `json:"result"` - Msg string `json:"msg,omitempty"` - Code int `json:"code,omitempty"` -} - -func NewTuyaClient(openAPIURL string, deviceId string, uid string, clientId string, clientSecret string, streamMode string) (*TuyaClient, error) { - client := &TuyaClient{ - httpClient: &http.Client{Timeout: 5 * time.Second}, - mqtt: &TuyaMQTT{waiter: core.Waiter{}}, - apiURL: openAPIURL, - sessionId: core.RandString(6, 62), - clientId: clientId, - deviceId: deviceId, - clientSecret: clientSecret, - uid: uid, - } - - if err := client.InitToken(); err != nil { - return nil, fmt.Errorf("failed to initialize token: %w", err) - } - - if streamMode == "rtsp" { - if err := client.GetStreamUrl("rtsp"); err != nil { - return nil, fmt.Errorf("failed to get RTSP URL: %w", err) - } - } else if streamMode == "hls" { - if err := client.GetStreamUrl("hls"); err != nil { - return nil, fmt.Errorf("failed to get HLS URL: %w", err) - } - } else { - if err := client.InitDevice(); err != nil { - return nil, fmt.Errorf("failed to initialize device: %w", err) - } - - if err := client.StartMQTT(); err != nil { - return nil, fmt.Errorf("failed to start MQTT: %w", err) - } - } - - return client, nil -} - -func (c *TuyaClient) Close() { - c.StopMQTT() - c.httpClient.CloseIdleConnections() -} - -func (c *TuyaClient) InitToken() (err error) { - url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.apiURL) - - c.accessToken = "" - c.refreshToken = "" - - body, err := c.Request("GET", url, nil) - if err != nil { - return err - } - - var tokenResponse TokenResponse - err = json.Unmarshal(body, &tokenResponse) - if err != nil { - return err - } - - if !tokenResponse.Success { - return fmt.Errorf(tokenResponse.Msg) - } - - c.accessToken = tokenResponse.Result.AccessToken - c.refreshToken = tokenResponse.Result.RefreshToken - c.expireTime = tokenResponse.Result.ExpireTime - - return nil -} - -func (c *TuyaClient) InitDevice() (err error) { - url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.apiURL, c.uid, c.deviceId) - - body, err := c.Request("GET", url, nil) - if err != nil { - return err - } - - var webRTCConfigResponse WebRTCConfigResponse - err = json.Unmarshal(body, &webRTCConfigResponse) - if err != nil { - return err - } - - if !webRTCConfigResponse.Success { - return fmt.Errorf(webRTCConfigResponse.Msg) - } - - err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) - if err != nil { - return err - } - - iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) - if err != nil { - return err - } - - c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) - if err != nil { - return err - } - - c.motoId = webRTCConfigResponse.Result.MotoID - c.auth = webRTCConfigResponse.Result.Auth - - return nil -} - -func (c *TuyaClient) GetStreamUrl(streamType string) (err error) { - url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.apiURL, c.deviceId) - - request := &AllocateRequest{ - Type: streamType, - } - - body, err := c.Request("POST", url, request) - if err != nil { - return err - } - - var allosResponse AllocateResponse - err = json.Unmarshal(body, &allosResponse) - if err != nil { - return err - } - - if !allosResponse.Success { - return fmt.Errorf(allosResponse.Msg) - } - - switch streamType { - case "rtsp": - c.rtspURL = allosResponse.Result.URL - case "hls": - c.hlsURL = allosResponse.Result.URL - default: - return fmt.Errorf("unsupported stream type: %s", streamType) - } - - return nil -} - -func (c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { - url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.apiURL) - - request := &OpenIoTHubConfigRequest{ - UID: c.uid, - UniqueID: uuid.New().String(), - LinkType: "mqtt", - Topics: "ipc", - } - - body, err := c.Request("POST", url, request) - if err != nil { - return nil, err - } - - var openIoTHubConfigResponse OpenIoTHubConfigResponse - err = json.Unmarshal(body, &openIoTHubConfigResponse) - if err != nil { - return nil, err - } - - if !openIoTHubConfigResponse.Success { - return nil, fmt.Errorf(openIoTHubConfigResponse.Msg) - } - - return &openIoTHubConfigResponse.Result, nil -} - -func (c *TuyaClient) Request(method string, url string, body any) ([]byte, error) { - var bodyReader io.Reader - if body != nil { - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(jsonBody) - } - - req, err := http.NewRequest(method, url, bodyReader) - if err != nil { - return nil, err - } - - ts := time.Now().UnixNano() / 1000000 - sign := c.calBusinessSign(ts) - - req.Header.Set("Accept", "*") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Access-Control-Allow-Origin", "*") - req.Header.Set("Access-Control-Allow-Methods", "*") - req.Header.Set("Access-Control-Allow-Headers", "*") - req.Header.Set("mode", "no-cors") - req.Header.Set("client_id", c.clientId) - req.Header.Set("access_token", c.accessToken) - req.Header.Set("sign", sign) - req.Header.Set("t", strconv.FormatInt(ts, 10)) - - response, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer response.Body.Close() - - res, err := io.ReadAll(response.Body) - if err != nil { - return nil, err - } - - if response.StatusCode != http.StatusOK { - return nil, err - } - - return res, nil -} - -func (c *TuyaClient) getVideoCodecs() []*core.Codec { - if len(c.skill.Videos) > 0 { - codecs := make([]*core.Codec, 0) - - for _, video := range c.skill.Videos { - name := core.CodecH264 - if c.isHEVC(video.StreamType) { - name = core.CodecH265 - } - - codec := &core.Codec{ - Name: name, - ClockRate: uint32(video.SampleRate), - } - - codecs = append(codecs, codec) - } - - if len(codecs) > 0 { - return codecs - } - } - - return nil -} - -func (c *TuyaClient) getAudioCodecs() []*core.Codec { - if len(c.skill.Audios) > 0 { - codecs := make([]*core.Codec, 0) - - for _, audio := range c.skill.Audios { - name := getAudioCodecName(&audio) - - codec := &core.Codec{ - Name: name, - ClockRate: uint32(audio.SampleRate), - Channels: uint8(audio.Channels), - } - codecs = append(codecs, codec) - } - - if len(codecs) > 0 { - return codecs - } - } - - return nil -} - -// https://protect-us.ismartlife.me/ -func getAudioCodecName(audioSkill *AudioSkill) string { - switch audioSkill.CodecType { - // case 100: - // return "ADPCM" - case 101: - return core.CodecPCML - case 102, 103, 104: - return core.CodecAAC - case 105: - return core.CodecPCMU - case 106: - return core.CodecPCMA - // case 107: - // return "G726-32" - // case 108: - // return "SPEEX" - case 109: - return core.CodecMP3 - default: - return core.CodecPCML - } -} - -func (c *TuyaClient) getStreamType(streamResolution string) int { - // Default streamType if nothing is found - defaultStreamType := 1 - - if c.skill == nil || len(c.skill.Videos) == 0 { - return defaultStreamType - } - - // Find the highest and lowest resolution - var highestResType = defaultStreamType - var highestRes = 0 - var lowestResType = defaultStreamType - var lowestRes = 0 - - for _, video := range c.skill.Videos { - res := video.Width * video.Height - - // Highest Resolution - if res > highestRes { - highestRes = res - highestResType = video.StreamType - } - - // Lower Resolution (or first if not set yet) - if lowestRes == 0 || res < lowestRes { - lowestRes = res - lowestResType = video.StreamType - } - } - - // Return the streamType based on the selection - switch streamResolution { - case "hd": - return highestResType - case "sd": - return lowestResType - default: - return defaultStreamType - } -} - -func (c *TuyaClient) isHEVC(streamType int) bool { - for _, video := range c.skill.Videos { - if video.StreamType == streamType { - return video.CodecType == 4 - } - } - - return false -} - -func (c *TuyaClient) calBusinessSign(ts int64) string { - data := fmt.Sprintf("%s%s%s%d", c.clientId, c.accessToken, c.clientSecret, ts) - val := md5.Sum([]byte(data)) - res := fmt.Sprintf("%X", val) - return res -} diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 5a865690..4a43c49a 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -15,16 +15,17 @@ import ( ) type Client struct { - api *TuyaClient - conn *webrtc.Conn - pc *pion.PeerConnection - dc *pion.DataChannel - videoSSRC uint32 - audioSSRC uint32 - isHEVC bool - connected core.Waiter - closed bool - handlers map[uint32]func(*rtp.Packet) + api TuyaAPI + conn *webrtc.Conn + pc *pion.PeerConnection + dc *pion.DataChannel + videoSSRC uint32 + audioSSRC uint32 + streamType int + isHEVC bool + connected core.Waiter + closed bool + handlers map[uint32]func(*rtp.Packet) } type DataChannelMessage struct { @@ -41,15 +42,6 @@ type RecvMessage struct { } `json:"audio"` } -const ( - DefaultCnURL = "openapi.tuyacn.com" - DefaultWestUsURL = "openapi.tuyaus.com" - DefaultEastUsURL = "openapi-ueaz.tuyaus.com" - DefaultCentralEuURL = "openapi.tuyaeu.com" - DefaultWestEuURL = "openapi-weaz.tuyaeu.com" - DefaultInURL = "openapi.tuyain.com" -) - func Dial(rawURL string) (core.Producer, error) { u, err := url.Parse(rawURL) if err != nil { @@ -57,274 +49,292 @@ func Dial(rawURL string) (core.Producer, error) { } query := u.Query() - deviceID := query.Get("device_id") - uid := query.Get("uid") + + // Open API + tokenInfo := query.Get("token") + terminalId := query.Get("terminal_id") + + // Cloud API clientId := query.Get("client_id") clientSecret := query.Get("client_secret") + + // Shared params + deviceId := query.Get("device_id") + uid := query.Get("uid") + + // Stream params streamResolution := query.Get("resolution") streamMode := query.Get("mode") + useOpenApi := deviceId != "" && uid != "" && tokenInfo != "" && terminalId != "" + useCloudApi := deviceId != "" && ((streamMode == "webrtc" || streamMode == "") && uid != "") && clientId != "" && clientSecret != "" + if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { streamResolution = "hd" } - useRTSP := streamMode == "rtsp" - useHLS := streamMode == "hls" - useWebRTC := streamMode == "webrtc" || streamMode == "" - - // check if host is correct - switch u.Hostname() { - case DefaultCnURL: - case DefaultWestUsURL: - case DefaultEastUsURL: - case DefaultCentralEuURL: - case DefaultWestEuURL: - case DefaultInURL: - default: - return nil, fmt.Errorf("tuya: wrong host %s", u.Hostname()) + if streamMode == "" || (streamMode != "rtsp" && streamMode != "hls" && streamMode != "flv" && streamMode != "rtmp" && streamMode != "webrtc") { + if useOpenApi { + streamMode = "rtsp" + } else { + streamMode = "webrtc" + } } - if deviceID == "" || clientId == "" || clientSecret == "" { - return nil, errors.New("tuya: no device_id, client_id or client_secret") - } - - if useWebRTC && uid == "" { - return nil, errors.New("tuya: no uid") - } - - if !useRTSP && !useHLS && !useWebRTC { - return nil, errors.New("tuya: wrong stream type") - } - - // Initialize Tuya API client - tuyaAPI, err := NewTuyaClient(u.Hostname(), deviceID, uid, clientId, clientSecret, streamMode) - if err != nil { - return nil, fmt.Errorf("tuya: %w", err) + if !useOpenApi && !useCloudApi { + return nil, errors.New("tuya: wrong query params") } client := &Client{ - api: tuyaAPI, handlers: make(map[uint32]func(*rtp.Packet)), } - if useRTSP { - if client.api.rtspURL == "" { - return nil, errors.New("tuya: no rtsp url") + if useOpenApi { + if client.api, err = NewTuyaOpenApiClient(u.Hostname(), uid, deviceId, terminalId, tokenInfo, streamMode); err != nil { + return nil, fmt.Errorf("tuya: %w", err) } - return streams.GetProducer(client.api.rtspURL) - } else if useHLS { - if client.api.hlsURL == "" { - return nil, errors.New("tuya: no hls url") - } - return streams.GetProducer(client.api.hlsURL) } else { - client.isHEVC = client.api.isHEVC(client.api.getStreamType(streamResolution)) - - // Create a new PeerConnection - conf := pion.Configuration{ - ICEServers: client.api.iceServers, - ICETransportPolicy: pion.ICETransportPolicyAll, - BundlePolicy: pion.BundlePolicyMaxBundle, + if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret, streamMode); err != nil { + return nil, fmt.Errorf("tuya: %w", err) } + } - api, err := webrtc.NewAPI() + if streamMode != "webrtc" { + streamUrl, err := client.api.GetStreamUrl(streamMode) if err != nil { - client.Stop() - return nil, err + return nil, fmt.Errorf("tuya: %w", err) } - client.pc, err = api.NewPeerConnection(conf) - if err != nil { - client.Stop() - return nil, err + return streams.GetProducer(streamUrl) + } + + if err := client.api.Init(); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + + client.streamType = client.api.GetStreamType(streamResolution) + client.isHEVC = client.api.IsHEVC(client.streamType) + + // Create a new PeerConnection + conf := pion.Configuration{ + ICEServers: client.api.GetICEServers(), + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyMaxBundle, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.Close(err) + return nil, err + } + + client.pc, err = api.NewPeerConnection(conf) + if err != nil { + client.Close(err) + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // Create new WebRTC connection + client.conn = webrtc.NewConn(client.pc) + client.conn.FormatName = "tuya/webrtc" + client.conn.Mode = core.ModeActiveProducer + client.conn.Protocol = "mqtt" + + mqttClient := client.api.GetMqtt() + if mqttClient == nil { + err = errors.New("tuya: no mqtt client") + client.Close(err) + return nil, err + } + + // Set up MQTT handlers + mqttClient.handleAnswer = func(answer AnswerFrame) { + // fmt.Printf("tuya: answer: %s\n", answer.Sdp) + + desc := pion.SessionDescription{ + Type: pion.SDPTypePranswer, + SDP: answer.Sdp, } - // protect from sending ICE candidate before Offer - var sendOffer core.Waiter - - // protect from blocking on errors - defer sendOffer.Done(nil) - - // Create new WebRTC connection - client.conn = webrtc.NewConn(client.pc) - client.conn.FormatName = "tuya/webrtc" - client.conn.Mode = core.ModeActiveProducer - client.conn.Protocol = "mqtt" - - // Set up MQTT handlers - client.api.mqtt.handleAnswer = func(answer AnswerFrame) { - // fmt.Printf("tuya: answer: %s\n", answer.Sdp) - - desc := pion.SessionDescription{ - Type: pion.SDPTypePranswer, - SDP: answer.Sdp, - } - - if err = client.pc.SetRemoteDescription(desc); err != nil { - client.connected.Done(err) - return - } - - if err = client.conn.SetAnswer(answer.Sdp); err != nil { - client.connected.Done(err) - return - } - - if client.isHEVC { - // Tuya seems to answers always with H264 and PCMU/8000 and PCMA/8000 codecs, replace with real codecs - - for _, media := range client.conn.Medias { - if media.Kind == core.KindVideo { - codecs := client.api.getVideoCodecs() - if codecs != nil { - media.Codecs = codecs - } - } - } - - for _, media := range client.conn.Medias { - if media.Kind == core.KindAudio { - codecs := client.api.getAudioCodecs() - if codecs != nil { - media.Codecs = codecs - } - } - } - } + if err = client.pc.SetRemoteDescription(desc); err != nil { + client.Close(err) + return } - client.api.mqtt.handleCandidate = func(candidate CandidateFrame) { - // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) - - if candidate.Candidate != "" { - client.conn.AddCandidate(candidate.Candidate) - if err != nil { - client.Stop() - } - } + if err = client.conn.SetAnswer(answer.Sdp); err != nil { + client.Close(err) + return } - client.api.mqtt.handleDisconnect = func() { - // fmt.Println("tuya: disconnect") - client.Stop() - } - - client.api.mqtt.handleError = func(err error) { - // fmt.Printf("tuya: error: %s\n", err.Error()) - client.Stop() - } - - // On HEVC, use DataChannel to receive video/audio if client.isHEVC { - // Create a new DataChannel - maxRetransmits := uint16(5) - ordered := true - client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ - MaxRetransmits: &maxRetransmits, - Ordered: &ordered, - }) + // Tuya seems to answers always with H264 and PCMU/8000 and PCMA/8000 codecs, replace with real codecs - // Set up data channel handler - client.dc.OnMessage(func(msg pion.DataChannelMessage) { - if msg.IsString { - client.probe(msg) - } else { - packet := &rtp.Packet{} - if err := packet.Unmarshal(msg.Data); err != nil { - // skip - return - } - - if handler, ok := client.handlers[packet.SSRC]; ok { - handler(packet) + for _, media := range client.conn.Medias { + if media.Kind == core.KindVideo { + codecs := client.api.GetVideoCodecs() + if codecs != nil { + media.Codecs = codecs } } - }) + } - client.dc.OnError(func(err error) { - // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) - client.connected.Done(err) - }) - - client.dc.OnClose(func() { - // fmt.Println("tuya: datachannel closed") - client.connected.Done(errors.New("datachannel: closed")) - }) - - client.dc.OnOpen(func() { - // fmt.Println("tuya: datachannel opened") - - codecRequest, _ := json.Marshal(DataChannelMessage{ - Type: "codec", - Msg: "", - }) - - if err := client.sendMessageToDataChannel(codecRequest); err != nil { - client.connected.Done(fmt.Errorf("failed to send codec request: %w", err)) + for _, media := range client.conn.Medias { + if media.Kind == core.KindAudio { + codecs := client.api.GetAudioCodecs() + if codecs != nil { + media.Codecs = codecs + } } - }) + } } + } - // Set up pc handler - client.conn.Listen(func(msg any) { - switch msg := msg.(type) { - case *pion.ICECandidate: - _ = sendOffer.Wait() - if err := client.api.sendCandidate("a=" + msg.ToJSON().Candidate); err != nil { - client.connected.Done(err) + mqttClient.handleCandidate = func(candidate CandidateFrame) { + // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) + + if candidate.Candidate != "" { + client.conn.AddCandidate(candidate.Candidate) + if err != nil { + client.Close(err) + } + } + } + + mqttClient.handleDisconnect = func() { + // fmt.Println("tuya: disconnect") + client.Close(errors.New("mqtt: disconnect")) + } + + mqttClient.handleError = func(err error) { + // fmt.Printf("tuya: error: %s\n", err.Error()) + client.Close(err) + } + + // On HEVC, use DataChannel to receive video/audio + if client.isHEVC { + // Create a new DataChannel + maxRetransmits := uint16(5) + ordered := true + client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ + MaxRetransmits: &maxRetransmits, + Ordered: &ordered, + }) + + // Set up data channel handler + client.dc.OnMessage(func(msg pion.DataChannelMessage) { + if msg.IsString { + if connected, err := client.probe(msg); err != nil { + client.Close(err) + } else if connected { + client.connected.Done(nil) + } + } else { + packet := &rtp.Packet{} + if err := packet.Unmarshal(msg.Data); err != nil { + // skip + return } - case pion.PeerConnectionState: - switch msg { - case pion.PeerConnectionStateNew: - break - case pion.PeerConnectionStateConnecting: - break - case pion.PeerConnectionStateConnected: - // On HEVC, wait for DataChannel to be opened and camera to send codec info - if !client.isHEVC { - client.connected.Done(nil) - } - default: - client.Stop() - client.connected.Done(errors.New("webrtc: " + msg.String())) + if handler, ok := client.handlers[packet.SSRC]; ok { + handler(packet) } } }) - // Audio first, otherwise tuya will send corrupt sdp - medias := []*core.Media{ - {Kind: core.KindAudio, Direction: core.DirectionSendRecv}, - {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, - } + client.dc.OnError(func(err error) { + // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) + client.Close(err) + }) - // Create offer - offer, err := client.conn.CreateOffer(medias) - if err != nil { - client.Stop() - return nil, err - } + client.dc.OnClose(func() { + // fmt.Println("tuya: datachannel closed") + client.Close(errors.New("datachannel: closed")) + }) - // horter sdp, remove a=extmap... line, device ONLY allow 8KB json payload - // https://github.com/tuya/webrtc-demo-go/blob/04575054f18ccccb6bc9d82939dd46d449544e20/static/js/main.js#L224 - re := regexp.MustCompile(`\r\na=extmap[^\r\n]*`) - offer = re.ReplaceAllString(offer, "") + client.dc.OnOpen(func() { + // fmt.Println("tuya: datachannel opened") - // Send offer - if err := client.api.sendOffer(offer, streamResolution); err != nil { - client.Stop() - return nil, fmt.Errorf("tuya: %w", err) - } + codecRequest, _ := json.Marshal(DataChannelMessage{ + Type: "codec", + Msg: "", + }) - sendOffer.Done(nil) - - // Wait for connection - if err = client.connected.Wait(); err != nil { - return nil, fmt.Errorf("tuya: %w", err) - } - - return client, nil + if err := client.sendMessageToDataChannel(codecRequest); err != nil { + client.Close(fmt.Errorf("failed to send codec request: %w", err)) + } + }) } + + // Set up pc handler + client.conn.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + if err := mqttClient.SendCandidate("a=" + msg.ToJSON().Candidate); err != nil { + client.Close(err) + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateNew: + break + case pion.PeerConnectionStateConnecting: + break + case pion.PeerConnectionStateConnected: + // On HEVC, wait for DataChannel to be opened and camera to send codec info + if !client.isHEVC { + if streamResolution == "hd" { + _ = mqttClient.SendResolution(0) + } + client.connected.Done(nil) + } + default: + client.Close(errors.New("webrtc: " + msg.String())) + } + } + }) + + // Audio first, otherwise tuya will send corrupt sdp + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionSendRecv}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + } + + // Create offer + offer, err := client.conn.CreateOffer(medias) + if err != nil { + client.Close(err) + return nil, err + } + + // horter sdp, remove a=extmap... line, device ONLY allow 8KB json payload + // https://github.com/tuya/webrtc-demo-go/blob/04575054f18ccccb6bc9d82939dd46d449544e20/static/js/main.js#L224 + re := regexp.MustCompile(`\r\na=extmap[^\r\n]*`) + offer = re.ReplaceAllString(offer, "") + + // Send offer + if err := mqttClient.SendOffer(offer, streamResolution, client.streamType, client.isHEVC); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + sendOffer.Done(nil) + + // Wait for connection + if err = client.connected.Wait(); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + return client, nil } func (c *Client) GetMedias() []*core.Media { @@ -343,7 +353,10 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece return errors.New("webrtc: can't get track") } - _ = c.api.sendSpeaker(1) + mqttClient := c.api.GetMqtt() + if mqttClient != nil { + _ = mqttClient.SendSpeaker(1) + } payloadType := codec.PayloadType @@ -411,22 +424,25 @@ func (c *Client) Stop() error { return nil } +func (c *Client) Close(err error) error { + c.connected.Done(err) + return c.Stop() +} + func (c *Client) MarshalJSON() ([]byte, error) { return c.conn.MarshalJSON() } -func (c *Client) probe(msg pion.DataChannelMessage) { +func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { // fmt.Printf("[tuya] Received string message: %s\n", string(msg.Data)) var message DataChannelMessage if err := json.Unmarshal([]byte(msg.Data), &message); err != nil { - c.connected.Done(fmt.Errorf("failed to parse datachannel message: %w", err)) + return false, err } switch message.Type { case "codec": - // fmt.Printf("[tuya] Codec info from camera: %s\n", message.Msg) - frameRequest, _ := json.Marshal(DataChannelMessage{ Type: "start", Msg: "frame", @@ -434,14 +450,13 @@ func (c *Client) probe(msg pion.DataChannelMessage) { err := c.sendMessageToDataChannel(frameRequest) if err != nil { - c.connected.Done(fmt.Errorf("failed to send frame request: %w", err)) + return false, err } case "recv": var recvMessage RecvMessage if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil { - c.connected.Done(fmt.Errorf("failed to parse recv message: %w", err)) - return + return false, err } c.videoSSRC = recvMessage.Video.SSRC @@ -454,11 +469,13 @@ func (c *Client) probe(msg pion.DataChannelMessage) { err := c.sendMessageToDataChannel(completeMsg) if err != nil { - c.connected.Done(fmt.Errorf("failed to send complete message: %w", err)) + return false, err } - c.connected.Done(nil) + return true, nil } + + return false, nil } func (c *Client) sendMessageToDataChannel(message []byte) error { diff --git a/pkg/tuya/cloud_api.go b/pkg/tuya/cloud_api.go new file mode 100644 index 00000000..bd74daf0 --- /dev/null +++ b/pkg/tuya/cloud_api.go @@ -0,0 +1,312 @@ +package tuya + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" +) + +type Token struct { + UID string `json:"uid"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` +} + +type WebRTCConfigResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result WebRTCConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TokenResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result Token `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type OpenIoTHubConfigRequest struct { + UID string `json:"uid"` + UniqueID string `json:"unique_id"` + LinkType string `json:"link_type"` + Topics string `json:"topics"` +} + +type OpenIoTHubConfig struct { + Url string `json:"url"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` + SinkTopic struct { + IPC string `json:"ipc"` + } `json:"sink_topic"` + SourceSink struct { + IPC string `json:"ipc"` + } `json:"source_topic"` + ExpireTime int `json:"expire_time"` +} + +type OpenIoTHubConfigResponse struct { + Timestamp int `json:"t"` + Success bool `json:"success"` + Result OpenIoTHubConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TuyaCloudApiClient struct { + TuyaClient + clientId string + clientSecret string + refreshingToken bool +} + +func NewTuyaCloudApiClient(baseUrl string, uid string, deviceId string, clientId string, clientSecret string, streamMode string) (*TuyaCloudApiClient, error) { + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaCloudApiClient{ + TuyaClient: TuyaClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + mqtt: mqttClient, + uid: uid, + deviceId: deviceId, + streamMode: streamMode, + expireTime: 0, + baseUrl: baseUrl, + }, + clientId: clientId, + clientSecret: clientSecret, + refreshingToken: false, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaCloudApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + return nil +} + +func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + if err := c.initToken(); err != nil { + return "", fmt.Errorf("failed to initialize token: %w", err) + } + + url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.baseUrl, c.deviceId) + + request := &AllocateRequest{ + Type: streamType, + } + + body, err := c.request("POST", url, request) + if err != nil { + return "", err + } + + var allocResponse AllocateResponse + err = json.Unmarshal(body, &allocResponse) + if err != nil { + return "", err + } + + if !allocResponse.Success { + return "", fmt.Errorf(allocResponse.Msg) + } + + return allocResponse.Result.URL, nil +} + +func (c *TuyaCloudApiClient) initToken() (err error) { + if c.refreshingToken { + return nil + } + + now := time.Now().Unix() + if (c.expireTime - 60) > now { + return nil + } + + c.refreshingToken = true + + url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.baseUrl) + + c.accessToken = "" + c.refreshToken = "" + + body, err := c.request("GET", url, nil) + if err != nil { + return err + } + + var tokenResponse TokenResponse + err = json.Unmarshal(body, &tokenResponse) + if err != nil { + return err + } + + if !tokenResponse.Success { + return fmt.Errorf(tokenResponse.Msg) + } + + c.accessToken = tokenResponse.Result.AccessToken + c.refreshToken = tokenResponse.Result.RefreshToken + c.expireTime = tokenResponse.Timestamp + tokenResponse.Result.ExpireTime + c.refreshingToken = false + + return nil +} + +func (c *TuyaCloudApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.baseUrl, c.uid, c.deviceId) + + body, err := c.request("GET", url, nil) + if err != nil { + return nil, err + } + + var webRTCConfigResponse WebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, fmt.Errorf(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &webRTCConfigResponse.Result, nil +} + +func (c *TuyaCloudApiClient) loadHubConfig() (config *MQTTConfig, err error) { + url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.baseUrl) + + request := &OpenIoTHubConfigRequest{ + UID: c.uid, + UniqueID: uuid.New().String(), + LinkType: "mqtt", + Topics: "ipc", + } + + body, err := c.request("POST", url, request) + if err != nil { + return nil, err + } + + var openIoTHubConfigResponse OpenIoTHubConfigResponse + err = json.Unmarshal(body, &openIoTHubConfigResponse) + if err != nil { + return nil, err + } + + if !openIoTHubConfigResponse.Success { + return nil, fmt.Errorf(openIoTHubConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: openIoTHubConfigResponse.Result.Url, + Username: openIoTHubConfigResponse.Result.Username, + Password: openIoTHubConfigResponse.Result.Password, + ClientID: openIoTHubConfigResponse.Result.ClientID, + PublishTopic: openIoTHubConfigResponse.Result.SinkTopic.IPC, + SubscribeTopic: openIoTHubConfigResponse.Result.SourceSink.IPC, + }, nil +} + +func (c *TuyaCloudApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + ts := time.Now().UnixNano() / 1000000 + sign := c.calBusinessSign(ts) + + req.Header.Set("Accept", "*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Access-Control-Allow-Origin", "*") + req.Header.Set("Access-Control-Allow-Methods", "*") + req.Header.Set("Access-Control-Allow-Headers", "*") + req.Header.Set("mode", "no-cors") + req.Header.Set("client_id", c.clientId) + req.Header.Set("access_token", c.accessToken) + req.Header.Set("sign", sign) + req.Header.Set("t", strconv.FormatInt(ts, 10)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} + +func (c *TuyaCloudApiClient) calBusinessSign(ts int64) string { + data := fmt.Sprintf("%s%s%s%d", c.clientId, c.accessToken, c.clientSecret, ts) + val := md5.Sum([]byte(data)) + res := fmt.Sprintf("%X", val) + return res +} diff --git a/pkg/tuya/crypto.go b/pkg/tuya/crypto.go new file mode 100644 index 00000000..b8f84615 --- /dev/null +++ b/pkg/tuya/crypto.go @@ -0,0 +1,134 @@ +package tuya + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "math/rand" +) + +// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py +func AesGCMEncrypt(rawData string, secret string) (string, error) { + nonce := []byte(RandomNonce(12)) + + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + ciphertext := aesgcm.Seal(nil, nonce, []byte(rawData), nil) + nonceB64 := base64.StdEncoding.EncodeToString(nonce) + ciphertextB64 := base64.StdEncoding.EncodeToString(ciphertext) + + return nonceB64 + ciphertextB64, nil +} + +func AesGCMDecrypt(cipherData string, secret string) (string, error) { + if len(cipherData) <= 16 { + return "", fmt.Errorf("invalid ciphertext length") + } + + nonceB64 := cipherData[:16] + ciphertextB64 := cipherData[16:] + + nonce, err := base64.StdEncoding.DecodeString(nonceB64) + if err != nil { + return "", err + } + + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) + if err != nil { + return "", err + } + + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +func SecretGenerating(rid, sid, hashKey string) string { + message := hashKey + mod := 16 + + if sid != "" { + sidLength := len(sid) + length := sidLength + if length > mod { + length = mod + } + + ecode := "" + for i := 0; i < length; i++ { + idx := int(sid[i]) % mod + ecode += string(sid[idx]) + } + message += "_" + message += ecode + } + + h := hmac.New(sha256.New, []byte(rid)) + h.Write([]byte(message)) + byteTemp := h.Sum(nil) + secret := hex.EncodeToString(byteTemp) + + return secret[:16] +} + +func RestfulSign(hashKey, queryEncdata, bodyEncdata string, data map[string]string) string { + headers := []string{"X-appKey", "X-requestId", "X-sid", "X-time", "X-token"} + headerSignStr := "" + + for _, item := range headers { + val, exists := data[item] + if exists && val != "" { + headerSignStr += item + "=" + val + "||" + } + } + + signStr := "" + if len(headerSignStr) > 2 { + signStr = headerSignStr[:len(headerSignStr)-2] + } + + if queryEncdata != "" { + signStr += queryEncdata + } + if bodyEncdata != "" { + signStr += bodyEncdata + } + + h := hmac.New(sha256.New, []byte(hashKey)) + h.Write([]byte(signStr)) + return hex.EncodeToString(h.Sum(nil)) +} + +func RandomNonce(length int) string { + const charset = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678" + result := make([]byte, length) + for i := range result { + result[i] = charset[rand.Intn(len(charset))] + } + return string(result) +} diff --git a/pkg/tuya/helper.go b/pkg/tuya/helper.go new file mode 100644 index 00000000..0b97b256 --- /dev/null +++ b/pkg/tuya/helper.go @@ -0,0 +1,72 @@ +package tuya + +import ( + "encoding/base64" + "encoding/json" + "fmt" +) + +func FormToJSON(content any) string { + if content == nil { + return "{}" + } + + jsonBytes, err := json.Marshal(content) + if err != nil { + return "{}" + } + + return string(jsonBytes) +} + +func ToBase64(tokenInfo *TokenInfo) (string, error) { + jsonData, err := json.Marshal(tokenInfo) + if err != nil { + return "", fmt.Errorf("error marshalling token: %v", err) + } + + encoded := base64.URLEncoding.EncodeToString(jsonData) + + return encoded, nil +} + +func FromBase64(encodedTokenInfo string) (*TokenInfo, error) { + jsonData, err := base64.URLEncoding.DecodeString(encodedTokenInfo) + if err != nil { + return nil, fmt.Errorf("error decoding token: %v", err) + } + + var tokenInfo TokenInfo + err = json.Unmarshal(jsonData, &tokenInfo) + if err != nil { + return nil, fmt.Errorf("error unmarshalling token: %v", err) + } + + return &tokenInfo, nil +} + +func ParseTokenInfo(tokenInfoOrString any) (*TokenInfo, error) { + var tokenInfo *TokenInfo + var err error + + switch v := tokenInfoOrString.(type) { + case string: + tokenInfo, err = FromBase64(v) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 token: %w", err) + } + case *TokenInfo: + tokenInfo = v + case TokenInfo: + copyOfV := v + tokenInfo = ©OfV + default: + return nil, fmt.Errorf("invalid type: %T", v) + } + + if tokenInfo == nil { + return nil, fmt.Errorf("token info is nil") + } + + return tokenInfo, nil +} diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go new file mode 100644 index 00000000..dae19a82 --- /dev/null +++ b/pkg/tuya/interface.go @@ -0,0 +1,259 @@ +package tuya + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/core" + pionWebrtc "github.com/pion/webrtc/v4" +) + +type TuyaAPI interface { + GetMqtt() *TuyaMqttClient + + GetStreamType(streamResolution string) int + IsHEVC(streamType int) bool + + GetVideoCodecs() []*core.Codec + GetAudioCodecs() []*core.Codec + + GetStreamUrl(streamUrl string) (string, error) + GetICEServers() []pionWebrtc.ICEServer + + Init() error + Close() +} + +type TuyaClient struct { + TuyaAPI + + httpClient *http.Client + mqtt *TuyaMqttClient + streamMode string + baseUrl string + accessToken string + refreshToken string + expireTime int64 + deviceId string + uid string + skill *Skill + iceServers []pionWebrtc.ICEServer +} + +type AudioAttributes struct { + CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way + HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker +} + +type OpenApiICE struct { + Urls string `json:"urls"` + Username string `json:"username"` + Credential string `json:"credential"` + TTL int `json:"ttl"` +} + +type WebICE struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +type P2PConfig struct { + Ices []OpenApiICE `json:"ices"` +} + +type AudioSkill struct { + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` +} + +type VideoSkill struct { + StreamType int `json:"streamType"` // 2 = main stream (hd), 4 = sub stream (sd) + ProfileId string `json:"profileId,omitempty"` + CodecType int `json:"codecType"` // 2 = H264, 4 = H265 + Width int `json:"width"` + Height int `json:"height"` + SampleRate int `json:"sampleRate"` +} + +type Skill struct { + WebRTC int `json:"webrtc"` + Audios []AudioSkill `json:"audios"` + Videos []VideoSkill `json:"videos"` +} + +type WebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audio_attributes"` + Auth string `json:"auth"` + ID string `json:"id"` + MotoID string `json:"moto_id"` + P2PConfig P2PConfig `json:"p2p_config"` + ProtocolVersion string `json:"protocol_version"` + Skill string `json:"skill"` + SupportsWebRTCRecord bool `json:"supports_webrtc_record"` + SupportsWebRTC bool `json:"supports_webrtc"` + VedioClaritiy int `json:"vedio_clarity"` + VideoClaritiy int `json:"video_clarity"` + VideoClarities []int `json:"video_clarities"` +} + +type MQTTConfig struct { + Url string `json:"url"` + PublishTopic string `json:"publish_topic"` + SubscribeTopic string `json:"subscribe_topic"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` +} + +type Allocate struct { + URL string `json:"url"` +} + +type AllocateRequest struct { + Type string `json:"type"` +} + +type AllocateResponse struct { + Success bool `json:"success"` + Result Allocate `json:"result"` + Msg string `json:"msg,omitempty"` +} + +func (c *TuyaClient) GetICEServers() []pionWebrtc.ICEServer { + return c.iceServers +} + +func (c *TuyaClient) GetMqtt() *TuyaMqttClient { + return c.mqtt +} + +func (c *TuyaClient) GetStreamType(streamResolution string) int { + // Default streamType if nothing is found + defaultStreamType := 1 + + if c.skill == nil || len(c.skill.Videos) == 0 { + return defaultStreamType + } + + // Find the highest and lowest resolution + var highestResType = defaultStreamType + var highestRes = 0 + var lowestResType = defaultStreamType + var lowestRes = 0 + + for _, video := range c.skill.Videos { + res := video.Width * video.Height + + // Highest Resolution + if res > highestRes { + highestRes = res + highestResType = video.StreamType + } + + // Lower Resolution (or first if not set yet) + if lowestRes == 0 || res < lowestRes { + lowestRes = res + lowestResType = video.StreamType + } + } + + // Return the streamType based on the selection + switch streamResolution { + case "hd": + return highestResType + case "sd": + return lowestResType + default: + return defaultStreamType + } +} + +func (c *TuyaClient) IsHEVC(streamType int) bool { + for _, video := range c.skill.Videos { + if video.StreamType == streamType { + return video.CodecType == 4 + } + } + + return false +} + +func (c *TuyaClient) GetVideoCodecs() []*core.Codec { + if len(c.skill.Videos) > 0 { + codecs := make([]*core.Codec, 0) + + for _, video := range c.skill.Videos { + name := core.CodecH264 + if c.IsHEVC(video.StreamType) { + name = core.CodecH265 + } + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(video.SampleRate), + } + + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) GetAudioCodecs() []*core.Codec { + if len(c.skill.Audios) > 0 { + codecs := make([]*core.Codec, 0) + + for _, audio := range c.skill.Audios { + name := getAudioCodecName(&audio) + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + } + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) Close() { + c.mqtt.Stop() + c.httpClient.CloseIdleConnections() +} + +// https://protect-us.ismartlife.me/ +func getAudioCodecName(audioSkill *AudioSkill) string { + switch audioSkill.CodecType { + // case 100: + // return "ADPCM" + case 101: + return core.CodecPCML + case 102, 103, 104: + return core.CodecAAC + case 105: + return core.CodecPCMU + case 106: + return core.CodecPCMA + // case 107: + // return "G726-32" + // case 108: + // return "SPEEX" + case 109: + return core.CodecMP3 + default: + return core.CodecPCML + } +} diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 6fd3d4a0..deb90b9e 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -10,13 +10,18 @@ import ( mqtt "github.com/eclipse/paho.mqtt.golang" ) -type TuyaMQTT struct { +type TuyaMqttClient struct { client mqtt.Client waiter core.Waiter publishTopic string subscribeTopic string + auth string uid string + motoId string + deviceId string + sessionId string closed bool + webrtcVersion int handleAnswer func(answer AnswerFrame) handleCandidate func(candidate CandidateFrame) handleDisconnect func() @@ -56,14 +61,14 @@ type CandidateFrame struct { Candidate string `json:"candidate"` } -// type ResolutionFrame struct { -// Mode string `json:"mode"` -// Value int `json:"value"` // 0: HD, 1: SD -// } +type ResolutionFrame struct { + Mode string `json:"mode"` + Value int `json:"cmdValue"` // 0: HD, 1: SD +} type SpeakerFrame struct { Mode string `json:"mode"` - Value int `json:"value"` // 0: off, 1: on + Value int `json:"cmdValue"` // 0: off, 1: on } type DisconnectFrame struct { @@ -77,20 +82,27 @@ type MqttMessage struct { Data MqttFrame `json:"data"` } -func (c *TuyaClient) StartMQTT() error { - hubConfig, err := c.LoadHubConfig() - if err != nil { - return err +func NewTuyaMqttClient(deviceId string) *TuyaMqttClient { + return &TuyaMqttClient{ + deviceId: deviceId, + sessionId: core.RandString(6, 62), + waiter: core.Waiter{}, } +} - c.mqtt.publishTopic = hubConfig.SinkTopic.IPC - c.mqtt.subscribeTopic = hubConfig.SourceSink.IPC +func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig, webrtcVersion int) error { + c.webrtcVersion = webrtcVersion + c.motoId = webrtcConfig.MotoID + c.auth = webrtcConfig.Auth - c.mqtt.publishTopic = strings.Replace(c.mqtt.publishTopic, "moto_id", c.motoId, 1) - c.mqtt.publishTopic = strings.Replace(c.mqtt.publishTopic, "{device_id}", c.deviceId, 1) + c.publishTopic = hubConfig.PublishTopic + c.subscribeTopic = hubConfig.SubscribeTopic - parts := strings.Split(c.mqtt.subscribeTopic, "/") - c.mqtt.uid = parts[3] + c.publishTopic = strings.Replace(c.publishTopic, "moto_id", c.motoId, 1) + c.publishTopic = strings.Replace(c.publishTopic, "{device_id}", c.deviceId, 1) + + parts := strings.Split(c.subscribeTopic, "/") + c.uid = parts[3] opts := mqtt.NewClientOptions().AddBroker(hubConfig.Url). SetClientID(hubConfig.ClientID). @@ -99,113 +111,27 @@ func (c *TuyaClient) StartMQTT() error { SetOnConnectHandler(c.onConnect). SetConnectTimeout(10 * time.Second) - c.mqtt.client = mqtt.NewClient(opts) + c.client = mqtt.NewClient(opts) - if token := c.mqtt.client.Connect(); token.Wait() && token.Error() != nil { + if token := c.client.Connect(); token.Wait() && token.Error() != nil { return token.Error() } - if err := c.mqtt.waiter.Wait(); err != nil { + if err := c.waiter.Wait(); err != nil { return err } return nil } -func (c *TuyaClient) StopMQTT() { - if c.mqtt.client != nil { - _ = c.sendDisconnect() - c.mqtt.client.Disconnect(1000) +func (c *TuyaMqttClient) Stop() { + if c.client != nil { + _ = c.SendDisconnect() + c.client.Disconnect(1000) } } -func (c *TuyaClient) onConnect(client mqtt.Client) { - if token := client.Subscribe(c.mqtt.subscribeTopic, 1, c.consume); token.Wait() && token.Error() != nil { - c.mqtt.waiter.Done(token.Error()) - return - } - - c.mqtt.waiter.Done(nil) -} - -func (c *TuyaClient) consume(client mqtt.Client, msg mqtt.Message) { - var rmqtt MqttMessage - if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil { - c.mqtt.onError(err) - return - } - - if rmqtt.Data.Header.SessionID != c.sessionId { - return - } - - switch rmqtt.Data.Header.Type { - case "answer": - c.mqtt.onMqttAnswer(&rmqtt) - case "candidate": - c.mqtt.onMqttCandidate(&rmqtt) - case "disconnect": - c.mqtt.onMqttDisconnect() - } -} - -func (c *TuyaMQTT) onMqttAnswer(msg *MqttMessage) { - var answerFrame AnswerFrame - if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { - c.onError(err) - return - } - - c.onAnswer(answerFrame) -} - -func (c *TuyaMQTT) onMqttCandidate(msg *MqttMessage) { - var candidateFrame CandidateFrame - if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { - c.onError(err) - return - } - - // candidate from device start with "a=", end with "\r\n", which are not needed by Chrome webRTC - candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=") - candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n") - - c.onCandidate(candidateFrame) -} - -func (c *TuyaMQTT) onMqttDisconnect() { - c.closed = true - c.onDisconnect() -} - -func (c *TuyaMQTT) onAnswer(answer AnswerFrame) { - if c.handleAnswer != nil { - c.handleAnswer(answer) - } -} - -func (c *TuyaMQTT) onCandidate(candidate CandidateFrame) { - if c.handleCandidate != nil { - c.handleCandidate(candidate) - } -} - -func (c *TuyaMQTT) onDisconnect() { - if c.handleDisconnect != nil { - c.handleDisconnect() - } -} - -func (c *TuyaMQTT) onError(err error) { - if c.handleError != nil { - c.handleError(err) - } -} - -func (c *TuyaClient) sendOffer(sdp string, streamResolution string) error { - streamType := c.getStreamType(streamResolution) - isHEVC := c.isHEVC(streamType) - +func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error { if isHEVC { // On HEVC we use streamType 0 for main stream (hd) and 1 for sub stream (sd) if streamResolution == "hd" { @@ -224,40 +150,125 @@ func (c *TuyaClient) sendOffer(sdp string, streamResolution string) error { }) } -func (c *TuyaClient) sendCandidate(candidate string) error { +func (c *TuyaMqttClient) SendCandidate(candidate string) error { return c.sendMqttMessage("candidate", 302, "", CandidateFrame{ Mode: "webrtc", Candidate: candidate, }) } -// func (c *TuyaClient) sendResolution(resolution int) error { -// isClaritySupperted := (c.skill.WebRTC & (1 << 5)) != 0 -// if !isClaritySupperted { -// return nil -// } +func (c *TuyaMqttClient) SendResolution(resolution int) error { + // isClaritySupperted := (c.webrtcVersion & (1 << 5)) != 0 + // if !isClaritySupperted { + // return nil + // } -// return c.sendMqttMessage("resolution", 302, "", ResolutionFrame{ -// Mode: "webrtc", -// Value: resolution, -// }) -// } + // Protocol 312 is used for clarity + return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{ + Mode: "webrtc", + Value: resolution, + }) +} -func (c *TuyaClient) sendSpeaker(speaker int) error { - return c.sendMqttMessage("speaker", 302, "", SpeakerFrame{ +func (c *TuyaMqttClient) SendSpeaker(speaker int) error { + // Protocol 312 is used for speaker + return c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ Mode: "webrtc", Value: speaker, }) } -func (c *TuyaClient) sendDisconnect() error { +func (c *TuyaMqttClient) SendDisconnect() error { return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ Mode: "webrtc", }) } -func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { - if c.mqtt.closed { +func (c *TuyaMqttClient) onConnect(client mqtt.Client) { + if token := client.Subscribe(c.subscribeTopic, 1, c.consume); token.Wait() && token.Error() != nil { + c.waiter.Done(token.Error()) + return + } + + c.waiter.Done(nil) +} + +func (c *TuyaMqttClient) consume(client mqtt.Client, msg mqtt.Message) { + var rmqtt MqttMessage + if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil { + c.onError(err) + return + } + + if rmqtt.Data.Header.SessionID != c.sessionId { + return + } + + switch rmqtt.Data.Header.Type { + case "answer": + c.onMqttAnswer(&rmqtt) + case "candidate": + c.onMqttCandidate(&rmqtt) + case "disconnect": + c.onMqttDisconnect() + } +} + +func (c *TuyaMqttClient) onMqttAnswer(msg *MqttMessage) { + var answerFrame AnswerFrame + if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { + c.onError(err) + return + } + + c.onAnswer(answerFrame) +} + +func (c *TuyaMqttClient) onMqttCandidate(msg *MqttMessage) { + var candidateFrame CandidateFrame + if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { + c.onError(err) + return + } + + // candidate from device start with "a=", end with "\r\n", which are not needed by Chrome webRTC + candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=") + candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n") + + c.onCandidate(candidateFrame) +} + +func (c *TuyaMqttClient) onMqttDisconnect() { + c.closed = true + c.onDisconnect() +} + +func (c *TuyaMqttClient) onAnswer(answer AnswerFrame) { + if c.handleAnswer != nil { + c.handleAnswer(answer) + } +} + +func (c *TuyaMqttClient) onCandidate(candidate CandidateFrame) { + if c.handleCandidate != nil { + c.handleCandidate(candidate) + } +} + +func (c *TuyaMqttClient) onDisconnect() { + if c.handleDisconnect != nil { + c.handleDisconnect() + } +} + +func (c *TuyaMqttClient) onError(err error) { + if c.handleError != nil { + c.handleError(err) + } +} + +func (c *TuyaMqttClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { + if c.closed { return fmt.Errorf("mqtt client is closed, send mqtt message fail") } @@ -273,7 +284,7 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti Data: MqttFrame{ Header: MqttFrameHeader{ Type: messageType, - From: c.mqtt.uid, + From: c.uid, To: c.deviceId, SessionID: c.sessionId, MotoID: c.motoId, @@ -288,7 +299,7 @@ func (c *TuyaClient) sendMqttMessage(messageType string, protocol int, transacti return err } - token := c.mqtt.client.Publish(c.mqtt.publishTopic, 1, false, payload) + token := c.client.Publish(c.publishTopic, 1, false, payload) if token.Wait() && token.Error() != nil { return token.Error() } diff --git a/pkg/tuya/sharing_api.go b/pkg/tuya/sharing_api.go new file mode 100644 index 00000000..88ec223d --- /dev/null +++ b/pkg/tuya/sharing_api.go @@ -0,0 +1,473 @@ +package tuya + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + TUYA_HOST = "apigw.iotbing.com" + TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" + TUYA_SCHEMA = "haauthorize" +) + +type OpenApiMQTTConfig struct { + ClientID string `json:"clientId"` + ExpireTime int `json:"expireTime"` + Password string `json:"password"` + Topic struct { + DevID struct { + Pub string `json:"pub"` + Sub string `json:"sub"` + } `json:"devId"` + OwnerID struct { + Sub string `json:"sub"` + } `json:"ownerId"` + } `json:"topic"` + URL string `json:"url"` + Username string `json:"username"` +} + +type OpenApiMQTTConfigRequest struct { + LinkID string `json:"linkId"` +} + +type OpenApiMQTTConfigResponse struct { + Success bool `json:"success"` + Result OpenApiMQTTConfig `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type TokenInfo struct { + AccessToken string `json:"access_token"` + ExpireTime int64 `json:"expire_time"` + RefreshToken string `json:"refresh_token"` +} + +type LoginResult struct { + AccessToken string `json:"access_token"` + Endpoint string `json:"endpoint"` + ExpireTime int64 `json:"expire_time"` // seconds + RefreshToken string `json:"refresh_token"` + TerminalID string `json:"terminal_id"` + UID string `json:"uid"` + Username string `json:"username"` +} + +type LoginResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result LoginResult `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type QRResponse struct { + Success bool `json:"success"` + Result struct { + Code string `json:"qrcode"` + } `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type Home struct { + ID int `json:"id"` + Name string `json:"name"` + OwnerID string `json:"ownerId"` + Background string `json:"background"` + GeoName string `json:"geoName"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + GmtCreate int64 `json:"gmtCreate"` + GmtModified int64 `json:"gmtModified"` + GroupID int64 `json:"groupId"` + Status bool `json:"status"` + UID string `json:"uid"` +} + +type HomesResponse struct { + Success bool `json:"success"` + Result []Home `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type DeviceFunction struct { + Code string `json:"code"` + Desc string `json:"desc"` + Name string `json:"name"` + Type string `json:"type"` + Values map[string]any `json:"values"` +} + +type DeviceStatusRange struct { + Code string `json:"code"` + Type string `json:"type"` + Values map[string]any `json:"values"` +} + +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + LocalKey string `json:"local_key"` + Category string `json:"category"` + ProductID string `json:"product_id"` + ProductName string `json:"product_name"` + Sub bool `json:"sub"` + UUID string `json:"uuid"` + AssetID string `json:"asset_id"` + Online bool `json:"online"` + Icon string `json:"icon"` + IP string `json:"ip"` + TimeZone string `json:"time_zone"` + ActiveTime int64 `json:"active_time"` + CreateTime int64 `json:"create_time"` + UpdateTime int64 `json:"update_time"` +} + +type DeviceRequest struct { + HomeID string `json:"homeId"` +} + +type DeviceResponse struct { + Success bool `json:"success"` + Result []Device `json:"result"` + Msg string `json:"msg,omitempty"` +} + +type TuyaOpenApiClient struct { + TuyaClient + terminalId string + refreshingToken bool +} + +func NewTuyaOpenApiClient( + baseUrl string, + uid string, + deviceId string, + terminalId string, + tokenInfoOrString any, + streamMode string, +) (*TuyaOpenApiClient, error) { + tokenInfo, err := ParseTokenInfo(tokenInfoOrString) + if err != nil { + return nil, fmt.Errorf("failed to parse token info: %w", err) + } + + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaOpenApiClient{ + TuyaClient: TuyaClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + mqtt: mqttClient, + uid: uid, + deviceId: deviceId, + accessToken: tokenInfo.AccessToken, + refreshToken: tokenInfo.RefreshToken, + expireTime: tokenInfo.ExpireTime, + streamMode: streamMode, + baseUrl: baseUrl, + }, + terminalId: terminalId, + refreshingToken: false, + } + + return client, nil +} + +// WebRTC Flow (not supported yet) +func (c *TuyaOpenApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + return fmt.Errorf("stream mode %s is not supported", c.streamMode) +} + +func (c *TuyaOpenApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + if err := c.initToken(); err != nil { + return "", fmt.Errorf("failed to initialize token: %w", err) + } + + urlPath := fmt.Sprintf("/v1.0/m/ipc/%s/stream/actions/allocate", c.deviceId) + + request := &AllocateRequest{ + Type: streamType, + } + + body, err := c.request("POST", urlPath, nil, request) + if err != nil { + return "", err + } + + var allocResponse AllocateResponse + err = json.Unmarshal(body, &allocResponse) + if err != nil { + return "", err + } + + if !allocResponse.Success { + return "", fmt.Errorf(allocResponse.Msg) + } + + return allocResponse.Result.URL, nil +} + +func (c *TuyaOpenApiClient) GetAllDevices() ([]Device, error) { + homes, err := c.queryHomes() + if err != nil { + return nil, err + } + + time.Sleep(500 * time.Millisecond) + deviceMap := make(map[string]Device) + + for i, home := range homes { + if i > 0 { + time.Sleep(300 * time.Millisecond) + } + + devices, err := c.queryDevicesByHome(home.OwnerID) + if err != nil { + return nil, err + } + + for _, device := range devices { + // https://github.com/home-assistant/core/blob/088cfc3576e0018ad1df373c08549092918e6530/homeassistant/components/tuya/camera.py#L19 + if device.Category == "sp" || device.Category == "dghsxj" { + deviceMap[device.ID] = device + } + } + } + + var devices []Device + for _, device := range deviceMap { + devices = append(devices, device) + } + + return devices, nil +} + +func (c *TuyaOpenApiClient) loadHubConfig() (config *MQTTConfig, err error) { + request := OpenApiMQTTConfigRequest{ + LinkID: fmt.Sprintf("tuya-device-sharing-sdk-go.%s", uuid.New().String()), + } + + body, err := c.request("POST", "/v1.0/m/life/ha/access/config", nil, request) + if err != nil { + return nil, err + } + + var mqttConfigResponse OpenApiMQTTConfigResponse + if err := json.Unmarshal(body, &mqttConfigResponse); err != nil { + return nil, err + } + + if !mqttConfigResponse.Success { + return nil, fmt.Errorf("failed to get MQTT config: %s", mqttConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: mqttConfigResponse.Result.URL, + Username: mqttConfigResponse.Result.Username, + Password: mqttConfigResponse.Result.Password, + ClientID: mqttConfigResponse.Result.ClientID, + PublishTopic: mqttConfigResponse.Result.Topic.DevID.Pub, + SubscribeTopic: mqttConfigResponse.Result.Topic.DevID.Sub, + }, nil +} + +func (c *TuyaOpenApiClient) queryHomes() ([]Home, error) { + body, err := c.request("GET", "/v1.0/m/life/users/homes", nil, nil) + if err != nil { + return nil, err + } + + var homesResponse HomesResponse + if err := json.Unmarshal(body, &homesResponse); err != nil { + return nil, err + } + + if !homesResponse.Success { + return nil, fmt.Errorf("failed to get homes: %s", homesResponse.Msg) + } + + return homesResponse.Result, nil +} + +func (c *TuyaOpenApiClient) queryDevicesByHome(homeID string) ([]Device, error) { + params := DeviceRequest{ + HomeID: homeID, + } + + body, err := c.request("GET", "/v1.0/m/life/ha/home/devices", params, nil) + if err != nil { + return nil, err + } + + var devicesResponse DeviceResponse + if err := json.Unmarshal(body, &devicesResponse); err != nil { + return nil, err + } + + if !devicesResponse.Success { + return nil, fmt.Errorf("failed to get devices: %s", devicesResponse.Msg) + } + + return devicesResponse.Result, nil +} + +// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py +func (c *TuyaOpenApiClient) request( + method string, + path string, + params any, + body any, +) ([]byte, error) { + rid := uuid.New().String() + sid := "" + + md5Hash := md5.New() + ridRefreshToken := rid + c.refreshToken + md5Hash.Write([]byte(ridRefreshToken)) + hashKey := hex.EncodeToString(md5Hash.Sum(nil)) + secret := SecretGenerating(rid, sid, hashKey) + + queryEncdata := "" + var reqURL string + if params != nil { + jsonData := FormToJSON(params) + + encryptedData, err := AesGCMEncrypt(jsonData, secret) + if err != nil { + return nil, err + } + + queryEncdata = encryptedData + reqURL = fmt.Sprintf("https://%s%s?encdata=%s", c.baseUrl, path, queryEncdata) + } else { + reqURL = fmt.Sprintf("https://%s%s", c.baseUrl, path) + } + + bodyEncdata := "" + var reqBody io.Reader + if body != nil { + jsonData := FormToJSON(body) + + encryptedData, err := AesGCMEncrypt(jsonData, secret) + if err != nil { + return nil, err + } + + bodyEncdata = encryptedData + encBody := map[string]string{"encdata": bodyEncdata} + bodyBytes, _ := json.Marshal(encBody) + reqBody = strings.NewReader(string(bodyBytes)) + } + + req, err := http.NewRequest(method, reqURL, reqBody) + if err != nil { + return nil, err + } + + t := time.Now().Add(2*time.Second).UnixNano() / int64(time.Millisecond) + headers := map[string]string{ + "X-appKey": TUYA_CLIENT_ID, + "X-requestId": rid, + "X-sid": sid, + "X-time": fmt.Sprintf("%d", t), + "Content-Type": "application/json", + } + + if c.accessToken != "" { + headers["X-token"] = c.accessToken + } + + sign := RestfulSign(hashKey, queryEncdata, bodyEncdata, headers) + headers["X-sign"] = sign + + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var resultObj map[string]any + if err := json.Unmarshal(respBody, &resultObj); err != nil { + return nil, err + } + + if resultStr, ok := resultObj["result"].(string); ok { + decrypted, err := AesGCMDecrypt(resultStr, secret) + if err != nil { + return nil, err + } + + var decryptedObj any + if err := json.Unmarshal([]byte(decrypted), &decryptedObj); err == nil { + resultObj["result"] = decryptedObj + } else { + resultObj["result"] = decrypted + } + + updatedResponse, err := json.Marshal(resultObj) + if err != nil { + return nil, fmt.Errorf("failed to marshal updated response: %w", err) + } + + return updatedResponse, nil + } + + return respBody, nil +} + +func (c *TuyaOpenApiClient) initToken() error { + if c.refreshingToken { + return nil + } + + now := time.Now().Unix() + if (c.expireTime - 60) > now { + return nil + } + + c.refreshingToken = true + + urlPath := fmt.Sprintf("/v1.0/m/token/%s", c.refreshToken) + + body, err := c.request("GET", urlPath, nil, nil) + if err != nil { + return err + } + + var loginResponse LoginResponse + if err := json.Unmarshal(body, &loginResponse); err != nil { + return err + } + + if !loginResponse.Success { + return fmt.Errorf("failed to get token: %s", loginResponse.Msg) + } + + c.accessToken = loginResponse.Result.AccessToken + c.refreshToken = loginResponse.Result.RefreshToken + c.expireTime = loginResponse.Timestamp + loginResponse.Result.ExpireTime + c.refreshingToken = false + + return nil +} diff --git a/www/add.html b/www/add.html index c8808736..f16d4d45 100644 --- a/www/add.html +++ b/www/add.html @@ -28,6 +28,7 @@ } + @@ -280,6 +281,110 @@ document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth); + +
+

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

+
+ + +
+ + + +
+
+ +
From fbd8d995ed0132088f9499eeaa13c86c698e5848 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 22 May 2025 00:16:31 +0200 Subject: [PATCH 027/241] refactor and increase timeout --- pkg/tuya/{sharing_api.go => open_api.go} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename pkg/tuya/{sharing_api.go => open_api.go} (99%) diff --git a/pkg/tuya/sharing_api.go b/pkg/tuya/open_api.go similarity index 99% rename from pkg/tuya/sharing_api.go rename to pkg/tuya/open_api.go index 88ec223d..4bd2a044 100644 --- a/pkg/tuya/sharing_api.go +++ b/pkg/tuya/open_api.go @@ -225,12 +225,12 @@ func (c *TuyaOpenApiClient) GetAllDevices() ([]Device, error) { return nil, err } - time.Sleep(500 * time.Millisecond) + time.Sleep(2 * time.Second) deviceMap := make(map[string]Device) for i, home := range homes { if i > 0 { - time.Sleep(300 * time.Millisecond) + time.Sleep(500 * time.Millisecond) } devices, err := c.queryDevicesByHome(home.OwnerID) From 7ee3f6e4f7629f54669c4fa108960eca5508b18e Mon Sep 17 00:00:00 2001 From: seydx <34152761+seydx@users.noreply.github.com> Date: Thu, 22 May 2025 00:41:22 +0200 Subject: [PATCH 028/241] Update README.md Co-authored-by: Felipe Santos --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 00b9cc45..d2d47ef8 100644 --- a/README.md +++ b/README.md @@ -587,7 +587,7 @@ The `Cloud API` requires setting up a cloud project in the Tuya Developer Platfo - `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): +- Use `resolution` parameter to select the stream (only available for `Cloud API` and not all cameras support `hd` stream through WebRTC even if the camera has it): - `hd` - HD stream (default) - `sd` - SD stream From 42b7eea8524c295076d03af02afa52e5bf19f828 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 22 May 2025 21:27:28 +0200 Subject: [PATCH 029/241] fix: correct transceiver direction check in getSender method --- pkg/tuya/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 4a43c49a..7692639c 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -490,7 +490,7 @@ func (c *Client) sendMessageToDataChannel(message []byte) error { func (c *Client) getSender() *webrtc.Track { for _, tr := range c.pc.GetTransceivers() { if tr.Kind() == pion.RTPCodecTypeAudio { - if tr.Kind() == pion.RTPCodecType(pion.RTPTransceiverDirectionSendonly) || tr.Kind() == pion.RTPCodecType(pion.RTPTransceiverDirectionSendrecv) { + if tr.Direction() == pion.RTPTransceiverDirectionSendonly || tr.Direction() == pion.RTPTransceiverDirectionSendrecv { if s := tr.Sender(); s != nil { if t := s.Track().(*webrtc.Track); t != nil { return t From 5be5d9247caaf09be4f68102f79118366d26b7cd Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 18:29:31 +0200 Subject: [PATCH 030/241] refactor, simplify api, add support for email/password auth --- README.md | 48 +--- internal/tuya/tuya.go | 282 +++++++++++--------- pkg/tuya/client.go | 39 +-- pkg/tuya/cloud_api.go | 13 +- pkg/tuya/crypto.go | 134 ---------- pkg/tuya/helper.go | 103 ++++---- pkg/tuya/interface.go | 28 +- pkg/tuya/open_api.go | 473 --------------------------------- pkg/tuya/tuya_api.go | 590 ++++++++++++++++++++++++++++++++++++++++++ www/add.html | 88 ++----- 10 files changed, 857 insertions(+), 941 deletions(-) delete mode 100644 pkg/tuya/crypto.go delete mode 100644 pkg/tuya/open_api.go create mode 100644 pkg/tuya/tuya_api.go diff --git a/README.md b/README.md index d2d47ef8..7a257ae0 100644 --- a/README.md +++ b/README.md @@ -568,58 +568,38 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. #### Source: Tuya -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Open API`. +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Tuya 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. +The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Tuya API` does not require a cloud project and the cameras can be added through the interface via email/password. **Cloud API**: -- Obtain `device_id`, `client_id`, `client_secret`, and `uid` (if using `mode=webrtc`) from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). +- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). **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. +- Smart Life accounts are not supported, you need to create a Tuya 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. **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 through WebRTC even if the camera has it): +- 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: - # Cloud API: WebRTC stream + # Cloud API: WebRTC main stream tuya_webrtc: - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX - - # 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 - # 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 - - # Cloud API: WebRTC stream (SD) + # 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 - - # 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 - - # 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 + # Tuya API: WebRTC main stream + tuya: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX + + # Tuya API: WebRTC sub stream + tuya: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd ``` #### Source: GoPro diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go index b5457253..cb25daa5 100644 --- a/internal/tuya/tuya.go +++ b/internal/tuya/tuya.go @@ -1,13 +1,13 @@ package tuya import ( + "bytes" "encoding/json" + "errors" "fmt" - "io" "net/http" "net/url" - "strings" - "time" + "strconv" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" @@ -15,8 +15,6 @@ import ( "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) @@ -27,74 +25,41 @@ func Init() { func apiTuya(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - userCode := query.Get("user_code") - token := query.Get("token") + region := query.Get("region") + email := query.Get("email") + password := query.Get("password") - if userCode == "" { - http.Error(w, "user_code is required", http.StatusBadRequest) + if email == "" || password == "" || region == "" { + http.Error(w, "email, password and region are 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 = "" + var tuyaRegion *tuya.Region + for _, r := range tuya.AvailableRegions { + if r.Host == region { + tuyaRegion = &r + break } } - 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, - }) - + if tuyaRegion == nil { + http.Error(w, fmt.Sprintf("invalid region: %s", region), http.StatusBadRequest) return } - if auth == nil && token != "" { - authResponse, err := login(userCode, token) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + httpClient := tuya.CreateHTTPClientWithSession() - 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) + _, err := login(httpClient, tuyaRegion.Host, email, password, tuyaRegion.Continent) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("login failed: %v", err), http.StatusInternalServerError) return } - tuyaAPI, err := tuya.NewTuyaOpenApiClient( - strings.Replace(auth.Result.Endpoint, "https://", "", 1), - auth.Result.UID, - "", - auth.Result.TerminalID, - tokenInfo, + tuyaAPI, err := tuya.NewTuyaApiClient( + httpClient, + tuyaRegion.Host, + email, + password, "", ) @@ -103,25 +68,52 @@ func apiTuya(w http.ResponseWriter, r *http.Request) { return } - devices, err := tuyaAPI.GetAllDevices() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + 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("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()) + 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.Name, + Name: device.DeviceName, URL: url, }) } @@ -129,86 +121,128 @@ func apiTuya(w http.ResponseWriter, r *http.Request) { 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) +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 } - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - - response, err := httpClient.Do(req) + encryptedPassword, err := tuya.EncryptPassword(password, tokenResp.Result.PbKey) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to encrypt password: %v", err) } - defer response.Body.Close() - res, err := io.ReadAll(response.Body) + 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 response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get QR code: %s", string(res)) + if !loginResp.Success { + return nil, errors.New(loginResp.ErrorMsg) } - 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 + return &loginResp.Result, 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) +func getLoginToken(client *http.Client, serverHost, username, countryCode string) (*tuya.LoginTokenResponse, error) { + url := fmt.Sprintf("https://%s/api/login/token", serverHost) - req, err := http.NewRequest("POST", url, nil) + tokenReq := tuya.LoginTokenRequest{ + CountryCode: countryCode, + Username: username, + IsUid: false, + } + + jsonData, err := json.Marshal(tokenReq) if err != nil { - return "", err + return nil, err } - req.Header.Set("Content-Type", "text/plain") - - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - - response, err := httpClient.Do(req) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { - return "", err + return nil, err } - defer response.Body.Close() - res, err := io.ReadAll(response.Body) + 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 "", err + 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 response.StatusCode != http.StatusOK { - return "", err + if !tokenResp.Success { + return nil, 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 + return &tokenResp, nil +} + +func performLogin(client *http.Client, url string, loginReq tuya.PasswordLoginRequest, serverHost string) (*tuya.PasswordLoginResponse, error) { + jsonData, err := json.Marshal(loginReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var loginResp tuya.PasswordLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return nil, err + } + + return &loginResp, nil +} + +func containsDevice(devices []tuya.Device, deviceID string) bool { + for _, device := range devices { + if device.DeviceId == deviceID { + return true + } + } + return false } diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 7692639c..f5b964e9 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -7,7 +7,6 @@ import ( "net/url" "regexp" - "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/pion/rtp" @@ -50,38 +49,29 @@ func Dial(rawURL string) (core.Producer, error) { query := u.Query() - // Open API - tokenInfo := query.Get("token") - terminalId := query.Get("terminal_id") + // Tuya API + email := query.Get("email") + password := query.Get("password") // Cloud API + uid := query.Get("uid") 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 != "" + useTuyaApi := deviceId != "" && email != "" && password != "" + useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != "" if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { streamResolution = "hd" } - if streamMode == "" || (streamMode != "rtsp" && streamMode != "hls" && streamMode != "flv" && streamMode != "rtmp" && streamMode != "webrtc") { - if useOpenApi { - streamMode = "rtsp" - } else { - streamMode = "webrtc" - } - } - - if !useOpenApi && !useCloudApi { + if !useTuyaApi && !useCloudApi { return nil, errors.New("tuya: wrong query params") } @@ -89,25 +79,16 @@ func Dial(rawURL string) (core.Producer, error) { handlers: make(map[uint32]func(*rtp.Packet)), } - if useOpenApi { - if client.api, err = NewTuyaOpenApiClient(u.Hostname(), uid, deviceId, terminalId, tokenInfo, streamMode); err != nil { + if useTuyaApi { + if client.api, err = NewTuyaApiClient(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, streamMode); err != nil { + if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret); err != nil { return nil, fmt.Errorf("tuya: %w", err) } } - if streamMode != "webrtc" { - streamUrl, err := client.api.GetStreamUrl(streamMode) - if err != nil { - return nil, fmt.Errorf("tuya: %w", err) - } - - return streams.GetProducer(streamUrl) - } - if err := client.api.Init(); err != nil { return nil, fmt.Errorf("tuya: %w", err) } diff --git a/pkg/tuya/cloud_api.go b/pkg/tuya/cloud_api.go index bd74daf0..4d25c2be 100644 --- a/pkg/tuya/cloud_api.go +++ b/pkg/tuya/cloud_api.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/md5" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -68,24 +69,26 @@ type OpenIoTHubConfigResponse struct { type TuyaCloudApiClient struct { TuyaClient + uid string clientId string clientSecret string + accessToken string + refreshToken string refreshingToken bool } -func NewTuyaCloudApiClient(baseUrl string, uid string, deviceId string, clientId string, clientSecret string, streamMode string) (*TuyaCloudApiClient, error) { +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, - uid: uid, deviceId: deviceId, - streamMode: streamMode, expireTime: 0, baseUrl: baseUrl, }, + uid: uid, clientId: clientId, clientSecret: clientSecret, refreshingToken: false, @@ -140,7 +143,7 @@ func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string, } if !allocResponse.Success { - return "", fmt.Errorf(allocResponse.Msg) + return "", errors.New(allocResponse.Msg) } return allocResponse.Result.URL, nil @@ -175,7 +178,7 @@ func (c *TuyaCloudApiClient) initToken() (err error) { } if !tokenResponse.Success { - return fmt.Errorf(tokenResponse.Msg) + return errors.New(tokenResponse.Msg) } c.accessToken = tokenResponse.Result.AccessToken diff --git a/pkg/tuya/crypto.go b/pkg/tuya/crypto.go deleted file mode 100644 index b8f84615..00000000 --- a/pkg/tuya/crypto.go +++ /dev/null @@ -1,134 +0,0 @@ -package tuya - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "math/rand" -) - -// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py -func AesGCMEncrypt(rawData string, secret string) (string, error) { - nonce := []byte(RandomNonce(12)) - - block, err := aes.NewCipher([]byte(secret)) - if err != nil { - return "", err - } - - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - ciphertext := aesgcm.Seal(nil, nonce, []byte(rawData), nil) - nonceB64 := base64.StdEncoding.EncodeToString(nonce) - ciphertextB64 := base64.StdEncoding.EncodeToString(ciphertext) - - return nonceB64 + ciphertextB64, nil -} - -func AesGCMDecrypt(cipherData string, secret string) (string, error) { - if len(cipherData) <= 16 { - return "", fmt.Errorf("invalid ciphertext length") - } - - nonceB64 := cipherData[:16] - ciphertextB64 := cipherData[16:] - - nonce, err := base64.StdEncoding.DecodeString(nonceB64) - if err != nil { - return "", err - } - - ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) - if err != nil { - return "", err - } - - block, err := aes.NewCipher([]byte(secret)) - if err != nil { - return "", err - } - - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", err - } - - return string(plaintext), nil -} - -func SecretGenerating(rid, sid, hashKey string) string { - message := hashKey - mod := 16 - - if sid != "" { - sidLength := len(sid) - length := sidLength - if length > mod { - length = mod - } - - ecode := "" - for i := 0; i < length; i++ { - idx := int(sid[i]) % mod - ecode += string(sid[idx]) - } - message += "_" - message += ecode - } - - h := hmac.New(sha256.New, []byte(rid)) - h.Write([]byte(message)) - byteTemp := h.Sum(nil) - secret := hex.EncodeToString(byteTemp) - - return secret[:16] -} - -func RestfulSign(hashKey, queryEncdata, bodyEncdata string, data map[string]string) string { - headers := []string{"X-appKey", "X-requestId", "X-sid", "X-time", "X-token"} - headerSignStr := "" - - for _, item := range headers { - val, exists := data[item] - if exists && val != "" { - headerSignStr += item + "=" + val + "||" - } - } - - signStr := "" - if len(headerSignStr) > 2 { - signStr = headerSignStr[:len(headerSignStr)-2] - } - - if queryEncdata != "" { - signStr += queryEncdata - } - if bodyEncdata != "" { - signStr += bodyEncdata - } - - h := hmac.New(sha256.New, []byte(hashKey)) - h.Write([]byte(signStr)) - return hex.EncodeToString(h.Sum(nil)) -} - -func RandomNonce(length int) string { - const charset = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678" - result := make([]byte, length) - for i := range result { - result[i] = charset[rand.Intn(len(charset))] - } - return string(result) -} diff --git a/pkg/tuya/helper.go b/pkg/tuya/helper.go index 0b97b256..7c9eb410 100644 --- a/pkg/tuya/helper.go +++ b/pkg/tuya/helper.go @@ -1,72 +1,69 @@ package tuya import ( - "encoding/base64" - "encoding/json" - "fmt" + "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 FormToJSON(content any) string { - if content == nil { - return "{}" +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") } - jsonBytes, err := json.Marshal(content) + pubKey, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - return "{}" + return "", err } - return string(jsonBytes) + 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 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 IsEmailAddress(input string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(input) } -func FromBase64(encodedTokenInfo string) (*TokenInfo, error) { - jsonData, err := base64.URLEncoding.DecodeString(encodedTokenInfo) +func CreateHTTPClientWithSession() *http.Client { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { - return nil, fmt.Errorf("error decoding token: %v", err) + return nil } - var tokenInfo TokenInfo - err = json.Unmarshal(jsonData, &tokenInfo) - if err != nil { - return nil, fmt.Errorf("error unmarshalling token: %v", err) + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, } - - return &tokenInfo, nil -} - -func ParseTokenInfo(tokenInfoOrString any) (*TokenInfo, error) { - var tokenInfo *TokenInfo - var err error - - switch v := tokenInfoOrString.(type) { - case string: - tokenInfo, err = FromBase64(v) - if err != nil { - return nil, fmt.Errorf("failed to decode base64 token: %w", err) - } - case *TokenInfo: - tokenInfo = v - case TokenInfo: - copyOfV := v - tokenInfo = ©OfV - default: - return nil, fmt.Errorf("invalid type: %T", v) - } - - if tokenInfo == nil { - return nil, fmt.Errorf("token info is nil") - } - - return tokenInfo, nil } diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go index dae19a82..f4f530aa 100644 --- a/pkg/tuya/interface.go +++ b/pkg/tuya/interface.go @@ -26,17 +26,13 @@ type TuyaAPI interface { 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 + httpClient *http.Client + mqtt *TuyaMqttClient + baseUrl string + expireTime int64 + deviceId string + skill *Skill + iceServers []pionWebrtc.ICEServer } type AudioAttributes struct { @@ -44,11 +40,11 @@ type AudioAttributes struct { HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker } -type OpenApiICE struct { +type ICEServer struct { Urls string `json:"urls"` - Username string `json:"username"` - Credential string `json:"credential"` - TTL int `json:"ttl"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` + TTL int `json:"ttl,omitempty"` } type WebICE struct { @@ -58,7 +54,7 @@ type WebICE struct { } type P2PConfig struct { - Ices []OpenApiICE `json:"ices"` + Ices []ICEServer `json:"ices"` } type AudioSkill struct { diff --git a/pkg/tuya/open_api.go b/pkg/tuya/open_api.go deleted file mode 100644 index 4bd2a044..00000000 --- a/pkg/tuya/open_api.go +++ /dev/null @@ -1,473 +0,0 @@ -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(2 * time.Second) - deviceMap := make(map[string]Device) - - for i, home := range homes { - if i > 0 { - time.Sleep(500 * time.Millisecond) - } - - devices, err := c.queryDevicesByHome(home.OwnerID) - if err != nil { - return nil, err - } - - for _, device := range devices { - // https://github.com/home-assistant/core/blob/088cfc3576e0018ad1df373c08549092918e6530/homeassistant/components/tuya/camera.py#L19 - if device.Category == "sp" || device.Category == "dghsxj" { - deviceMap[device.ID] = device - } - } - } - - var devices []Device - for _, device := range deviceMap { - devices = append(devices, device) - } - - return devices, nil -} - -func (c *TuyaOpenApiClient) loadHubConfig() (config *MQTTConfig, err error) { - request := OpenApiMQTTConfigRequest{ - LinkID: fmt.Sprintf("tuya-device-sharing-sdk-go.%s", uuid.New().String()), - } - - body, err := c.request("POST", "/v1.0/m/life/ha/access/config", nil, request) - if err != nil { - return nil, err - } - - var mqttConfigResponse OpenApiMQTTConfigResponse - if err := json.Unmarshal(body, &mqttConfigResponse); err != nil { - return nil, err - } - - if !mqttConfigResponse.Success { - return nil, fmt.Errorf("failed to get MQTT config: %s", mqttConfigResponse.Msg) - } - - return &MQTTConfig{ - Url: mqttConfigResponse.Result.URL, - Username: mqttConfigResponse.Result.Username, - Password: mqttConfigResponse.Result.Password, - ClientID: mqttConfigResponse.Result.ClientID, - PublishTopic: mqttConfigResponse.Result.Topic.DevID.Pub, - SubscribeTopic: mqttConfigResponse.Result.Topic.DevID.Sub, - }, nil -} - -func (c *TuyaOpenApiClient) queryHomes() ([]Home, error) { - body, err := c.request("GET", "/v1.0/m/life/users/homes", nil, nil) - if err != nil { - return nil, err - } - - var homesResponse HomesResponse - if err := json.Unmarshal(body, &homesResponse); err != nil { - return nil, err - } - - if !homesResponse.Success { - return nil, fmt.Errorf("failed to get homes: %s", homesResponse.Msg) - } - - return homesResponse.Result, nil -} - -func (c *TuyaOpenApiClient) queryDevicesByHome(homeID string) ([]Device, error) { - params := DeviceRequest{ - HomeID: homeID, - } - - body, err := c.request("GET", "/v1.0/m/life/ha/home/devices", params, nil) - if err != nil { - return nil, err - } - - var devicesResponse DeviceResponse - if err := json.Unmarshal(body, &devicesResponse); err != nil { - return nil, err - } - - if !devicesResponse.Success { - return nil, fmt.Errorf("failed to get devices: %s", devicesResponse.Msg) - } - - return devicesResponse.Result, nil -} - -// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py -func (c *TuyaOpenApiClient) request( - method string, - path string, - params any, - body any, -) ([]byte, error) { - rid := uuid.New().String() - sid := "" - - md5Hash := md5.New() - ridRefreshToken := rid + c.refreshToken - md5Hash.Write([]byte(ridRefreshToken)) - hashKey := hex.EncodeToString(md5Hash.Sum(nil)) - secret := SecretGenerating(rid, sid, hashKey) - - queryEncdata := "" - var reqURL string - if params != nil { - jsonData := FormToJSON(params) - - encryptedData, err := AesGCMEncrypt(jsonData, secret) - if err != nil { - return nil, err - } - - queryEncdata = encryptedData - reqURL = fmt.Sprintf("https://%s%s?encdata=%s", c.baseUrl, path, queryEncdata) - } else { - reqURL = fmt.Sprintf("https://%s%s", c.baseUrl, path) - } - - bodyEncdata := "" - var reqBody io.Reader - if body != nil { - jsonData := FormToJSON(body) - - encryptedData, err := AesGCMEncrypt(jsonData, secret) - if err != nil { - return nil, err - } - - bodyEncdata = encryptedData - encBody := map[string]string{"encdata": bodyEncdata} - bodyBytes, _ := json.Marshal(encBody) - reqBody = strings.NewReader(string(bodyBytes)) - } - - req, err := http.NewRequest(method, reqURL, reqBody) - if err != nil { - return nil, err - } - - t := time.Now().Add(2*time.Second).UnixNano() / int64(time.Millisecond) - headers := map[string]string{ - "X-appKey": TUYA_CLIENT_ID, - "X-requestId": rid, - "X-sid": sid, - "X-time": fmt.Sprintf("%d", t), - "Content-Type": "application/json", - } - - if c.accessToken != "" { - headers["X-token"] = c.accessToken - } - - sign := RestfulSign(hashKey, queryEncdata, bodyEncdata, headers) - headers["X-sign"] = sign - - for key, value := range headers { - req.Header.Set(key, value) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var resultObj map[string]any - if err := json.Unmarshal(respBody, &resultObj); err != nil { - return nil, err - } - - if resultStr, ok := resultObj["result"].(string); ok { - decrypted, err := AesGCMDecrypt(resultStr, secret) - if err != nil { - return nil, err - } - - var decryptedObj any - if err := json.Unmarshal([]byte(decrypted), &decryptedObj); err == nil { - resultObj["result"] = decryptedObj - } else { - resultObj["result"] = decrypted - } - - updatedResponse, err := json.Marshal(resultObj) - if err != nil { - return nil, fmt.Errorf("failed to marshal updated response: %w", err) - } - - return updatedResponse, nil - } - - return respBody, nil -} - -func (c *TuyaOpenApiClient) initToken() error { - if c.refreshingToken { - return nil - } - - now := time.Now().Unix() - if (c.expireTime - 60) > now { - return nil - } - - c.refreshingToken = true - - urlPath := fmt.Sprintf("/v1.0/m/token/%s", c.refreshToken) - - body, err := c.request("GET", urlPath, nil, nil) - if err != nil { - return err - } - - var loginResponse LoginResponse - if err := json.Unmarshal(body, &loginResponse); err != nil { - return err - } - - if !loginResponse.Success { - return fmt.Errorf("failed to get token: %s", loginResponse.Msg) - } - - c.accessToken = loginResponse.Result.AccessToken - c.refreshToken = loginResponse.Result.RefreshToken - c.expireTime = loginResponse.Timestamp + loginResponse.Result.ExpireTime - c.refreshingToken = false - - return nil -} diff --git a/pkg/tuya/tuya_api.go b/pkg/tuya/tuya_api.go new file mode 100644 index 00000000..454efb46 --- /dev/null +++ b/pkg/tuya/tuya_api.go @@ -0,0 +1,590 @@ +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 TuyaApiMQTTConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type TuyaApiMQTTConfig 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 TuyaApiWebRTCConfigRequest struct { + DevId string `json:"devId"` + ClientTraceId string `json:"clientTraceId"` +} + +type TuyaApiWebRTCConfigResponse struct { + Result TuyaWebRTCConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type TuyaWebRTCConfig 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 TuyaApiClient 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 NewTuyaApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaApiClient, 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 := &TuyaApiClient{ + 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 *TuyaApiClient) 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 *TuyaApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + return "", errors.New("not supported") +} + +func (c *TuyaApiClient) 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 *TuyaApiClient) 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 *TuyaApiClient) 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 *TuyaApiClient) 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 *TuyaApiClient) 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("wss://%s/mqtt", loginResp.Result.Domain.MobileMqttsUrl) + c.expireTime = time.Now().Unix() + 2*24*60*60 // 2 days in seconds + + return nil +} + +func (c *TuyaApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl) + + data := TuyaApiWebRTCConfigRequest{ + DevId: c.deviceId, + ClientTraceId: fmt.Sprintf("%x", rand.Int63()), + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var webRTCConfigResponse TuyaApiWebRTCConfigResponse + 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 + } + + 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 *TuyaApiClient) 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 *TuyaApiClient) 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 +} \ No newline at end of file diff --git a/www/add.html b/www/add.html index f16d4d45..5a04cd96 100644 --- a/www/add.html +++ b/www/add.html @@ -28,7 +28,6 @@ } - @@ -283,14 +282,17 @@
-

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

-
- - -
- - @@ -298,91 +300,31 @@
From 3036dd7cfea384b0b2241f1a9918db4a5a0233df Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 18:36:21 +0200 Subject: [PATCH 031/241] refactor --- README.md | 16 +++++----- internal/tuya/tuya.go | 2 +- pkg/tuya/client.go | 6 ++-- pkg/tuya/{tuya_api.go => smart_api.go} | 42 +++++++++++++------------- 4 files changed, 33 insertions(+), 33 deletions(-) rename pkg/tuya/{tuya_api.go => smart_api.go} (92%) diff --git a/README.md b/README.md index 7a257ae0..a4992acd 100644 --- a/README.md +++ b/README.md @@ -568,14 +568,14 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. #### Source: Tuya -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Tuya API`. +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Cloud API` and `Tuya Smart API`. -The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Tuya API` does not require a cloud project and the cameras can be added through the interface via email/password. +The `Tuya Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Tuya Smart API` does not require a cloud project and the cameras can be added through the interface via email/password. -**Cloud API**: +**Tuya Cloud API**: - 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/). -**Open API**: +**Tuya Smart API**: - Smart Life accounts are not supported, you need to create a Tuya 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. **Configuring the stream:** @@ -585,19 +585,19 @@ The `Cloud API` requires setting up a cloud project in the Tuya Developer Platfo ```yaml streams: - # Cloud API: WebRTC main stream + # Tuya Cloud API: WebRTC main stream tuya_webrtc: - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX - # Cloud API: WebRTC sub stream + # 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 - # Tuya API: WebRTC main stream + # Tuya Smart API: WebRTC main stream tuya: - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX - # Tuya API: WebRTC sub stream + # Tuya Smart API: WebRTC sub stream tuya: - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd ``` diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go index cb25daa5..c44bdbbc 100644 --- a/internal/tuya/tuya.go +++ b/internal/tuya/tuya.go @@ -55,7 +55,7 @@ func apiTuya(w http.ResponseWriter, r *http.Request) { return } - tuyaAPI, err := tuya.NewTuyaApiClient( + tuyaAPI, err := tuya.NewTuyaSmartApiClient( httpClient, tuyaRegion.Host, email, diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index f5b964e9..50fc80b4 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -49,11 +49,11 @@ func Dial(rawURL string) (core.Producer, error) { query := u.Query() - // Tuya API + // Tuya Smart API email := query.Get("email") password := query.Get("password") - // Cloud API + // Tuya Cloud API uid := query.Get("uid") clientId := query.Get("client_id") clientSecret := query.Get("client_secret") @@ -80,7 +80,7 @@ func Dial(rawURL string) (core.Producer, error) { } if useTuyaApi { - if client.api, err = NewTuyaApiClient(nil, u.Hostname(), email, password, deviceId); err != nil { + if client.api, err = NewTuyaSmartApiClient(nil, u.Hostname(), email, password, deviceId); err != nil { return nil, fmt.Errorf("tuya: %w", err) } } else { diff --git a/pkg/tuya/tuya_api.go b/pkg/tuya/smart_api.go similarity index 92% rename from pkg/tuya/tuya_api.go rename to pkg/tuya/smart_api.go index 454efb46..bcd6bc9d 100644 --- a/pkg/tuya/tuya_api.go +++ b/pkg/tuya/smart_api.go @@ -121,12 +121,12 @@ type AppInfo struct { } type MQTTConfigResponse struct { - Result TuyaApiMQTTConfig `json:"result"` + Result SmartApiMQTTConfig `json:"result"` Success bool `json:"success"` Msg string `json:"errorMsg,omitempty"` } -type TuyaApiMQTTConfig struct { +type SmartApiMQTTConfig struct { Msid string `json:"msid"` Password string `json:"password"` } @@ -203,18 +203,18 @@ type Device struct { Uuid string `json:"uuid"` } -type TuyaApiWebRTCConfigRequest struct { +type SmartApiWebRTCConfigRequest struct { DevId string `json:"devId"` ClientTraceId string `json:"clientTraceId"` } -type TuyaApiWebRTCConfigResponse struct { - Result TuyaWebRTCConfig `json:"result"` +type SmartApiWebRTCConfigResponse struct { + Result SmartApiWebRTCConfig `json:"result"` Success bool `json:"success"` Msg string `json:"errorMsg,omitempty"` } -type TuyaWebRTCConfig struct { +type SmartApiWebRTCConfig struct { AudioAttributes AudioAttributes `json:"audioAttributes"` Auth string `json:"auth"` GatewayId string `json:"gatewayId"` @@ -234,7 +234,7 @@ type TuyaWebRTCConfig struct { VideoClarity int `json:"videoClarity"` } -type TuyaApiClient struct { +type TuyaSmartApiClient struct { TuyaClient email string @@ -259,7 +259,7 @@ var AvailableRegions = []Region{ {"india", "protect-in.ismartlife.me", "India", "IN"}, } -func NewTuyaApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaApiClient, error) { +func NewTuyaSmartApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaSmartApiClient, error) { var region *Region for _, r := range AvailableRegions { if r.Host == baseUrl { @@ -278,7 +278,7 @@ func NewTuyaApiClient(httpClient *http.Client, baseUrl, email, password, deviceI mqttClient := NewTuyaMqttClient(deviceId) - client := &TuyaApiClient{ + client := &TuyaSmartApiClient{ TuyaClient: TuyaClient{ httpClient: httpClient, mqtt: mqttClient, @@ -295,7 +295,7 @@ func NewTuyaApiClient(httpClient *http.Client, baseUrl, email, password, deviceI } // WebRTC Flow -func (c *TuyaApiClient) Init() error { +func (c *TuyaSmartApiClient) Init() error { if err := c.initToken(); err != nil { return fmt.Errorf("failed to initialize token: %w", err) } @@ -317,11 +317,11 @@ func (c *TuyaApiClient) Init() error { return nil } -func (c *TuyaApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { +func (c *TuyaSmartApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { return "", errors.New("not supported") } -func (c *TuyaApiClient) GetAppInfo() (*AppInfoResponse, error) { +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) @@ -341,7 +341,7 @@ func (c *TuyaApiClient) GetAppInfo() (*AppInfoResponse, error) { return &appInfoResponse, nil } -func (c *TuyaApiClient) GetHomeList() (*HomeListResponse, error) { +func (c *TuyaSmartApiClient) GetHomeList() (*HomeListResponse, error) { url := fmt.Sprintf("https://%s/api/new/common/homeList", c.baseUrl) body, err := c.request("POST", url, nil) @@ -361,7 +361,7 @@ func (c *TuyaApiClient) GetHomeList() (*HomeListResponse, error) { return &homeListResponse, nil } -func (c *TuyaApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) { +func (c *TuyaSmartApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) { url := fmt.Sprintf("https://%s/api/new/playback/shareList", c.baseUrl) body, err := c.request("POST", url, nil) @@ -381,7 +381,7 @@ func (c *TuyaApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) { return &sharedHomeListResponse, nil } -func (c *TuyaApiClient) GetRoomList(homeId string) (*RoomListResponse, error) { +func (c *TuyaSmartApiClient) GetRoomList(homeId string) (*RoomListResponse, error) { url := fmt.Sprintf("https://%s/api/new/common/roomList", c.baseUrl) data := RoomListRequest{ @@ -405,7 +405,7 @@ func (c *TuyaApiClient) GetRoomList(homeId string) (*RoomListResponse, error) { return &roomListResponse, nil } -func (c *TuyaApiClient) initToken() error { +func (c *TuyaSmartApiClient) initToken() error { tokenUrl := fmt.Sprintf("https://%s/api/login/token", c.baseUrl) tokenReq := LoginTokenRequest{ @@ -470,10 +470,10 @@ func (c *TuyaApiClient) initToken() error { return nil } -func (c *TuyaApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { +func (c *TuyaSmartApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl) - data := TuyaApiWebRTCConfigRequest{ + data := SmartApiWebRTCConfigRequest{ DevId: c.deviceId, ClientTraceId: fmt.Sprintf("%x", rand.Int63()), } @@ -483,7 +483,7 @@ func (c *TuyaApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { return nil, err } - var webRTCConfigResponse TuyaApiWebRTCConfigResponse + var webRTCConfigResponse SmartApiWebRTCConfigResponse err = json.Unmarshal(body, &webRTCConfigResponse) if err != nil { return nil, err @@ -524,7 +524,7 @@ func (c *TuyaApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { }, nil } -func (c *TuyaApiClient) loadHubConfig() (config *MQTTConfig, err error) { +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) @@ -552,7 +552,7 @@ func (c *TuyaApiClient) loadHubConfig() (config *MQTTConfig, err error) { }, nil } -func (c *TuyaApiClient) request(method string, url string, body any) ([]byte, error) { +func (c *TuyaSmartApiClient) request(method string, url string, body any) ([]byte, error) { var bodyReader io.Reader if body != nil { jsonBody, err := json.Marshal(body) From a2d422f5cb22d20b10793715bba447332637d535 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 18:42:24 +0200 Subject: [PATCH 032/241] format --- pkg/tuya/smart_api.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/tuya/smart_api.go b/pkg/tuya/smart_api.go index bcd6bc9d..5f4e74f9 100644 --- a/pkg/tuya/smart_api.go +++ b/pkg/tuya/smart_api.go @@ -122,8 +122,8 @@ type AppInfo struct { type MQTTConfigResponse struct { Result SmartApiMQTTConfig `json:"result"` - Success bool `json:"success"` - Msg string `json:"errorMsg,omitempty"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` } type SmartApiMQTTConfig struct { @@ -210,8 +210,8 @@ type SmartApiWebRTCConfigRequest struct { type SmartApiWebRTCConfigResponse struct { Result SmartApiWebRTCConfig `json:"result"` - Success bool `json:"success"` - Msg string `json:"errorMsg,omitempty"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` } type SmartApiWebRTCConfig struct { @@ -587,4 +587,4 @@ func (c *TuyaSmartApiClient) request(method string, url string, body any) ([]byt } return res, nil -} \ No newline at end of file +} From 9efc717633f7b2dc7d69fd4d29484c680e0f9ff6 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 20:18:13 +0200 Subject: [PATCH 033/241] ... --- pkg/tuya/client.go | 6 +++--- pkg/tuya/mqtt.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 50fc80b4..fbf6c31b 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -64,14 +64,14 @@ func Dial(rawURL string) (core.Producer, error) { // Stream params streamResolution := query.Get("resolution") - useTuyaApi := deviceId != "" && email != "" && password != "" + useSmartApi := deviceId != "" && email != "" && password != "" useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != "" if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { streamResolution = "hd" } - if !useTuyaApi && !useCloudApi { + if !useSmartApi && !useCloudApi { return nil, errors.New("tuya: wrong query params") } @@ -79,7 +79,7 @@ func Dial(rawURL string) (core.Producer, error) { handlers: make(map[uint32]func(*rtp.Packet)), } - if useTuyaApi { + if useSmartApi { if client.api, err = NewTuyaSmartApiClient(nil, u.Hostname(), email, password, deviceId); err != nil { return nil, fmt.Errorf("tuya: %w", err) } diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index deb90b9e..52f928a5 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -231,7 +231,7 @@ func (c *TuyaMqttClient) onMqttCandidate(msg *MqttMessage) { return } - // candidate from device start with "a=", end with "\r\n", which are not needed by Chrome webRTC + // fix candidates candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=") candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n") From c38c8a7fcedbe641c2bb6a2116f31a194648b55e Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 2 Jun 2025 22:23:23 +0300 Subject: [PATCH 034/241] readme --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a4992acd..4eb6c404 100644 --- a/README.md +++ b/README.md @@ -568,15 +568,16 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. #### Source: Tuya -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Cloud API` and `Tuya Smart API`. +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. The `Tuya Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Tuya Smart API` does not require a cloud project and the cameras can be added through the interface via email/password. +**Tuya Smart API (recommended)**: +- Smart Life accounts are not supported, you need to create a Tuya 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**: - 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/). - -**Tuya Smart API**: -- Smart Life accounts are not supported, you need to create a Tuya 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. +- 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): @@ -594,11 +595,11 @@ streams: - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd # Tuya Smart API: WebRTC main stream - tuya: + tuya_main: - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX # Tuya Smart API: WebRTC sub stream - tuya: + tuya_sub: - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd ``` From e44f1ad53e1e59ab59ddcfbdf3b74d4879751d7c Mon Sep 17 00:00:00 2001 From: seydx <34152761+seydx@users.noreply.github.com> Date: Tue, 3 Jun 2025 06:43:43 +0300 Subject: [PATCH 035/241] Update README.md Co-authored-by: Felipe Santos --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4eb6c404..29af3e17 100644 --- a/README.md +++ b/README.md @@ -570,12 +570,12 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. [Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. -The `Tuya Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Tuya Smart API` does not require a cloud project and the cameras can be added through the interface via email/password. - **Tuya Smart API (recommended)**: -- Smart Life accounts are not supported, you need to create a Tuya 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. +- 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). From 3149b6f750bf803c6997702d9690223317ced259 Mon Sep 17 00:00:00 2001 From: seydx <34152761+seydx@users.noreply.github.com> Date: Tue, 3 Jun 2025 06:44:03 +0300 Subject: [PATCH 036/241] Update README.md Co-authored-by: Felipe Santos --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 29af3e17..47482652 100644 --- a/README.md +++ b/README.md @@ -586,6 +586,14 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. ```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 @@ -593,14 +601,6 @@ streams: # 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 - - # Tuya Smart API: WebRTC main stream - tuya_main: - - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX - - # Tuya Smart API: WebRTC sub stream - tuya_sub: - - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd ``` #### Source: GoPro From 60b6b93ff8e6a3d4d8036056029244c8d77032cb Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 8 Jun 2025 20:34:25 +0200 Subject: [PATCH 037/241] fix concurrent writes and improve mqtt --- pkg/tuya/client.go | 49 ++++++++++++++++++++++++++++++++++------------ pkg/tuya/mqtt.go | 6 +++++- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index fbf6c31b..277848e7 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "regexp" + "sync" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" @@ -24,6 +25,7 @@ type Client struct { isHEVC bool connected core.Waiter closed bool + handlersMu sync.RWMutex handlers map[uint32]func(*rtp.Packet) } @@ -222,7 +224,7 @@ func Dial(rawURL string) (core.Producer, error) { return } - if handler, ok := client.handlers[packet.SSRC]; ok { + if handler, ok := client.getHandler(packet.SSRC); ok { handler(packet) } } @@ -368,16 +370,20 @@ func (c *Client) Start() error { } } - c.handlers[c.videoSSRC] = func(packet *rtp.Packet) { - if video != nil { - video.WriteRTP(packet) - } + if c.videoSSRC != 0 { + c.setHandler(c.videoSSRC, func(packet *rtp.Packet) { + if video != nil { + video.WriteRTP(packet) + } + }) } - c.handlers[c.audioSSRC] = func(packet *rtp.Packet) { - if audio != nil { - audio.WriteRTP(packet) - } + if c.audioSSRC != 0 { + c.setHandler(c.audioSSRC, func(packet *rtp.Packet) { + if audio != nil { + audio.WriteRTP(packet) + } + }) } return c.conn.Start() @@ -390,9 +396,7 @@ func (c *Client) Stop() error { c.closed = true - for ssrc := range c.handlers { - delete(c.handlers, ssrc) - } + c.clearHandlers() if c.conn != nil { _ = c.conn.Stop() @@ -414,6 +418,27 @@ 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)) diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 52f928a5..e5565487 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -109,7 +109,11 @@ func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig SetUsername(hubConfig.Username). SetPassword(hubConfig.Password). SetOnConnectHandler(c.onConnect). - SetConnectTimeout(10 * time.Second) + SetAutoReconnect(true). + SetMaxReconnectInterval(30 * time.Second). + SetConnectTimeout(15 * time.Second). + SetKeepAlive(30 * time.Second). + SetPingTimeout(15 * time.Second) c.client = mqtt.NewClient(opts) From 47e87281a1e494df8a55ef7cbfc46f97abbc45f8 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 8 Jun 2025 20:38:25 +0200 Subject: [PATCH 038/241] increase timeout --- pkg/tuya/mqtt.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index e5565487..cdf1e8dd 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -111,9 +111,9 @@ func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig SetOnConnectHandler(c.onConnect). SetAutoReconnect(true). SetMaxReconnectInterval(30 * time.Second). - SetConnectTimeout(15 * time.Second). + SetConnectTimeout(30 * time.Second). SetKeepAlive(30 * time.Second). - SetPingTimeout(15 * time.Second) + SetPingTimeout(20 * time.Second) c.client = mqtt.NewClient(opts) From b58c1a7ed6c498367edbdb17847e9542c7eb3a24 Mon Sep 17 00:00:00 2001 From: seydx <34152761+seydx@users.noreply.github.com> Date: Thu, 12 Jun 2025 06:37:46 +0200 Subject: [PATCH 039/241] Update README.md Co-authored-by: Felipe Santos --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 47482652..efafd260 100644 --- a/README.md +++ b/README.md @@ -572,7 +572,7 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. **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. +- **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. From 30c418542ceedb375bf61a9186cb5e3a750f7e23 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 02:06:27 +0200 Subject: [PATCH 040/241] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index efafd260..5446a091 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ Supported sources: - [Hikvision ISAPI](#source-isapi) cameras - [Roborock vacuums](#source-roborock) models with cameras - [Exec](#source-exec) audio on server +- [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)). From da68101c097d3b696fd8461030f3b4f1d8ab6dae Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 14:58:31 +0200 Subject: [PATCH 041/241] Optimize URL decoding and update MQTT keep-alive --- pkg/tuya/client.go | 8 ++++++-- pkg/tuya/mqtt.go | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 277848e7..c6a34d6e 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "regexp" + "strings" "sync" "github.com/AlexxIT/go2rtc/pkg/core" @@ -44,7 +45,8 @@ type RecvMessage struct { } func Dial(rawURL string) (core.Producer, error) { - u, err := url.Parse(rawURL) + escapedURL := strings.ReplaceAll(rawURL, "#", "%23") + u, err := url.Parse(escapedURL) if err != nil { return nil, err } @@ -277,8 +279,10 @@ func Dial(rawURL string) (core.Producer, error) { } client.connected.Done(nil) } - default: + case pion.PeerConnectionStateClosed: client.Close(errors.New("webrtc: " + msg.String())) + default: + // client.Close(errors.New("webrtc: " + msg.String())) } } }) diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index cdf1e8dd..0559e8bc 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -112,7 +112,7 @@ func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig SetAutoReconnect(true). SetMaxReconnectInterval(30 * time.Second). SetConnectTimeout(30 * time.Second). - SetKeepAlive(30 * time.Second). + SetKeepAlive(60 * time.Second). SetPingTimeout(20 * time.Second) c.client = mqtt.NewClient(opts) From c5dfa84ff2a593f786e8df9bb2a293990abdc9f4 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 19 Jun 2025 10:29:27 +0200 Subject: [PATCH 042/241] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5446a091..d40ff472 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ Available source types: - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR - [tapo](#source-tapo) - TP-Link Tapo 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 From e9611769bea1e821c0fc9eded3a0434114769e71 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 10 Jul 2025 16:12:07 +0200 Subject: [PATCH 043/241] deps --- go.mod | 1 + go.sum | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/go.mod b/go.mod index 7abf1edd..af372221 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( require ( github.com/asticode/go-astikit v0.56.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/pion/datachannel v1.5.10 // indirect diff --git a/go.sum b/go.sum index 4dfebcf6..e14b77d9 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,7 @@ github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwf github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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= @@ -87,6 +88,7 @@ github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -98,6 +100,8 @@ github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfU github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= @@ -133,5 +137,6 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 6db4dda53539f20211e5e95bbb9d617a19aeef5f Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 23 Oct 2025 14:42:38 +0300 Subject: [PATCH 044/241] Fix onvif client for some cameras --- pkg/onvif/client.go | 89 +++++++++++++++++++++++--------------------- pkg/onvif/helpers.go | 12 ++++++ 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go index 77bbe0ff..a2358e68 100644 --- a/pkg/onvif/client.go +++ b/pkg/onvif/client.go @@ -1,12 +1,14 @@ package onvif import ( + "bufio" "bytes" "errors" "fmt" "html" "io" "net" + "net/http" "net/url" "regexp" "strings" @@ -32,26 +34,18 @@ func NewClient(rawURL string) (*Client, error) { baseURL := "http://" + u.Host client := &Client{url: u} - if u.Path == "" { - client.deviceURL = baseURL + PathDevice - } else { - client.deviceURL = baseURL + u.Path - } + client.deviceURL = baseURL + GetPath(u.Path, PathDevice) b, err := client.DeviceRequest(DeviceGetCapabilities) if err != nil { return nil, err } - client.mediaURL = FindTagValue(b, "Media.+?XAddr") - if client.mediaURL == "" { - client.mediaURL = baseURL + "/onvif/media_service" - } + s := FindTagValue(b, "Media.+?XAddr") + client.mediaURL = baseURL + GetPath(s, "/onvif/media_service") - client.imaginURL = FindTagValue(b, "Imaging.+?XAddr") - if client.imaginURL == "" { - client.imaginURL = baseURL + "/onvif/imaging_service" - } + s = FindTagValue(b, "Imaging.+?XAddr") + client.imaginURL = baseURL + GetPath(s, "/onvif/imaging_service") return client, nil } @@ -188,9 +182,6 @@ func (c *Client) Request(rawUrl, body string) ([]byte, error) { return nil, errors.New("onvif: unsupported service") } - e := NewEnvelopeWithUser(c.url.User) - e.Append(body) - u, err := url.Parse(rawUrl) if err != nil { return nil, err @@ -201,44 +192,58 @@ func (c *Client) Request(rawUrl, body string) ([]byte, error) { host += ":80" } - conn, err := net.DialTimeout("tcp", host, 5*time.Second) + const timeout = 5 * time.Second + + conn, err := net.DialTimeout("tcp", host, timeout) if err != nil { return nil, err } defer conn.Close() - reqBody := e.Bytes() - rawReq := fmt.Appendf(nil, "POST %s HTTP/1.1\r\n"+ - "Host: %s\r\n"+ - "Content-Type: application/soap+xml;charset=utf-8\r\n"+ - "Content-Length: %d\r\n"+ - "Connection: close\r\n"+ - "\r\n", u.Path, u.Host, len(reqBody)) - rawReq = append(rawReq, reqBody...) + e := NewEnvelopeWithUser(c.url.User) + e.Append(body) + buf := e.Bytes() - if _, err = conn.Write(rawReq); err != nil { + req := &http.Request{ + Method: "POST", + URL: u, + Proto: "HTTP/1.1", + Header: http.Header{"Content-Type": {"application/soap+xml;charset=utf-8"}}, + Body: io.NopCloser(bytes.NewReader(buf)), + ContentLength: int64(len(buf)), + Close: true, + } + + _ = conn.SetWriteDeadline(time.Now().Add(timeout)) + if err = req.Write(conn); err != nil { return nil, err } - rawRes, err := io.ReadAll(conn) + rd := bufio.NewReaderSize(conn, 16*1024) + + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + res, err := http.ReadResponse(rd, req) if err != nil { - return nil, err - } - - // Look for XML in complete response - if i := bytes.Index(rawRes, []byte(" 0 { - return rawRes[i:], nil - } - - // No XML found - might be an error response - if i := bytes.Index(rawRes, []byte("\r\n\r\n")); i > 0 { - if bytes.Contains(rawRes[:i], []byte("chunked")) { - return nil, errors.New("onvif: TODO: support chunked encoding") + // Try to fix broken response https://github.com/AlexxIT/go2rtc/pull/1589 + if buf, err = io.ReadAll(rd); err != nil { + return nil, err } - // Return body after headers - return rawRes[i+4:], nil + // Look for XML in complete response + if i := bytes.Index(buf, []byte(" 0 { + return buf[i:], nil + } + + return nil, fmt.Errorf("onvif: broken response: %.100s", buf) } - return rawRes, nil + if res.StatusCode != http.StatusOK { + return nil, errors.New("onvif: wrong response " + res.Status) + } + + if buf, err = io.ReadAll(res.Body); err != nil { + return nil, err + } + + return buf, nil } diff --git a/pkg/onvif/helpers.go b/pkg/onvif/helpers.go index f240f2ec..893beb00 100644 --- a/pkg/onvif/helpers.go +++ b/pkg/onvif/helpers.go @@ -3,6 +3,7 @@ package onvif import ( "fmt" "net" + "net/url" "regexp" "strconv" "strings" @@ -129,3 +130,14 @@ func GetPosixTZ(current time.Time) string { return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60) } + +func GetPath(urlOrPath, defPath string) string { + if urlOrPath == "" || urlOrPath[0] == '/' { + return defPath + } + u, err := url.Parse(urlOrPath) + if err != nil { + return defPath + } + return GetPath(u.Path, defPath) +} From cc97bc33c4399c7979d13702b048db3fd1392c0f Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 24 Oct 2025 17:28:49 +0300 Subject: [PATCH 045/241] Restore simple onvif client logic --- pkg/onvif/client.go | 66 +++++---------------------------------------- 1 file changed, 7 insertions(+), 59 deletions(-) diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go index a2358e68..bad103c7 100644 --- a/pkg/onvif/client.go +++ b/pkg/onvif/client.go @@ -1,13 +1,10 @@ package onvif import ( - "bufio" "bytes" "errors" - "fmt" "html" "io" - "net" "net/http" "net/url" "regexp" @@ -177,73 +174,24 @@ func (c *Client) MediaRequest(operation string) ([]byte, error) { return c.Request(c.mediaURL, operation) } -func (c *Client) Request(rawUrl, body string) ([]byte, error) { - if rawUrl == "" { +func (c *Client) Request(url, body string) ([]byte, error) { + if url == "" { return nil, errors.New("onvif: unsupported service") } - u, err := url.Parse(rawUrl) - if err != nil { - return nil, err - } - - host := u.Host - if u.Port() == "" { - host += ":80" - } - - const timeout = 5 * time.Second - - conn, err := net.DialTimeout("tcp", host, timeout) - if err != nil { - return nil, err - } - defer conn.Close() - e := NewEnvelopeWithUser(c.url.User) e.Append(body) - buf := e.Bytes() - req := &http.Request{ - Method: "POST", - URL: u, - Proto: "HTTP/1.1", - Header: http.Header{"Content-Type": {"application/soap+xml;charset=utf-8"}}, - Body: io.NopCloser(bytes.NewReader(buf)), - ContentLength: int64(len(buf)), - Close: true, - } - - _ = conn.SetWriteDeadline(time.Now().Add(timeout)) - if err = req.Write(conn); err != nil { + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes())) + if err != nil { return nil, err } - - rd := bufio.NewReaderSize(conn, 16*1024) - - _ = conn.SetReadDeadline(time.Now().Add(timeout)) - res, err := http.ReadResponse(rd, req) - if err != nil { - // Try to fix broken response https://github.com/AlexxIT/go2rtc/pull/1589 - if buf, err = io.ReadAll(rd); err != nil { - return nil, err - } - - // Look for XML in complete response - if i := bytes.Index(buf, []byte(" 0 { - return buf[i:], nil - } - - return nil, fmt.Errorf("onvif: broken response: %.100s", buf) - } + defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, errors.New("onvif: wrong response " + res.Status) } - if buf, err = io.ReadAll(res.Body); err != nil { - return nil, err - } - - return buf, nil + return io.ReadAll(res.Body) } From 9f407a754dafecd05aed9709b6503b24d1a15fb7 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 24 Oct 2025 17:54:37 +0300 Subject: [PATCH 046/241] Fix tapo source for some cameras #1918 --- pkg/tapo/client.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go index c19267ff..0652140e 100644 --- a/pkg/tapo/client.go +++ b/pkg/tapo/client.go @@ -140,6 +140,12 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri username = "admin" } + if strings.Contains(exchange, `username="none"`) { + // https://nvd.nist.gov/vuln/detail/CVE-2022-37255 + username = "none" + password = "TPL075526460603" + } + key := md5.Sum([]byte(nonce + ":" + password)) iv := md5.Sum([]byte(username + ":" + nonce)) From 7254bd4fbc53b2dd7fc1bf9de05be2e975e4b672 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 24 Oct 2025 17:54:55 +0300 Subject: [PATCH 047/241] Code refactoring for tapo source --- pkg/tapo/client.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go index 0652140e..be669eb5 100644 --- a/pkg/tapo/client.go +++ b/pkg/tapo/client.go @@ -164,8 +164,9 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri cbc.CryptBlocks(b, b) // unpad - padSize := int(b[len(b)-1]) - return b[:len(b)-padSize] + n := len(b) + padSize := int(b[n-1]) + return b[:n-padSize] } } @@ -298,12 +299,12 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http. return nil, nil, err } _, _ = io.Copy(io.Discard, res.Body) // discard leftovers - _ = res.Body.Close() // ignore response body + _ = res.Body.Close() // ignore response body auth := res.Header.Get("WWW-Authenticate") if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") { - return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode) + return nil, nil, errors.New("tapo: wrond status: " + res.Status) } if brand == "tapo" && password == "" { From 994e0dc526ab9cddd3685805df39c42143ae027c Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 21 Oct 2025 12:26:24 +0300 Subject: [PATCH 048/241] Improve homekit tlv8 parsing --- pkg/hap/client.go | 4 ++-- pkg/hap/client_pairing.go | 12 ++++++------ pkg/hap/server.go | 5 ++--- pkg/hap/server_pairing.go | 28 +++++++++++++++++++++------- pkg/hap/tlv8/tlv8.go | 13 +++++++++++-- pkg/homekit/server.go | 2 +- 6 files changed, 43 insertions(+), 21 deletions(-) diff --git a/pkg/hap/client.go b/pkg/hap/client.go index 2c1f7dd3..2801dd9f 100644 --- a/pkg/hap/client.go +++ b/pkg/hap/client.go @@ -124,7 +124,7 @@ func (c *Client) Dial() (err error) { EncryptedData string `tlv8:"5"` State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(res.Body, &cipherM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM2); err != nil { return err } if cipherM2.State != StateM2 { @@ -209,7 +209,7 @@ func (c *Client) Dial() (err error) { var plainM4 struct { State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil { return } if plainM4.State != StateM4 { diff --git a/pkg/hap/client_pairing.go b/pkg/hap/client_pairing.go index baec7be5..a58526d9 100644 --- a/pkg/hap/client_pairing.go +++ b/pkg/hap/client_pairing.go @@ -107,7 +107,7 @@ func (c *Client) Pair(feature, pin string) (err error) { State byte `tlv8:"6"` Error byte `tlv8:"7"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return } if plainM2.State != StateM2 { @@ -159,7 +159,7 @@ func (c *Client) Pair(feature, pin string) (err error) { EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices) } - if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil { return } if plainM4.State != StateM4 { @@ -232,7 +232,7 @@ func (c *Client) Pair(feature, pin string) (err error) { State byte `tlv8:"6"` Error byte `tlv8:"7"` }{} - if err = tlv8.UnmarshalReader(res.Body, &cipherM6); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM6); err != nil { return } if cipherM6.State != StateM6 || cipherM6.Error != 0 { @@ -296,7 +296,7 @@ func (c *Client) ListPairings() error { State byte `tlv8:"6"` Permission byte `tlv8:"11"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return err } @@ -329,7 +329,7 @@ func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) e State byte `tlv8:"6"` Unknown byte `tlv8:"7"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return err } @@ -354,7 +354,7 @@ func (c *Client) DeletePairing(id string) error { var plainM2 struct { State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return err } if plainM2.State != StateM2 { diff --git a/pkg/hap/server.go b/pkg/hap/server.go index 2a912324..a71ab7aa 100644 --- a/pkg/hap/server.go +++ b/pkg/hap/server.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "errors" "fmt" - "io" "net" "net/http" @@ -55,7 +54,7 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co PublicKey string `tlv8:"3"` State byte `tlv8:"6"` } - if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil { + if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { return err } if plainM1.State != StateM1 { @@ -125,7 +124,7 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co EncryptedData string `tlv8:"5"` State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(req.Body, &cipherM3); err != nil { + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil { return err } if cipherM3.State != StateM3 { diff --git a/pkg/hap/server_pairing.go b/pkg/hap/server_pairing.go index 77895c10..571ba7a2 100644 --- a/pkg/hap/server_pairing.go +++ b/pkg/hap/server_pairing.go @@ -5,7 +5,6 @@ import ( "crypto/sha512" "errors" "fmt" - "io" "net" "net/http" @@ -25,18 +24,33 @@ const ( PairMethodList ) -func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error { - if req.Header.Get("Content-Type") != MimeTLV8 { - return errors.New("hap: wrong content type") +func (s *Server) HandleConn(conn net.Conn) error { + rd := bufio.NewReader(conn) + req, err := http.ReadRequest(rd) + if err != nil { + return err } + rw := bufio.NewReadWriter(rd, bufio.NewWriter(conn)) + + switch req.RequestURI { + case PathPairSetup: + return s.PairSetup(req, rw, conn) + case PathPairVerify: + return s.PairVerify(req, rw, conn) + } + + return errors.New("hap: unsupported request uri: " + req.RequestURI) +} + +func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error { // STEP 1. Request from iPhone var plainM1 struct { Method byte `tlv8:"0"` State byte `tlv8:"6"` Flags uint32 `tlv8:"19"` } - if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil { + if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { return err } if plainM1.State != StateM1 { @@ -87,7 +101,7 @@ func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Con Proof string `tlv8:"4"` State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(req.Body, &plainM3); err != nil { + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil { return err } if plainM3.State != StateM3 { @@ -129,7 +143,7 @@ func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Con EncryptedData string `tlv8:"5"` State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(req.Body, &cipherM5); err != nil { + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil { return err } if cipherM5.State != StateM5 { diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 7af27ea4..6efe20a6 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -170,11 +170,20 @@ func UnmarshalBase64(in any, out any) error { return Unmarshal(data, out) } -func UnmarshalReader(r io.Reader, v any) error { - data, err := io.ReadAll(r) +func UnmarshalReader(r io.Reader, n int64, v any) error { + var data []byte + var err error + + if n > 0 { + data = make([]byte, n) + _, err = io.ReadFull(r, data) + } else { + data, err = io.ReadAll(r) + } if err != nil { return err } + return Unmarshal(data, v) } diff --git a/pkg/homekit/server.go b/pkg/homekit/server.go index 20cfc59d..2d00deab 100644 --- a/pkg/homekit/server.go +++ b/pkg/homekit/server.go @@ -139,7 +139,7 @@ func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Re Permissions byte `tlv8:"11"` }{} - if err := tlv8.UnmarshalReader(req.Body, &cmd); err != nil { + if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &cmd); err != nil { return nil, err } From ff18283d11c072075d5237256868b53f8b165e0d Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 21 Oct 2025 12:27:38 +0300 Subject: [PATCH 049/241] Improve homekit secure conn buffers --- pkg/hap/client.go | 4 +++- pkg/hap/secure/secure.go | 19 ++++++++----------- pkg/hap/server.go | 2 +- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/pkg/hap/client.go b/pkg/hap/client.go index 2801dd9f..bde85277 100644 --- a/pkg/hap/client.go +++ b/pkg/hap/client.go @@ -216,8 +216,10 @@ func (c *Client) Dial() (err error) { return newResponseError(cipherM3, plainM4) } + rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn)) + // like tls.Client wrapper over net.Conn - if c.Conn, err = secure.Client(c.Conn, sessionShared, true); err != nil { + if c.Conn, err = secure.Client(c.Conn, rw, sessionShared, true); err != nil { return } // new reader for new conn diff --git a/pkg/hap/secure/secure.go b/pkg/hap/secure/secure.go index 576ee127..a42c7dea 100644 --- a/pkg/hap/secure/secure.go +++ b/pkg/hap/secure/secure.go @@ -14,9 +14,7 @@ import ( type Conn struct { conn net.Conn - - rd *bufio.Reader - wr *bufio.Writer + rw *bufio.ReadWriter encryptKey []byte decryptKey []byte @@ -26,7 +24,7 @@ type Conn struct { SharedKey []byte } -func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { +func Client(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) { key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key") if err != nil { return nil, err @@ -39,8 +37,7 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { c := &Conn{ conn: conn, - rd: bufio.NewReaderSize(conn, 32*1024), - wr: bufio.NewWriterSize(conn, 32*1024), + rw: rw, SharedKey: sharedKey, } @@ -69,14 +66,14 @@ func (c *Conn) Read(b []byte) (n int, err error) { } verify := make([]byte, 2) // verify = plain message size - if _, err = io.ReadFull(c.rd, verify); err != nil { + if _, err = io.ReadFull(c.rw, verify); err != nil { return } n = int(binary.LittleEndian.Uint16(verify)) ciphertext := make([]byte, n+Overhead) - if _, err = io.ReadFull(c.rd, ciphertext); err != nil { + if _, err = io.ReadFull(c.rw, ciphertext); err != nil { return } @@ -100,7 +97,7 @@ func (c *Conn) Write(b []byte) (n int, err error) { } binary.LittleEndian.PutUint16(verify, uint16(size)) - if _, err = c.wr.Write(verify); err != nil { + if _, err = c.rw.Write(verify); err != nil { return } @@ -112,7 +109,7 @@ func (c *Conn) Write(b []byte) (n int, err error) { return } - if _, err = c.wr.Write(buf[:size+Overhead]); err != nil { + if _, err = c.rw.Write(buf[:size+Overhead]); err != nil { return } @@ -120,7 +117,7 @@ func (c *Conn) Write(b []byte) (n int, err error) { n += size } - err = c.wr.Flush() + err = c.rw.Flush() return } diff --git a/pkg/hap/server.go b/pkg/hap/server.go index a71ab7aa..99c86f6b 100644 --- a/pkg/hap/server.go +++ b/pkg/hap/server.go @@ -166,7 +166,7 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co return err } - if conn, err = secure.Client(conn, sessionShared, false); err != nil { + if conn, err = secure.Client(conn, rw, sessionShared, false); err != nil { return err } From 863174839cd1135922c2c0a2c36fcb6d914cf524 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 26 Oct 2025 16:39:59 +0100 Subject: [PATCH 050/241] Fix video/audio ssrc and low power cameras --- pkg/tuya/client.go | 18 ++++---- pkg/tuya/cloud_api.go | 7 +++ pkg/tuya/interface.go | 9 ++-- pkg/tuya/mqtt.go | 105 ++++++++++++++++++++++++++++++++++++------ pkg/tuya/smart_api.go | 7 +++ 5 files changed, 122 insertions(+), 24 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index c6a34d6e..d1a549a8 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -20,8 +20,8 @@ type Client struct { conn *webrtc.Conn pc *pion.PeerConnection dc *pion.DataChannel - videoSSRC uint32 - audioSSRC uint32 + videoSSRC *uint32 + audioSSRC *uint32 streamType int isHEVC bool connected core.Waiter @@ -374,16 +374,16 @@ func (c *Client) Start() error { } } - if c.videoSSRC != 0 { - c.setHandler(c.videoSSRC, func(packet *rtp.Packet) { + if c.videoSSRC != nil { + c.setHandler(*c.videoSSRC, func(packet *rtp.Packet) { if video != nil { video.WriteRTP(packet) } }) } - if c.audioSSRC != 0 { - c.setHandler(c.audioSSRC, func(packet *rtp.Packet) { + if c.audioSSRC != nil { + c.setHandler(*c.audioSSRC, func(packet *rtp.Packet) { if audio != nil { audio.WriteRTP(packet) } @@ -469,8 +469,10 @@ func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { return false, err } - c.videoSSRC = recvMessage.Video.SSRC - c.audioSSRC = recvMessage.Audio.SSRC + videoSSRC := recvMessage.Video.SSRC + audioSSRC := recvMessage.Audio.SSRC + c.videoSSRC = &videoSSRC + c.audioSSRC = &audioSSRC completeMsg, _ := json.Marshal(DataChannelMessage{ Type: "complete", diff --git a/pkg/tuya/cloud_api.go b/pkg/tuya/cloud_api.go index 4d25c2be..c34d0fe4 100644 --- a/pkg/tuya/cloud_api.go +++ b/pkg/tuya/cloud_api.go @@ -117,6 +117,10 @@ func (c *TuyaCloudApiClient) Init() error { return fmt.Errorf("failed to start MQTT: %w", err) } + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + return nil } @@ -212,6 +216,9 @@ func (c *TuyaCloudApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { 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 diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go index f4f530aa..74fcd585 100644 --- a/pkg/tuya/interface.go +++ b/pkg/tuya/interface.go @@ -31,6 +31,7 @@ type TuyaClient struct { baseUrl string expireTime int64 deviceId string + localKey string skill *Skill iceServers []pionWebrtc.ICEServer } @@ -74,15 +75,17 @@ type VideoSkill struct { } type Skill struct { - WebRTC int `json:"webrtc"` - Audios []AudioSkill `json:"audios"` - Videos []VideoSkill `json:"videos"` + WebRTC int `json:"webrtc"` + LowPower int `json:"lowPower,omitempty"` + 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"` diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 0559e8bc..79a30102 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -1,8 +1,11 @@ package tuya import ( + "encoding/hex" "encoding/json" + "errors" "fmt" + "hash/crc32" "strings" "time" @@ -13,6 +16,7 @@ import ( type TuyaMqttClient struct { client mqtt.Client waiter core.Waiter + wakeupWaiter core.Waiter publishTopic string subscribeTopic string auth string @@ -75,6 +79,15 @@ type DisconnectFrame struct { Mode string `json:"mode"` } +// {"protocol":4,"t":1761487814,"data":{"dps":{"152":"0","160":1,"170":false}}} +type DPSMessage struct { + Protocol int `json:"protocol"` + T int `json:"t"` + Data struct { + Dps map[string]interface{} `json:"dps"` + } `json:"data"` +} + type MqttMessage struct { Protocol int `json:"protocol"` Pv string `json:"pv"` @@ -84,9 +97,10 @@ type MqttMessage struct { func NewTuyaMqttClient(deviceId string) *TuyaMqttClient { return &TuyaMqttClient{ - deviceId: deviceId, - sessionId: core.RandString(6, 62), - waiter: core.Waiter{}, + deviceId: deviceId, + sessionId: core.RandString(6, 62), + waiter: core.Waiter{}, + wakeupWaiter: core.Waiter{}, } } @@ -129,26 +143,70 @@ func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig } func (c *TuyaMqttClient) Stop() { + c.closed = true + c.waiter.Done(errors.New("mqtt: stopped")) + c.wakeupWaiter.Done(errors.New("mqtt: stopped")) + if c.client != nil { _ = c.SendDisconnect() c.client.Disconnect(1000) } } -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" { - streamType = 0 - } else { - streamType = 1 +func (c *TuyaMqttClient) WakeUp(localKey string) error { + // Calculate CRC32 of localKey + 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: smart/decrypt/in/{deviceId} + 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 { + // 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: streamType, + StreamType: mqttStreamType, Auth: c.auth, DatachannelEnable: isHEVC, }) @@ -189,7 +247,7 @@ func (c *TuyaMqttClient) SendDisconnect() error { } func (c *TuyaMqttClient) onConnect(client mqtt.Client) { - if token := client.Subscribe(c.subscribeTopic, 1, c.consume); token.Wait() && token.Error() != nil { + if token := client.Subscribe(c.subscribeTopic, 1, c.onMessage); token.Wait() && token.Error() != nil { c.waiter.Done(token.Error()) return } @@ -197,7 +255,7 @@ func (c *TuyaMqttClient) onConnect(client mqtt.Client) { c.waiter.Done(nil) } -func (c *TuyaMqttClient) consume(client mqtt.Client, msg mqtt.Message) { +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) @@ -218,6 +276,27 @@ func (c *TuyaMqttClient) consume(client mqtt.Client, msg mqtt.Message) { } } +func (c *TuyaMqttClient) onLowPowerMessage(client mqtt.Client, msg mqtt.Message) { + var message DPSMessage + if err := json.Unmarshal(msg.Payload(), &message); err != nil { + return + } + + // Check if protocol is 4 and dps[149] is true (motion detection = camera ready) + 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 or debugging. + c.wakeupWaiter.Done(nil) + } + } + } +} + func (c *TuyaMqttClient) onMqttAnswer(msg *MqttMessage) { var answerFrame AnswerFrame if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { diff --git a/pkg/tuya/smart_api.go b/pkg/tuya/smart_api.go index 5f4e74f9..3c96fe98 100644 --- a/pkg/tuya/smart_api.go +++ b/pkg/tuya/smart_api.go @@ -314,6 +314,10 @@ func (c *TuyaSmartApiClient) Init() error { return fmt.Errorf("failed to start MQTT: %w", err) } + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + return nil } @@ -498,6 +502,9 @@ func (c *TuyaSmartApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { return nil, err } + // Store LocalKey + c.localKey = webRTCConfigResponse.Result.LocalKey + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) if err != nil { return nil, err From fb8c6e1b1ba1958a941649132b36953514800d33 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 26 Oct 2025 22:21:56 +0100 Subject: [PATCH 051/241] Update comments --- pkg/tuya/mqtt.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 79a30102..cf36e09c 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -79,12 +79,18 @@ type DisconnectFrame struct { Mode string `json:"mode"` } -// {"protocol":4,"t":1761487814,"data":{"dps":{"152":"0","160":1,"170":false}}} -type DPSMessage struct { - Protocol int `json:"protocol"` - T int `json:"t"` +type MqttLowPowerMessage struct { + Protocol int `json:"protocol"` + T int `json:"t"` + S int `json:"s,omitempty"` + Type string `json:"type,omitempty"` Data struct { - Dps map[string]interface{} `json:"dps"` + 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"` } @@ -277,12 +283,13 @@ func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) { } func (c *TuyaMqttClient) onLowPowerMessage(client mqtt.Client, msg mqtt.Message) { - var message DPSMessage + var message MqttLowPowerMessage if err := json.Unmarshal(msg.Payload(), &message); err != nil { return } - // Check if protocol is 4 and dps[149] is true (motion detection = camera ready) + // 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 { @@ -290,7 +297,7 @@ func (c *TuyaMqttClient) onLowPowerMessage(client mqtt.Client, msg mqtt.Message) // 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 or debugging. + // This waiter is kept for potential future use. c.wakeupWaiter.Done(nil) } } From 721ed98afb07d043b40d71e0c4463214217884f4 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 27 Oct 2025 21:47:52 +0100 Subject: [PATCH 052/241] webrtc: export GetSenderTrack --- pkg/webrtc/conn.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 092b05c8..924fd550 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -161,16 +161,7 @@ func (c *Conn) AddCandidate(candidate string) error { return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate}) } -func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { - for _, tr := range c.pc.GetTransceivers() { - if tr.Mid() == mid { - return tr - } - } - return nil -} - -func (c *Conn) getSenderTrack(mid string) *Track { +func (c *Conn) GetSenderTrack(mid string) *Track { if tr := c.getTranseiver(mid); tr != nil { if s := tr.Sender(); s != nil { if t := s.Track().(*Track); t != nil { @@ -181,6 +172,15 @@ func (c *Conn) getSenderTrack(mid string) *Track { return nil } +func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { + for _, tr := range c.pc.GetTransceivers() { + if tr.Mid() == mid { + return tr + } + } + return nil +} + func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) { for _, tr := range c.pc.GetTransceivers() { // search Transeiver for this TrackRemote From 8142d2fc431031bc851e6e6afc7fe2f08a8fc63f Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 27 Oct 2025 21:48:15 +0100 Subject: [PATCH 053/241] Refactor RepackG711 to use configurable packet size --- pkg/pcm/handlers.go | 15 +++++++++------ pkg/rtsp/consumer.go | 2 +- pkg/webrtc/consumer.go | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/pcm/handlers.go b/pkg/pcm/handlers.go index 18a96468..7eab1e15 100644 --- a/pkg/pcm/handlers.go +++ b/pkg/pcm/handlers.go @@ -12,8 +12,11 @@ import ( // 1. Fixes WebRTC audio quality issue (monotonic timestamp) // 2. Fixes Reolink Doorbell backchannel issue (zero timestamp) // https://github.com/AlexxIT/go2rtc/issues/331 -func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { - const PacketSize = 1024 +func RepackG711(zeroTS bool, size int, handler core.HandlerFunc) core.HandlerFunc { + packetSize := 1024 + if size > 0 { + packetSize = size + } var buf []byte var seq uint16 @@ -26,7 +29,7 @@ func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { mu.Lock() buf = append(buf, packet.Payload...) - if len(buf) < PacketSize { + if len(buf) < packetSize { mu.Unlock() return } @@ -39,7 +42,7 @@ func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { SequenceNumber: seq, SSRC: packet.SSRC, }, - Payload: buf[:PacketSize], + Payload: buf[:packetSize], } seq++ @@ -48,10 +51,10 @@ func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { // don't have this strange devices for tests if !zeroTS { pkt.Timestamp = ts - ts += PacketSize + ts += uint32(packetSize) } - buf = buf[PacketSize:] + buf = buf[packetSize:] mu.Unlock() diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index e6525d96..8fab94f3 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -56,7 +56,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv if c.mode == core.ModeActiveProducer && track.Codec.Name == core.CodecPCMA { // Fix Reolink Doorbell https://github.com/AlexxIT/go2rtc/issues/331 - sender.Handler = pcm.RepackG711(true, sender.Handler) + sender.Handler = pcm.RepackG711(true, 0, sender.Handler) } sender.HandleRTP(track) diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index ebc3a008..261ba10b 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -32,7 +32,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv panic(core.Caller()) } - localTrack := c.getSenderTrack(media.ID) + localTrack := c.GetSenderTrack(media.ID) if localTrack == nil { return errors.New("webrtc: can't get track") } @@ -66,7 +66,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: // Fix audio quality https://github.com/AlexxIT/WebRTC/issues/500 // should be before ResampleToG711, because it will be called last - sender.Handler = pcm.RepackG711(false, sender.Handler) + sender.Handler = pcm.RepackG711(false, 0, sender.Handler) if codec.ClockRate == 0 { if codec.Name == core.CodecPCM || codec.Name == core.CodecPCML { From c6940eb0f3561d79afe0efc1b99e0f622f4680e2 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 27 Oct 2025 21:48:35 +0100 Subject: [PATCH 054/241] Refactor AddTrack to use GetSenderTrack and improve audio handling --- pkg/tuya/client.go | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index d1a549a8..6fb3222b 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -10,6 +10,7 @@ import ( "sync" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/pion/rtp" pion "github.com/pion/webrtc/v4" @@ -333,9 +334,7 @@ func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, } func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - // Manually handle backchannel, because repacking audio through go2rtc does not work - - localTrack := c.getSender() + localTrack := c.conn.GetSenderTrack(media.ID) if localTrack == nil { return errors.New("webrtc: can't get track") } @@ -350,10 +349,21 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece sender := core.NewSender(media, codec) sender.Handler = func(packet *rtp.Packet) { c.conn.Send += packet.MarshalSize() - //important to send with remote PayloadType _ = localTrack.WriteRTP(payloadType, packet) } + switch track.Codec.Name { + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: + // https://developer.tuya.com/en/docs/iot-device-dev/tuyaos-package-ipc-device?id=Kcn1px33iptn2#title-29-Why%20can%E2%80%99t%20WebRTC%20play%20audio%3F + frameSize := 240 + if track.Codec.Name == core.CodecPCM { + frameSize = 560 + } + + sender.Handler = pcm.RepackG711(false, frameSize, sender.Handler) + sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler) + } + sender.HandleRTP(track) c.conn.Senders = append(c.conn.Senders, sender) @@ -498,18 +508,3 @@ func (c *Client) sendMessageToDataChannel(message []byte) error { return nil } - -func (c *Client) getSender() *webrtc.Track { - for _, tr := range c.pc.GetTransceivers() { - if tr.Kind() == pion.RTPCodecTypeAudio { - if tr.Direction() == pion.RTPTransceiverDirectionSendonly || tr.Direction() == pion.RTPTransceiverDirectionSendrecv { - if s := tr.Sender(); s != nil { - if t := s.Track().(*webrtc.Track); t != nil { - return t - } - } - } - } - } - return nil -} From 6d3d45e3375a9f46f4edaf2f015b3e76edbde4f4 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 27 Oct 2025 21:48:44 +0100 Subject: [PATCH 055/241] Handle speaker messages --- pkg/tuya/mqtt.go | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index cf36e09c..81eeaf11 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -17,6 +17,7 @@ type TuyaMqttClient struct { client mqtt.Client waiter core.Waiter wakeupWaiter core.Waiter + speakerWaiter core.Waiter publishTopic string subscribeTopic string auth string @@ -152,6 +153,7 @@ func (c *TuyaMqttClient) Stop() { c.closed = true 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() @@ -240,10 +242,18 @@ func (c *TuyaMqttClient) SendResolution(resolution int) error { func (c *TuyaMqttClient) SendSpeaker(speaker int) error { // Protocol 312 is used for speaker - return c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ + if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ Mode: "webrtc", Value: speaker, - }) + }); err != nil { + return err + } + + // if err := c.speakerWaiter.Wait(); err != nil { + // return fmt.Errorf("speaker wait failed: %w", err) + // } + + return nil } func (c *TuyaMqttClient) SendDisconnect() error { @@ -279,6 +289,8 @@ func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) { c.onMqttCandidate(&rmqtt) case "disconnect": c.onMqttDisconnect() + case "speaker": + c.onMqttSpeaker(&rmqtt) } } @@ -333,6 +345,21 @@ func (c *TuyaMqttClient) onMqttDisconnect() { 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) From 0ff3bf67e1c1db0cf853c46942e28d92727895e5 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 27 Oct 2025 23:57:14 +0100 Subject: [PATCH 056/241] Comment out MQTT speaker message sending in AddTrack function --- pkg/tuya/client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 6fb3222b..a7e43061 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -339,10 +339,10 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece return errors.New("webrtc: can't get track") } - mqttClient := c.api.GetMqtt() - if mqttClient != nil { - _ = mqttClient.SendSpeaker(1) - } + // mqttClient := c.api.GetMqtt() + // if mqttClient != nil { + // _ = mqttClient.SendSpeaker(1) + // } payloadType := codec.PayloadType From 0f27bb112404ae26edbc52bf740bcb0f7fb0abb5 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 27 Oct 2025 23:57:54 +0100 Subject: [PATCH 057/241] Update SendOffer to include token and fix mqtt close --- pkg/tuya/mqtt.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 81eeaf11..157c441a 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -21,6 +21,7 @@ type TuyaMqttClient struct { publishTopic string subscribeTopic string auth string + iceServers []ICEServer uid string motoId string deviceId string @@ -49,11 +50,12 @@ type MqttFrame struct { } type OfferFrame struct { - Mode string `json:"mode"` - Sdp string `json:"sdp"` - StreamType int `json:"stream_type"` - Auth string `json:"auth"` - DatachannelEnable bool `json:"datachannel_enable"` + Mode string `json:"mode"` + Sdp string `json:"sdp"` + StreamType int `json:"stream_type"` + Auth string `json:"auth"` + DatachannelEnable bool `json:"datachannel_enable"` + Token []ICEServer `json:"token"` } type AnswerFrame struct { @@ -115,6 +117,7 @@ func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig c.webrtcVersion = webrtcVersion c.motoId = webrtcConfig.MotoID c.auth = webrtcConfig.Auth + c.iceServers = webrtcConfig.P2PConfig.Ices c.publishTopic = hubConfig.PublishTopic c.subscribeTopic = hubConfig.SubscribeTopic @@ -150,15 +153,16 @@ func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig } func (c *TuyaMqttClient) Stop() { - c.closed = true 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(1000) + c.client.Disconnect(100) } + + c.closed = true } func (c *TuyaMqttClient) WakeUp(localKey string) error { @@ -217,6 +221,7 @@ func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamTy StreamType: mqttStreamType, Auth: c.auth, DatachannelEnable: isHEVC, + Token: c.iceServers, }) } @@ -242,18 +247,16 @@ func (c *TuyaMqttClient) SendResolution(resolution int) error { func (c *TuyaMqttClient) SendSpeaker(speaker int) error { // Protocol 312 is used for speaker - if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ + return c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ Mode: "webrtc", Value: speaker, - }); err != nil { - return err - } + }) // if err := c.speakerWaiter.Wait(); err != nil { // return fmt.Errorf("speaker wait failed: %w", err) // } - return nil + // return nil } func (c *TuyaMqttClient) SendDisconnect() error { From 25e7ac531eba942c0b5c6f88ceb3fdbe5d31813f Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 28 Oct 2025 11:12:44 +0100 Subject: [PATCH 058/241] Cleanup and update comments --- pkg/tuya/client.go | 39 +++++++++++++++++++++++++----------- pkg/tuya/interface.go | 26 +++++++++++++++++------- pkg/tuya/mqtt.go | 46 +++++++++++++++++++++++++------------------ 3 files changed, 73 insertions(+), 38 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index a7e43061..19b926d8 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -17,25 +17,28 @@ import ( ) type Client struct { - api TuyaAPI - conn *webrtc.Conn - pc *pion.PeerConnection + 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 - connected core.Waiter - closed bool handlersMu sync.RWMutex handlers map[uint32]func(*rtp.Packet) } type DataChannelMessage struct { - Type string `json:"type"` + 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"` @@ -159,7 +162,8 @@ func Dial(rawURL string) (core.Producer, error) { } if client.isHEVC { - // Tuya seems to answers always with H264 and PCMU/8000 and PCMA/8000 codecs, replace with real codecs + // We need to replace the SDP codecs with the real ones from Skill. + // The actual media comes via DataChannel, not RTP tracks. for _, media := range client.conn.Medias { if media.Kind == core.KindVideo { @@ -202,9 +206,7 @@ func Dial(rawURL string) (core.Producer, error) { client.Close(err) } - // On HEVC, use DataChannel to receive video/audio if client.isHEVC { - // Create a new DataChannel maxRetransmits := uint16(5) ordered := true client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ @@ -212,18 +214,22 @@ func Dial(rawURL string) (core.Producer, error) { Ordered: &ordered, }) - // Set up data channel handler + // 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 + // Skip invalid packets return } @@ -339,6 +345,9 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece 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) @@ -352,14 +361,16 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece _ = localTrack.WriteRTP(payloadType, packet) } + // Tuya cameras require specific frame sizes + // See: https://developer.tuya.com/en/docs/iot-device-dev/tuyaos-package-ipc-device?id=Kcn1px33iptn2#title-29 switch track.Codec.Name { case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: - // https://developer.tuya.com/en/docs/iot-device-dev/tuyaos-package-ipc-device?id=Kcn1px33iptn2#title-29-Why%20can%E2%80%99t%20WebRTC%20play%20audio%3F frameSize := 240 if track.Codec.Name == core.CodecPCM { frameSize = 560 } + // Repack to required frame size sender.Handler = pcm.RepackG711(false, frameSize, sender.Handler) sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler) } @@ -463,6 +474,7 @@ func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { switch message.Type { case "codec": + // Camera responded to our codec request - now request frame start frameRequest, _ := json.Marshal(DataChannelMessage{ Type: "start", Msg: "frame", @@ -474,6 +486,8 @@ func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { } 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 @@ -484,6 +498,7 @@ func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { 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: "", diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go index 74fcd585..25ba0ddd 100644 --- a/pkg/tuya/interface.go +++ b/pkg/tuya/interface.go @@ -66,17 +66,17 @@ type AudioSkill struct { } 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 + 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"` - LowPower int `json:"lowPower,omitempty"` + 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"` } @@ -128,6 +128,14 @@ 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 @@ -136,7 +144,7 @@ func (c *TuyaClient) GetStreamType(streamResolution string) int { return defaultStreamType } - // Find the highest and lowest resolution + // Find the highest and lowest resolution based on pixel count var highestResType = defaultStreamType var highestRes = 0 var lowestResType = defaultStreamType @@ -169,10 +177,14 @@ func (c *TuyaClient) GetStreamType(streamResolution string) int { } } +// 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 + return video.CodecType == 4 // 4 = H265/HEVC } } diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go index 157c441a..5f64ef48 100644 --- a/pkg/tuya/mqtt.go +++ b/pkg/tuya/mqtt.go @@ -52,9 +52,9 @@ type MqttFrame struct { type OfferFrame struct { Mode string `json:"mode"` Sdp string `json:"sdp"` - StreamType int `json:"stream_type"` + StreamType int `json:"stream_type"` // 0: mainStream(HD), 1: substream(SD) Auth string `json:"auth"` - DatachannelEnable bool `json:"datachannel_enable"` + DatachannelEnable bool `json:"datachannel_enable"` // true for HEVC, false for H264 Token []ICEServer `json:"token"` } @@ -165,8 +165,11 @@ func (c *TuyaMqttClient) Stop() { 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 + // Calculate CRC32 of localKey as wake-up payload crc := crc32.ChecksumIEEE([]byte(localKey)) // Convert to hex string @@ -189,7 +192,8 @@ func (c *TuyaMqttClient) WakeUp(localKey string) error { return fmt.Errorf("failed to publish wake-up message: %w", token.Error()) } - // Subscribe to lowPower topic: smart/decrypt/in/{deviceId} + // 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()) @@ -199,6 +203,7 @@ func (c *TuyaMqttClient) WakeUp(localKey string) error { } 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) @@ -220,7 +225,7 @@ func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamTy Sdp: sdp, StreamType: mqttStreamType, Auth: c.auth, - DatachannelEnable: isHEVC, + DatachannelEnable: isHEVC, // must be true for HEVC Token: c.iceServers, }) } @@ -233,30 +238,32 @@ func (c *TuyaMqttClient) SendCandidate(candidate string) error { } func (c *TuyaMqttClient) SendResolution(resolution int) error { - // isClaritySupperted := (c.webrtcVersion & (1 << 5)) != 0 - // if !isClaritySupperted { - // return nil - // } + // Check if camera supports clarity switching + isClaritySupported := (c.webrtcVersion & (1 << 5)) != 0 + if !isClaritySupported { + return nil + } - // Protocol 312 is used for clarity return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{ Mode: "webrtc", - Value: resolution, + Value: resolution, // 0: HD, 1: SD }) } func (c *TuyaMqttClient) SendSpeaker(speaker int) error { - // Protocol 312 is used for speaker - return c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ + if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ Mode: "webrtc", - Value: speaker, - }) + Value: speaker, // 0: off, 1: on + }); err != nil { + return err + } - // if err := c.speakerWaiter.Wait(); err != nil { - // return fmt.Errorf("speaker wait failed: %w", err) - // } + // Wait for camera response + if err := c.speakerWaiter.Wait(); err != nil { + return fmt.Errorf("speaker wait failed: %w", err) + } - // return nil + return nil } func (c *TuyaMqttClient) SendDisconnect() error { @@ -281,6 +288,7 @@ func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) { return } + // Filter by session ID to prevent processing messages from other sessions if rmqtt.Data.Header.SessionID != c.sessionId { return } From 62a9046f019aeea3b69da7e60c0ca9c07f7bbff7 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 28 Oct 2025 12:50:36 +0100 Subject: [PATCH 059/241] Revert configurable packet size for RepackG711 --- pkg/pcm/handlers.go | 15 ++++++--------- pkg/rtsp/consumer.go | 2 +- pkg/webrtc/consumer.go | 2 +- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pkg/pcm/handlers.go b/pkg/pcm/handlers.go index 7eab1e15..18a96468 100644 --- a/pkg/pcm/handlers.go +++ b/pkg/pcm/handlers.go @@ -12,11 +12,8 @@ import ( // 1. Fixes WebRTC audio quality issue (monotonic timestamp) // 2. Fixes Reolink Doorbell backchannel issue (zero timestamp) // https://github.com/AlexxIT/go2rtc/issues/331 -func RepackG711(zeroTS bool, size int, handler core.HandlerFunc) core.HandlerFunc { - packetSize := 1024 - if size > 0 { - packetSize = size - } +func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { + const PacketSize = 1024 var buf []byte var seq uint16 @@ -29,7 +26,7 @@ func RepackG711(zeroTS bool, size int, handler core.HandlerFunc) core.HandlerFun mu.Lock() buf = append(buf, packet.Payload...) - if len(buf) < packetSize { + if len(buf) < PacketSize { mu.Unlock() return } @@ -42,7 +39,7 @@ func RepackG711(zeroTS bool, size int, handler core.HandlerFunc) core.HandlerFun SequenceNumber: seq, SSRC: packet.SSRC, }, - Payload: buf[:packetSize], + Payload: buf[:PacketSize], } seq++ @@ -51,10 +48,10 @@ func RepackG711(zeroTS bool, size int, handler core.HandlerFunc) core.HandlerFun // don't have this strange devices for tests if !zeroTS { pkt.Timestamp = ts - ts += uint32(packetSize) + ts += PacketSize } - buf = buf[packetSize:] + buf = buf[PacketSize:] mu.Unlock() diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index 8fab94f3..e6525d96 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -56,7 +56,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv if c.mode == core.ModeActiveProducer && track.Codec.Name == core.CodecPCMA { // Fix Reolink Doorbell https://github.com/AlexxIT/go2rtc/issues/331 - sender.Handler = pcm.RepackG711(true, 0, sender.Handler) + sender.Handler = pcm.RepackG711(true, sender.Handler) } sender.HandleRTP(track) diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index 261ba10b..767394df 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -66,7 +66,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: // Fix audio quality https://github.com/AlexxIT/WebRTC/issues/500 // should be before ResampleToG711, because it will be called last - sender.Handler = pcm.RepackG711(false, 0, sender.Handler) + sender.Handler = pcm.RepackG711(false, sender.Handler) if codec.ClockRate == 0 { if codec.Name == core.CodecPCM || codec.Name == core.CodecPCML { From 33e4527042c428aa59e5e0d05fbd71390684bf6e Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 28 Oct 2025 12:58:00 +0100 Subject: [PATCH 060/241] Revert repackaging for backchannel --- pkg/tuya/client.go | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 19b926d8..2ad74b84 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -10,7 +10,6 @@ import ( "sync" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/pion/rtp" pion "github.com/pion/webrtc/v4" @@ -361,20 +360,6 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece _ = localTrack.WriteRTP(payloadType, packet) } - // Tuya cameras require specific frame sizes - // See: https://developer.tuya.com/en/docs/iot-device-dev/tuyaos-package-ipc-device?id=Kcn1px33iptn2#title-29 - switch track.Codec.Name { - case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: - frameSize := 240 - if track.Codec.Name == core.CodecPCM { - frameSize = 560 - } - - // Repack to required frame size - sender.Handler = pcm.RepackG711(false, frameSize, sender.Handler) - sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler) - } - sender.HandleRTP(track) c.conn.Senders = append(c.conn.Senders, sender) From 292b32af9963894dbe7014ba1a11c822794e98ad Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 28 Oct 2025 13:53:42 +0100 Subject: [PATCH 061/241] Optimize audio frame size handling in AddTrack to reduce latency for Tuya cameras --- pkg/tuya/client.go | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 2ad74b84..a010243b 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -355,9 +355,43 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece payloadType := codec.PayloadType sender := core.NewSender(media, codec) + + // 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) { - c.conn.Send += packet.MarshalSize() - _ = localTrack.WriteRTP(payloadType, 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) + } } sender.HandleRTP(track) From 56d7a6fee4d1ef2494134c3820cb8bd5da01843b Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 28 Oct 2025 14:54:54 +0100 Subject: [PATCH 062/241] Add comments and improve repackaging --- pkg/tuya/client.go | 75 ++++++++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index a010243b..3043a8d2 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -161,9 +161,8 @@ func Dial(rawURL string) (core.Producer, error) { } if client.isHEVC { - // We need to replace the SDP codecs with the real ones from Skill. - // The actual media comes via DataChannel, not RTP tracks. - + // 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() @@ -173,6 +172,9 @@ func Dial(rawURL string) (core.Producer, error) { } } + // 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() @@ -356,41 +358,50 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece sender := core.NewSender(media, codec) - // 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 + 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 + var buf []byte + var seq uint16 + var ts uint32 - sender.Handler = func(packet *rtp.Packet) { - buf = append(buf, packet.Payload...) + sender.Handler = func(packet *rtp.Packet) { + buf = append(buf, packet.Payload...) - for len(buf) >= frameSize { - payload := buf[:frameSize] + 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, + 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) } + } - 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) } } From e4359ac217da128f26a082bf46d20d7d7c289c6f Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 9 Nov 2025 21:24:47 +0300 Subject: [PATCH 063/241] Rename HomeKit structures according to specs --- internal/homekit/server.go | 4 +- pkg/hap/camera/README.md | 3 + pkg/hap/camera/accessory.go | 26 +++---- pkg/hap/camera/accessory_test.go | 78 +++++++++---------- pkg/hap/camera/ch114_supported_video.go | 20 ++--- pkg/hap/camera/ch115_supported_audio.go | 26 +++---- ...6_rtp_config.go => ch116_supported_rtp.go} | 6 +- pkg/hap/camera/ch117_selected_stream.go | 8 +- pkg/hap/camera/ch118_setup_endpoints.go | 33 ++++---- pkg/hap/camera/ch120_streaming_status.go | 2 +- pkg/hap/camera/ch130_data_stream_transport.go | 11 +++ pkg/hap/camera/ch131_data_stream.go | 4 +- pkg/hap/camera/ch205.go | 18 +++++ pkg/hap/camera/ch206.go | 20 +++++ pkg/hap/camera/ch207.go | 19 +++++ pkg/hap/camera/ch209.go | 9 +++ pkg/hap/camera/stream.go | 22 +++--- pkg/homekit/consumer.go | 20 ++--- pkg/homekit/helpers.go | 18 ++--- pkg/homekit/producer.go | 4 +- pkg/homekit/proxy.go | 4 +- 21 files changed, 221 insertions(+), 134 deletions(-) create mode 100644 pkg/hap/camera/README.md rename pkg/hap/camera/{ch116_rtp_config.go => ch116_supported_rtp.go} (60%) create mode 100644 pkg/hap/camera/ch130_data_stream_transport.go create mode 100644 pkg/hap/camera/ch205.go create mode 100644 pkg/hap/camera/ch206.go create mode 100644 pkg/hap/camera/ch207.go create mode 100644 pkg/hap/camera/ch209.go diff --git a/internal/homekit/server.go b/internal/homekit/server.go index 6c8b37ae..d4d81456 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -86,7 +86,7 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a switch char.Type { case camera.TypeSetupEndpoints: - var offer camera.SetupEndpoints + var offer camera.SetupEndpointsRequest if err := tlv8.UnmarshalBase64(value, &offer); err != nil { return } @@ -95,7 +95,7 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a s.consumer.SetOffer(&offer) case camera.TypeSelectedStreamConfiguration: - var conf camera.SelectedStreamConfig + var conf camera.SelectedStreamConfiguration if err := tlv8.UnmarshalBase64(value, &conf); err != nil { return } diff --git a/pkg/hap/camera/README.md b/pkg/hap/camera/README.md new file mode 100644 index 00000000..c6c6f236 --- /dev/null +++ b/pkg/hap/camera/README.md @@ -0,0 +1,3 @@ +## Useful links + +- https://github.com/bauer-andreas/secure-video-specification diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 973983ec..37724497 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -49,17 +49,17 @@ func ServiceCameraRTPStreamManagement() *hap.Service { val120, _ := tlv8.MarshalBase64(StreamingStatus{ Status: StreamingStatusAvailable, }) - val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileMain}, Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones {Width: 320, Height: 240, Framerate: 15}, // apple watch @@ -67,23 +67,23 @@ func ServiceCameraRTPStreamManagement() *hap.Service { }, }, }) - val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfig{ - Codecs: []AudioCodec{ + val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ { CodecType: AudioCodecTypeOpus, - CodecParams: []AudioParams{ + CodecParams: []AudioCodecParameters{ { - Channels: 1, - Bitrate: AudioCodecBitrateVariable, - SampleRate: []byte{AudioCodecSampleRate16Khz}, + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, + SampleRate: []byte{AudioCodecSampleRate16Khz}, }, }, }, }, - ComfortNoise: 0, + ComfortNoiseSupport: 0, }) - val116, _ := tlv8.MarshalBase64(SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, + val116, _ := tlv8.MarshalBase64(SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, }) service := &hap.Service{ diff --git a/pkg/hap/camera/accessory_test.go b/pkg/hap/camera/accessory_test.go index 3f5dcd71..53c99a49 100644 --- a/pkg/hap/camera/accessory_test.go +++ b/pkg/hap/camera/accessory_test.go @@ -63,19 +63,19 @@ func TestAqaraG3(t *testing.T) { { name: "114", value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==", - actual: &SupportedVideoStreamConfig{}, - expect: &SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileMain}, Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, CVOEnabled: []byte{0}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, {Width: 640, Height: 360, Framerate: 30}, @@ -94,29 +94,29 @@ func TestAqaraG3(t *testing.T) { { name: "115", value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==", - actual: &SupportedAudioStreamConfig{}, - expect: &SupportedAudioStreamConfig{ - Codecs: []AudioCodec{ + actual: &SupportedAudioStreamConfiguration{}, + expect: &SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ { CodecType: AudioCodecTypeAACELD, - CodecParams: []AudioParams{ + CodecParams: []AudioCodecParameters{ { - Channels: 1, - Bitrate: AudioCodecBitrateVariable, - SampleRate: []byte{AudioCodecSampleRate16Khz}, + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, + SampleRate: []byte{AudioCodecSampleRate16Khz}, }, }, }, }, - ComfortNoise: 0, + ComfortNoiseSupport: 0, }, }, { name: "116", value: "AgEAAAACAQEAAAIBAg==", - actual: &SupportedRTPConfig{}, - expect: &SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoNone}, + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoDisabled}, }, }, } @@ -130,18 +130,18 @@ func TestHomebridge(t *testing.T) { { name: "114", value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==", - actual: &SupportedVideoStreamConfig{}, - expect: &SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh}, Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 320, Height: 180, Framerate: 30}, {Width: 320, Height: 240, Framerate: 15}, @@ -162,9 +162,9 @@ func TestHomebridge(t *testing.T) { { name: "116", value: "AgEA", - actual: &SupportedRTPConfig{}, - expect: &SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, }, }, } @@ -178,18 +178,18 @@ func TestScrypted(t *testing.T) { { name: "114", value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP", - actual: &SupportedVideoStreamConfig{}, - expect: &SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileMain}, Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 3840, Height: 2160, Framerate: 30}, {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, @@ -202,15 +202,15 @@ func TestScrypted(t *testing.T) { { name: "115", value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=", - actual: &SupportedAudioStreamConfig{}, - expect: &SupportedAudioStreamConfig{ - Codecs: []AudioCodec{ + actual: &SupportedAudioStreamConfiguration{}, + expect: &SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ { CodecType: AudioCodecTypeOpus, - CodecParams: []AudioParams{ + CodecParams: []AudioCodecParameters{ { - Channels: 1, - Bitrate: AudioCodecBitrateVariable, + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, SampleRate: []byte{ AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz, AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz, @@ -220,15 +220,15 @@ func TestScrypted(t *testing.T) { }, }, }, - ComfortNoise: 0, + ComfortNoiseSupport: 0, }, }, { name: "116", value: "AgEAAAACAQI=", - actual: &SupportedRTPConfig{}, - expect: &SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoNone}, + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoDisabled}, }, }, } diff --git a/pkg/hap/camera/ch114_supported_video.go b/pkg/hap/camera/ch114_supported_video.go index 196f0286..ec70dc61 100644 --- a/pkg/hap/camera/ch114_supported_video.go +++ b/pkg/hap/camera/ch114_supported_video.go @@ -2,15 +2,15 @@ package camera const TypeSupportedVideoStreamConfiguration = "114" -type SupportedVideoStreamConfig struct { - Codecs []VideoCodec `tlv8:"1"` +type SupportedVideoStreamConfiguration struct { + Codecs []VideoCodecConfiguration `tlv8:"1"` } -type VideoCodec struct { - CodecType byte `tlv8:"1"` - CodecParams []VideoParams `tlv8:"2"` - VideoAttrs []VideoAttrs `tlv8:"3"` - RTPParams []RTPParams `tlv8:"4"` +type VideoCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []VideoCodecParameters `tlv8:"2"` + VideoAttrs []VideoCodecAttributes `tlv8:"3"` + RTPParams []RTPParams `tlv8:"4"` } //goland:noinspection ALL @@ -31,15 +31,15 @@ const ( VideoCodecCvoSuppported = 1 ) -type VideoParams struct { +type VideoCodecParameters struct { ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0 PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported - CVOID []byte `tlv8:"5"` // ??? + CVOID []byte `tlv8:"5"` // ID for CVO RTP extensio } -type VideoAttrs struct { +type VideoCodecAttributes struct { Width uint16 `tlv8:"1"` Height uint16 `tlv8:"2"` Framerate uint8 `tlv8:"3"` diff --git a/pkg/hap/camera/ch115_supported_audio.go b/pkg/hap/camera/ch115_supported_audio.go index efb0d881..f7ba9b44 100644 --- a/pkg/hap/camera/ch115_supported_audio.go +++ b/pkg/hap/camera/ch115_supported_audio.go @@ -2,9 +2,9 @@ package camera const TypeSupportedAudioStreamConfiguration = "115" -type SupportedAudioStreamConfig struct { - Codecs []AudioCodec `tlv8:"1"` - ComfortNoise byte `tlv8:"2"` +type SupportedAudioStreamConfiguration struct { + Codecs []AudioCodecConfiguration `tlv8:"1"` + ComfortNoiseSupport byte `tlv8:"2"` } //goland:noinspection ALL @@ -31,16 +31,16 @@ const ( RTPTimeAACLD24 = 40 // 24000/1000*40=960 ) -type AudioCodec struct { - CodecType byte `tlv8:"1"` - CodecParams []AudioParams `tlv8:"2"` - RTPParams []RTPParams `tlv8:"3"` - ComfortNoise []byte `tlv8:"4"` +type AudioCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []AudioCodecParameters `tlv8:"2"` + RTPParams []RTPParams `tlv8:"3"` + ComfortNoise []byte `tlv8:"4"` } -type AudioParams struct { - Channels uint8 `tlv8:"1"` - Bitrate byte `tlv8:"2"` // 0 - variable, 1 - constant - SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000 - RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60 +type AudioCodecParameters struct { + Channels uint8 `tlv8:"1"` + BitrateMode byte `tlv8:"2"` // 0 - variable, 1 - constant + SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000 + RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60 } diff --git a/pkg/hap/camera/ch116_rtp_config.go b/pkg/hap/camera/ch116_supported_rtp.go similarity index 60% rename from pkg/hap/camera/ch116_rtp_config.go rename to pkg/hap/camera/ch116_supported_rtp.go index fb4be550..f0ca0db9 100644 --- a/pkg/hap/camera/ch116_rtp_config.go +++ b/pkg/hap/camera/ch116_supported_rtp.go @@ -6,9 +6,9 @@ const TypeSupportedRTPConfiguration = "116" const ( CryptoAES_CM_128_HMAC_SHA1_80 = 0 CryptoAES_CM_256_HMAC_SHA1_80 = 1 - CryptoNone = 2 + CryptoDisabled = 2 ) -type SupportedRTPConfig struct { - CryptoType []byte `tlv8:"2"` +type SupportedRTPConfiguration struct { + SRTPCryptoType []byte `tlv8:"2"` } diff --git a/pkg/hap/camera/ch117_selected_stream.go b/pkg/hap/camera/ch117_selected_stream.go index aa0c7038..d94ba96b 100644 --- a/pkg/hap/camera/ch117_selected_stream.go +++ b/pkg/hap/camera/ch117_selected_stream.go @@ -2,10 +2,10 @@ package camera const TypeSelectedStreamConfiguration = "117" -type SelectedStreamConfig struct { - Control SessionControl `tlv8:"1"` - VideoCodec VideoCodec `tlv8:"2"` - AudioCodec AudioCodec `tlv8:"3"` +type SelectedStreamConfiguration struct { + Control SessionControl `tlv8:"1"` + VideoCodec VideoCodecConfiguration `tlv8:"2"` + AudioCodec AudioCodecConfiguration `tlv8:"3"` } //goland:noinspection ALL diff --git a/pkg/hap/camera/ch118_setup_endpoints.go b/pkg/hap/camera/ch118_setup_endpoints.go index 9405de4a..e0f426c0 100644 --- a/pkg/hap/camera/ch118_setup_endpoints.go +++ b/pkg/hap/camera/ch118_setup_endpoints.go @@ -2,25 +2,32 @@ package camera const TypeSetupEndpoints = "118" -type SetupEndpoints struct { - SessionID string `tlv8:"1"` - Status []byte `tlv8:"2"` - Address Addr `tlv8:"3"` - VideoCrypto CryptoSuite `tlv8:"4"` - AudioCrypto CryptoSuite `tlv8:"5"` - VideoSSRC []uint32 `tlv8:"6"` - AudioSSRC []uint32 `tlv8:"7"` +type SetupEndpointsRequest struct { + SessionID string `tlv8:"1"` + Address Address `tlv8:"3"` + VideoCrypto SRTPCryptoSuite `tlv8:"4"` + AudioCrypto SRTPCryptoSuite `tlv8:"5"` } -type Addr struct { +type SetupEndpointsResponse struct { + SessionID string `tlv8:"1"` + Status byte `tlv8:"2"` + Address Address `tlv8:"3"` + VideoCrypto SRTPCryptoSuite `tlv8:"4"` + AudioCrypto SRTPCryptoSuite `tlv8:"5"` + VideoSSRC uint32 `tlv8:"6"` + AudioSSRC uint32 `tlv8:"7"` +} + +type Address struct { IPVersion byte `tlv8:"1"` IPAddr string `tlv8:"2"` VideoRTPPort uint16 `tlv8:"3"` AudioRTPPort uint16 `tlv8:"4"` } -type CryptoSuite struct { - CryptoType byte `tlv8:"1"` - MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM) - MasterSalt string `tlv8:"3"` // 14 byte +type SRTPCryptoSuite struct { + CryptoSuite byte `tlv8:"1"` + MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM) + MasterSalt string `tlv8:"3"` // 14 byte } diff --git a/pkg/hap/camera/ch120_streaming_status.go b/pkg/hap/camera/ch120_streaming_status.go index 2fe53911..e617df27 100644 --- a/pkg/hap/camera/ch120_streaming_status.go +++ b/pkg/hap/camera/ch120_streaming_status.go @@ -9,6 +9,6 @@ type StreamingStatus struct { //goland:noinspection ALL const ( StreamingStatusAvailable = 0 - StreamingStatusBusy = 1 + StreamingStatusInUse = 1 StreamingStatusUnavailable = 2 ) diff --git a/pkg/hap/camera/ch130_data_stream_transport.go b/pkg/hap/camera/ch130_data_stream_transport.go new file mode 100644 index 00000000..808f822d --- /dev/null +++ b/pkg/hap/camera/ch130_data_stream_transport.go @@ -0,0 +1,11 @@ +package camera + +const TypeSupportedDataStreamTransportConfiguration = "130" + +type SupportedDataStreamTransportConfiguration struct { + Configs []TransferTransportConfiguration `tlv8:"1"` +} + +type TransferTransportConfiguration struct { + TransportType byte `tlv8:"1"` +} diff --git a/pkg/hap/camera/ch131_data_stream.go b/pkg/hap/camera/ch131_data_stream.go index 067b01b4..4f4ab49f 100644 --- a/pkg/hap/camera/ch131_data_stream.go +++ b/pkg/hap/camera/ch131_data_stream.go @@ -2,13 +2,13 @@ package camera const TypeSetupDataStreamTransport = "131" -type SetupDataStreamRequest struct { +type SetupDataStreamTransportRequest struct { SessionCommandType byte `tlv8:"1"` TransportType byte `tlv8:"2"` ControllerKeySalt string `tlv8:"3"` } -type SetupDataStreamResponse struct { +type SetupDataStreamTransportResponse struct { Status byte `tlv8:"1"` TransportTypeSessionParameters struct { TCPListeningPort uint16 `tlv8:"1"` diff --git a/pkg/hap/camera/ch205.go b/pkg/hap/camera/ch205.go new file mode 100644 index 00000000..431db7b0 --- /dev/null +++ b/pkg/hap/camera/ch205.go @@ -0,0 +1,18 @@ +package camera + +const TypeSupportedCameraRecordingConfiguration = "205" + +type SupportedCameraRecordingConfiguration struct { + PrebufferLength uint32 `tlv8:"1"` + EventTriggerOptions uint64 `tlv8:"2"` + MediaContainerConfigurations `tlv8:"3"` +} + +type MediaContainerConfigurations struct { + MediaContainerType uint8 `tlv8:"1"` + MediaContainerParameters `tlv8:"2"` +} + +type MediaContainerParameters struct { + FragmentLength uint32 `tlv8:"1"` +} diff --git a/pkg/hap/camera/ch206.go b/pkg/hap/camera/ch206.go new file mode 100644 index 00000000..89219fa7 --- /dev/null +++ b/pkg/hap/camera/ch206.go @@ -0,0 +1,20 @@ +package camera + +const TypeSupportedVideoRecordingConfiguration = "206" + +type SupportedVideoRecordingConfiguration struct { + CodecConfigs []VideoRecordingCodecConfiguration `tlv8:"1"` +} + +type VideoRecordingCodecConfiguration struct { + CodecType uint8 `tlv8:"1"` + CodecParams VideoRecordingCodecParameters `tlv8:"2"` + CodecAttrs VideoCodecAttributes `tlv8:"3"` +} + +type VideoRecordingCodecParameters struct { + ProfileID uint8 `tlv8:"1"` + Level uint8 `tlv8:"2"` + Bitrate uint32 `tlv8:"3"` + IFrameInterval uint32 `tlv8:"4"` +} diff --git a/pkg/hap/camera/ch207.go b/pkg/hap/camera/ch207.go new file mode 100644 index 00000000..5d389923 --- /dev/null +++ b/pkg/hap/camera/ch207.go @@ -0,0 +1,19 @@ +package camera + +const TypeSupportedAudioRecordingConfiguration = "207" + +type SupportedAudioRecordingConfiguration struct { + CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"` +} + +type AudioRecordingCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []AudioRecordingCodecParameters `tlv8:"2"` +} + +type AudioRecordingCodecParameters struct { + Channels uint8 `tlv8:"1"` + BitrateMode []byte `tlv8:"2"` + SampleRate []byte `tlv8:"3"` + MaxAudioBitrate []uint32 `tlv8:"4"` +} diff --git a/pkg/hap/camera/ch209.go b/pkg/hap/camera/ch209.go new file mode 100644 index 00000000..c51359fb --- /dev/null +++ b/pkg/hap/camera/ch209.go @@ -0,0 +1,9 @@ +package camera + +const TypeSelectedCameraRecordingConfiguration = "209" + +type SelectedCameraRecordingConfiguration struct { + GeneralConfig SupportedCameraRecordingConfiguration `tlv8:"1"` + VideoConfig SupportedVideoRecordingConfiguration `tlv8:"2"` + AudioConfig SupportedAudioRecordingConfiguration `tlv8:"3"` +} diff --git a/pkg/hap/camera/stream.go b/pkg/hap/camera/stream.go index 23d53c39..bda67920 100644 --- a/pkg/hap/camera/stream.go +++ b/pkg/hap/camera/stream.go @@ -15,7 +15,7 @@ type Stream struct { } func NewStream( - client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec, + client *hap.Client, videoCodec *VideoCodecConfiguration, audioCodec *AudioCodecConfiguration, videoSession, audioSession *srtp.Session, bitrate int, ) (*Stream, error) { stream := &Stream{ @@ -58,7 +58,7 @@ func NewStream( } audioCodec.ComfortNoise = []byte{0} - config := &SelectedStreamConfig{ + config := &SelectedStreamConfiguration{ Control: SessionControl{ SessionID: stream.id, Command: SessionCommandStart, @@ -103,19 +103,19 @@ func (s *Stream) GetFreeStream() error { } func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error { - req := SetupEndpoints{ + req := SetupEndpointsRequest{ SessionID: s.id, - Address: Addr{ + Address: Address{ IPVersion: 0, IPAddr: videoSession.Local.Addr, VideoRTPPort: videoSession.Local.Port, AudioRTPPort: audioSession.Local.Port, }, - VideoCrypto: CryptoSuite{ + VideoCrypto: SRTPCryptoSuite{ MasterKey: string(videoSession.Local.MasterKey), MasterSalt: string(videoSession.Local.MasterSalt), }, - AudioCrypto: CryptoSuite{ + AudioCrypto: SRTPCryptoSuite{ MasterKey: string(audioSession.Local.MasterKey), MasterSalt: string(audioSession.Local.MasterSalt), }, @@ -129,7 +129,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err return err } - var res SetupEndpoints + var res SetupEndpointsResponse if err := s.client.GetCharacter(char); err != nil { return err } @@ -142,7 +142,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err Port: res.Address.VideoRTPPort, MasterKey: []byte(res.VideoCrypto.MasterKey), MasterSalt: []byte(res.VideoCrypto.MasterSalt), - SSRC: res.VideoSSRC[0], + SSRC: res.VideoSSRC, } audioSession.Remote = &srtp.Endpoint{ @@ -150,13 +150,13 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err Port: res.Address.AudioRTPPort, MasterKey: []byte(res.AudioCrypto.MasterKey), MasterSalt: []byte(res.AudioCrypto.MasterSalt), - SSRC: res.AudioSSRC[0], + SSRC: res.AudioSSRC, } return nil } -func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error { +func (s *Stream) SetStreamConfig(config *SelectedStreamConfiguration) error { char := s.service.GetCharacter(TypeSelectedStreamConfiguration) if err := char.Write(config); err != nil { return err @@ -169,7 +169,7 @@ func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error { } func (s *Stream) Close() error { - config := &SelectedStreamConfig{ + config := &SelectedStreamConfiguration{ Control: SessionControl{ SessionID: s.id, Command: SessionCommandEnd, diff --git a/pkg/homekit/consumer.go b/pkg/homekit/consumer.go index ea83146f..05204218 100644 --- a/pkg/homekit/consumer.go +++ b/pkg/homekit/consumer.go @@ -59,7 +59,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { } } -func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) { +func (c *Consumer) SetOffer(offer *camera.SetupEndpointsRequest) { c.sessionID = offer.SessionID c.videoSession = &srtp.Session{ Remote: &srtp.Endpoint{ @@ -79,32 +79,32 @@ func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) { } } -func (c *Consumer) GetAnswer() *camera.SetupEndpoints { +func (c *Consumer) GetAnswer() *camera.SetupEndpointsResponse { c.videoSession.Local = c.srtpEndpoint() c.audioSession.Local = c.srtpEndpoint() - return &camera.SetupEndpoints{ + return &camera.SetupEndpointsResponse{ SessionID: c.sessionID, - Status: []byte{0}, - Address: camera.Addr{ + Status: camera.StreamingStatusAvailable, + Address: camera.Address{ IPAddr: c.videoSession.Local.Addr, VideoRTPPort: c.videoSession.Local.Port, AudioRTPPort: c.audioSession.Local.Port, }, - VideoCrypto: camera.CryptoSuite{ + VideoCrypto: camera.SRTPCryptoSuite{ MasterKey: string(c.videoSession.Local.MasterKey), MasterSalt: string(c.videoSession.Local.MasterSalt), }, - AudioCrypto: camera.CryptoSuite{ + AudioCrypto: camera.SRTPCryptoSuite{ MasterKey: string(c.audioSession.Local.MasterKey), MasterSalt: string(c.audioSession.Local.MasterSalt), }, - VideoSSRC: []uint32{c.videoSession.Local.SSRC}, - AudioSSRC: []uint32{c.audioSession.Local.SSRC}, + VideoSSRC: c.videoSession.Local.SSRC, + AudioSSRC: c.audioSession.Local.SSRC, } } -func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfig) bool { +func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfiguration) bool { if c.sessionID != conf.Control.SessionID { return false } diff --git a/pkg/homekit/helpers.go b/pkg/homekit/helpers.go index f5a17319..2a2268d6 100644 --- a/pkg/homekit/helpers.go +++ b/pkg/homekit/helpers.go @@ -13,7 +13,7 @@ var videoCodecs = [...]string{core.CodecH264} var videoProfiles = [...]string{"4200", "4D00", "6400"} var videoLevels = [...]string{"1F", "20", "28"} -func videoToMedia(codecs []camera.VideoCodec) *core.Media { +func videoToMedia(codecs []camera.VideoCodecConfiguration) *core.Media { media := &core.Media{ Kind: core.KindVideo, Direction: core.DirectionRecvonly, } @@ -39,7 +39,7 @@ func videoToMedia(codecs []camera.VideoCodec) *core.Media { var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus} var audioSampleRates = [...]uint32{8000, 16000, 24000} -func audioToMedia(codecs []camera.AudioCodec) *core.Media { +func audioToMedia(codecs []camera.AudioCodecConfiguration) *core.Media { media := &core.Media{ Kind: core.KindAudio, Direction: core.DirectionRecvonly, } @@ -67,7 +67,7 @@ func audioToMedia(codecs []camera.AudioCodec) *core.Media { return media } -func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.VideoCodec { +func trackToVideo(track *core.Receiver, video0 *camera.VideoCodecConfiguration) *camera.VideoCodecConfiguration { profileID := video0.CodecParams[0].ProfileID[0] level := video0.CodecParams[0].Level[0] attrs := video0.VideoAttrs[0] @@ -96,19 +96,19 @@ func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.Video } } - return &camera.VideoCodec{ + return &camera.VideoCodecConfiguration{ CodecType: video0.CodecType, - CodecParams: []camera.VideoParams{ + CodecParams: []camera.VideoCodecParameters{ { ProfileID: []byte{profileID}, Level: []byte{level}, }, }, - VideoAttrs: []camera.VideoAttrs{attrs}, + VideoAttrs: []camera.VideoCodecAttributes{attrs}, } } -func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.AudioCodec { +func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodecConfiguration) *camera.AudioCodecConfiguration { codecType := audio0.CodecType channels := audio0.CodecParams[0].Channels sampleRate := audio0.CodecParams[0].SampleRate[0] @@ -131,9 +131,9 @@ func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.Audio } } - return &camera.AudioCodec{ + return &camera.AudioCodecConfiguration{ CodecType: codecType, - CodecParams: []camera.AudioParams{ + CodecParams: []camera.AudioCodecParameters{ { Channels: channels, SampleRate: []byte{sampleRate}, diff --git a/pkg/homekit/producer.go b/pkg/homekit/producer.go index 451b9882..3351a736 100644 --- a/pkg/homekit/producer.go +++ b/pkg/homekit/producer.go @@ -22,8 +22,8 @@ type Client struct { hap *hap.Client srtp *srtp.Server - videoConfig camera.SupportedVideoStreamConfig - audioConfig camera.SupportedAudioStreamConfig + videoConfig camera.SupportedVideoStreamConfiguration + audioConfig camera.SupportedAudioStreamConfiguration videoSession *srtp.Session audioSession *srtp.Session diff --git a/pkg/homekit/proxy.go b/pkg/homekit/proxy.go index be233042..0e378b49 100644 --- a/pkg/homekit/proxy.go +++ b/pkg/homekit/proxy.go @@ -74,7 +74,7 @@ func (p *Proxy) handleCon(pair ServerPair) error { _ = json.Unmarshal(body, &v) for _, char := range v.Value { if char.IID == hdsCharIID { - var hdsReq camera.SetupDataStreamRequest + var hdsReq camera.SetupDataStreamTransportRequest _ = tlv8.UnmarshalBase64(char.Value, &hdsReq) hdsConSalt = hdsReq.ControllerKeySalt break @@ -110,7 +110,7 @@ func (p *Proxy) handleCon(pair ServerPair) error { _ = json.Unmarshal(body, &v) for i, char := range v.Value { if char.IID == hdsCharIID { - var hdsRes camera.SetupDataStreamResponse + var hdsRes camera.SetupDataStreamTransportResponse _ = tlv8.UnmarshalBase64(char.Value, &hdsRes) hdsAccSalt := hdsRes.AccessoryKeySalt From 96919bf9e3527850624ff7921842535b97a3fcfd Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 9 Nov 2025 21:25:23 +0300 Subject: [PATCH 064/241] Add support uint64 to tlv8 --- pkg/hap/tlv8/tlv8.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 6efe20a6..7b397b99 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -112,6 +112,10 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { v := value.Uint() return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil + case reflect.Uint64: + v := value.Uint() + return binary.LittleEndian.AppendUint64(append(b, tag, 8), v), nil + case reflect.Float32: v := math.Float32bits(float32(value.Float())) return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil @@ -310,6 +314,12 @@ func unmarshalValue(v []byte, value reflect.Value) error { } value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24) + case reflect.Uint64: + if len(v) != 8 { + return errors.New("tlv8: wrong size: " + value.Type().Name()) + } + value.SetUint(binary.LittleEndian.Uint64(v)) + case reflect.Float32: f := math.Float32frombits(binary.LittleEndian.Uint32(v)) value.SetFloat(float64(f)) From 81cfcf877a5e74044bb30bcec71f97541cb2dd18 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 9 Nov 2025 21:28:53 +0300 Subject: [PATCH 065/241] Fix HomeKit proxy EVENTs --- pkg/hap/client_http.go | 17 +++++++++++++++++ pkg/homekit/proxy.go | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/hap/client_http.go b/pkg/hap/client_http.go index 360f48bc..7f8314f8 100644 --- a/pkg/hap/client_http.go +++ b/pkg/hap/client_http.go @@ -82,3 +82,20 @@ func ReadResponse(r *bufio.Reader, req *http.Request) (*http.Response, error) { return res, nil } + +func WriteEvent(w io.Writer, res *http.Response) error { + return res.Write(&eventWriter{w: w}) +} + +type eventWriter struct { + w io.Writer + done bool +} + +func (e *eventWriter) Write(p []byte) (n int, err error) { + if !e.done { + p = append([]byte("EVENT/1.0"), p[8:]...) + e.done = true + } + return e.w.Write(p) +} diff --git a/pkg/homekit/proxy.go b/pkg/homekit/proxy.go index 0e378b49..ac2f14d7 100644 --- a/pkg/homekit/proxy.go +++ b/pkg/homekit/proxy.go @@ -149,7 +149,7 @@ func (p *Proxy) handleAcc() error { } if res.Proto == hap.ProtoEvent { - if err = res.Write(p.con); err != nil { + if err = hap.WriteEvent(p.con, res); err != nil { return err } continue From 158f9d3a08ebb17f96bc47f28cfb27dc56a26807 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 9 Nov 2025 21:58:44 +0300 Subject: [PATCH 066/241] Code refactoring for HomeKit server --- internal/homekit/api.go | 105 +++++---- internal/homekit/homekit.go | 76 ++----- internal/homekit/server.go | 299 ++++++++++++++++-------- pkg/hap/client.go | 10 +- pkg/hap/client_pairing.go | 5 +- pkg/hap/{secure/secure.go => conn.go} | 47 +++- pkg/hap/hds/hds.go | 33 ++- pkg/hap/server.go | 314 ++++++++++++++++++++++---- pkg/hap/server_pairing.go | 266 ---------------------- pkg/homekit/consumer.go | 6 +- pkg/homekit/log/debug.go | 45 ++++ pkg/homekit/producer.go | 16 +- pkg/homekit/proxy.go | 54 +++-- pkg/homekit/server.go | 57 +---- www/add.html | 15 +- 15 files changed, 742 insertions(+), 606 deletions(-) rename pkg/hap/{secure/secure.go => conn.go} (74%) delete mode 100644 pkg/hap/server_pairing.go create mode 100644 pkg/homekit/log/debug.go diff --git a/internal/homekit/api.go b/internal/homekit/api.go index 9f76c2d6..5d2b38d2 100644 --- a/internal/homekit/api.go +++ b/internal/homekit/api.go @@ -3,6 +3,7 @@ package homekit import ( "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -14,56 +15,84 @@ import ( "github.com/AlexxIT/go2rtc/pkg/mdns" ) -func apiHandler(w http.ResponseWriter, r *http.Request) { +func apiDiscovery(w http.ResponseWriter, r *http.Request) { + sources, err := discovery() + if err != nil { + api.Error(w, err) + return + } + + urls := findHomeKitURLs() + for id, u := range urls { + deviceID := u.Query().Get("device_id") + for _, source := range sources { + if strings.Contains(source.URL, deviceID) { + source.Location = id + break + } + } + } + + for _, source := range sources { + if source.Location == "" { + source.Location = " " + } + } + + api.ResponseSources(w, sources) +} + +func apiHomekit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch r.Method { case "GET": - sources, err := discovery() - if err != nil { - api.Error(w, err) - return + if id := r.Form.Get("id"); id != "" { + api.ResponsePrettyJSON(w, servers[id]) + } else { + api.ResponsePrettyJSON(w, servers) } - urls := findHomeKitURLs() - for id, u := range urls { - deviceID := u.Query().Get("device_id") - for _, source := range sources { - if strings.Contains(source.URL, deviceID) { - source.Location = id - break - } - } - } - - for _, source := range sources { - if source.Location == "" { - source.Location = " " - } - } - - api.ResponseSources(w, sources) - case "POST": - if err := r.ParseMultipartForm(1024); err != nil { - api.Error(w, err) - return - } - - if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil { - api.Error(w, err) + id := r.Form.Get("id") + rawURL := r.Form.Get("src") + "&pin=" + r.Form.Get("pin") + if err := apiPair(id, rawURL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } case "DELETE": - if err := r.ParseMultipartForm(1024); err != nil { - api.Error(w, err) - return - } - - if err := apiUnpair(r.Form.Get("id")); err != nil { - api.Error(w, err) + id := r.Form.Get("id") + if err := apiUnpair(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } } } +func apiHomekitAccessories(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + rawURL := findHomeKitURL(stream.Sources()) + + client, err := hap.Dial(rawURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer client.Close() + + res, err := client.Get(hap.PathAccessories) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", api.MimeJSON) + _, _ = io.Copy(w, res.Body) +} + func discovery() ([]*api.Source, error) { var sources []*api.Source diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index b4237211..a6b02bf2 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -2,8 +2,6 @@ package homekit import ( "errors" - "io" - "net" "net/http" "strings" @@ -35,12 +33,15 @@ func Init() { streams.HandleFunc("homekit", streamHandler) - api.HandleFunc("api/homekit", apiHandler) + api.HandleFunc("api/homekit", apiHomekit) + api.HandleFunc("api/homekit/accessories", apiHomekitAccessories) + api.HandleFunc("api/discovery/homekit", apiDiscovery) if cfg.Mod == nil { return } + hosts = map[string]*server{} servers = map[string]*server{} var entries []*mdns.ServiceEntry @@ -66,33 +67,14 @@ func Init() { srv := &server{ stream: id, - srtp: srtp.Server, pairings: conf.Pairings, } srv.hap = &hap.Server{ - Pin: pin, - DeviceID: deviceID, - DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id), - GetPair: srv.GetPair, - AddPair: srv.AddPair, - Handler: homekit.ServerHandler(srv), - } - - if url := findHomeKitURL(stream.Sources()); url != "" { - // 1. Act as transparent proxy for HomeKit camera - dial := func() (net.Conn, error) { - client, err := homekit.Dial(url, srtp.Server) - if err != nil { - return nil, err - } - return client.Conn(), nil - } - srv.hap.Handler = homekit.ProxyHandler(srv, dial) - } else { - // 2. Act as basic HomeKit camera - srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version) - srv.hap.Handler = homekit.ServerHandler(srv) + Pin: pin, + DeviceID: deviceID, + DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id), + GetClientPublic: srv.GetPair, } srv.mdns = &mdns.ServiceEntry{ @@ -114,15 +96,24 @@ func Init() { srv.UpdateStatus() + if url := findHomeKitURL(stream.Sources()); url != "" { + // 1. Act as transparent proxy for HomeKit camera + srv.proxyURL = url + } else { + // 2. Act as basic HomeKit camera + srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version) + } + host := srv.mdns.Host(mdns.ServiceHAP) - servers[host] = srv + hosts[host] = srv + servers[id] = srv + + log.Trace().Msgf("[homekit] new server: %s", srv.mdns) } api.HandleFunc(hap.PathPairSetup, hapHandler) api.HandleFunc(hap.PathPairVerify, hapHandler) - log.Trace().Msgf("[homekit] mdns: %s", entries) - go func() { if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil { log.Error().Err(err).Caller().Send() @@ -131,6 +122,7 @@ func Init() { } var log zerolog.Logger +var hosts map[string]*server var servers map[string]*server func streamHandler(rawURL string) (core.Producer, error) { @@ -149,45 +141,27 @@ func streamHandler(rawURL string) (core.Producer, error) { } func resolve(host string) *server { - if len(servers) == 1 { - for _, srv := range servers { + if len(hosts) == 1 { + for _, srv := range hosts { return srv } } - if srv, ok := servers[host]; ok { + if srv, ok := hosts[host]; ok { return srv } return nil } func hapHandler(w http.ResponseWriter, r *http.Request) { - conn, rw, err := w.(http.Hijacker).Hijack() - if err != nil { - return - } - - defer conn.Close() - // Can support multiple HomeKit cameras on single port ONLY for Apple devices. // Doesn't support Home Assistant and any other open source projects // because they don't send the host header in requests. srv := resolve(r.Host) if srv == nil { log.Error().Msg("[homekit] unknown host: " + r.Host) - _ = hap.WriteBackoff(rw) return } - - switch r.RequestURI { - case hap.PathPairSetup: - err = srv.hap.PairSetup(r, rw, conn) - case hap.PathPairVerify: - err = srv.hap.PairVerify(r, rw, conn) - } - - if err != nil && err != io.EOF { - log.Error().Err(err).Caller().Send() - } + srv.Handle(w, r) } func findHomeKitURL(sources []string) string { diff --git a/internal/homekit/server.go b/internal/homekit/server.go index d4d81456..57e97287 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -4,10 +4,16 @@ import ( "crypto/ed25519" "crypto/sha512" "encoding/hex" + "encoding/json" + "errors" "fmt" + "io" "net" + "net/http" "net/url" + "slices" "strings" + "sync" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg" @@ -16,23 +22,133 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/hap/hds" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/homekit" "github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/mdns" - "github.com/AlexxIT/go2rtc/pkg/srtp" ) type server struct { - stream string // stream name from YAML - hap *hap.Server // server for HAP connection and encryption - mdns *mdns.ServiceEntry - srtp *srtp.Server - accessory *hap.Accessory // HAP accessory - pairings []string // pairings list + hap *hap.Server // server for HAP connection and encryption + mdns *mdns.ServiceEntry - streams map[string]*homekit.Consumer - consumer *homekit.Consumer + pairings []string // pairings list + conns []any + mu sync.Mutex + + accessory *hap.Accessory // HAP accessory + consumer *homekit.Consumer + proxyURL string + stream string // stream name from YAML +} + +func (s *server) MarshalJSON() ([]byte, error) { + v := struct { + Name string `json:"name"` + DeviceID string `json:"device_id"` + Paired int `json:"paired"` + Conns []any `json:"connections"` + }{ + Name: s.mdns.Name, + DeviceID: s.mdns.Info[hap.TXTDeviceID], + Paired: len(s.pairings), + Conns: s.conns, + } + return json.Marshal(v) +} + +func (s *server) Handle(w http.ResponseWriter, r *http.Request) { + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + + defer conn.Close() + + // Fix reading from Body after Hijack. + r.Body = io.NopCloser(rw) + + switch r.RequestURI { + case hap.PathPairSetup: + id, key, err := s.hap.PairSetup(r, rw) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + s.AddPair(id, key, hap.PermissionAdmin) + + case hap.PathPairVerify: + id, key, err := s.hap.PairVerify(r, rw) + if err != nil { + log.Debug().Err(err).Caller().Send() + return + } + + log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[homekit] %s: new conn", conn.RemoteAddr()) + + controller, err := hap.NewConn(conn, rw, key, false) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + s.AddConn(controller) + defer s.DelConn(controller) + + var handler homekit.HandlerFunc + + switch { + case s.accessory != nil: + handler = homekit.ServerHandler(s) + case s.proxyURL != "": + client, err := hap.Dial(s.proxyURL) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + handler = homekit.ProxyHandler(s, client.Conn) + } + + // If your iPhone goes to sleep, it will be an EOF error. + if err = handler(controller); err != nil && !errors.Is(err, io.EOF) { + log.Error().Err(err).Caller().Send() + return + } + } +} + +type logger struct { + v any +} + +func (l logger) String() string { + switch v := l.v.(type) { + case *hap.Conn: + return "hap " + v.RemoteAddr().String() + case *hds.Conn: + return "hds " + v.RemoteAddr().String() + case *homekit.Consumer: + return "rtp " + v.RemoteAddr + } + return "unknown" +} + +func (s *server) AddConn(v any) { + log.Trace().Str("stream", s.stream).Msgf("[homekit] add conn %s", logger{v}) + s.mu.Lock() + s.conns = append(s.conns, v) + s.mu.Unlock() +} + +func (s *server) DelConn(v any) { + log.Trace().Str("stream", s.stream).Msgf("[homekit] del conn %s", logger{v}) + s.mu.Lock() + if i := slices.Index(s.conns, v); i >= 0 { + s.conns = slices.Delete(s.conns, i, i+1) + } + s.mu.Unlock() } func (s *server) UpdateStatus() { @@ -44,12 +160,68 @@ func (s *server) UpdateStatus() { } } +func (s *server) pairIndex(id string) int { + id = "client_id=" + id + for i, pairing := range s.pairings { + if strings.HasPrefix(pairing, id) { + return i + } + } + return -1 +} + +func (s *server) GetPair(id string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + + if i := s.pairIndex(id); i >= 0 { + query, _ := url.ParseQuery(s.pairings[i]) + b, _ := hex.DecodeString(query.Get("client_public")) + return b + } + return nil +} + +func (s *server) AddPair(id string, public []byte, permissions byte) { + log.Debug().Str("stream", s.stream).Msgf("[homekit] add pair id=%s public=%x perm=%d", id, public, permissions) + + s.mu.Lock() + if s.pairIndex(id) < 0 { + s.pairings = append(s.pairings, fmt.Sprintf( + "client_id=%s&client_public=%x&permissions=%d", id, public, permissions, + )) + s.UpdateStatus() + s.PatchConfig() + } + s.mu.Unlock() +} + +func (s *server) DelPair(id string) { + log.Debug().Str("stream", s.stream).Msgf("[homekit] del pair id=%s", id) + + s.mu.Lock() + if i := s.pairIndex(id); i >= 0 { + s.pairings = append(s.pairings[:i], s.pairings[i+1:]...) + s.UpdateStatus() + s.PatchConfig() + } + s.mu.Unlock() +} + +func (s *server) PatchConfig() { + if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil { + log.Error().Err(err).Msgf( + "[homekit] can't save %s pairings=%v", s.stream, s.pairings, + ) + } +} + func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory { return []*hap.Accessory{s.accessory} } func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { - log.Trace().Msgf("[homekit] %s: get char aid=%d iid=0x%x", conn.RemoteAddr(), aid, iid) + log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid) char := s.accessory.GetCharacterByID(iid) if char == nil { @@ -59,11 +231,12 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { switch char.Type { case camera.TypeSetupEndpoints: - if s.consumer == nil { + consumer := s.consumer + if consumer == nil { return nil } - answer := s.consumer.GetAnswer() + answer := consumer.GetAnswer() v, err := tlv8.MarshalBase64(answer) if err != nil { return nil @@ -76,7 +249,7 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { } func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) { - log.Trace().Msgf("[homekit] %s: set char aid=%d iid=0x%x value=%v", conn.RemoteAddr(), aid, iid, value) + log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value) char := s.accessory.GetCharacterByID(iid) if char == nil { @@ -91,8 +264,9 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a return } - s.consumer = homekit.NewConsumer(conn, srtp2.Server) - s.consumer.SetOffer(&offer) + consumer := homekit.NewConsumer(conn, srtp2.Server) + consumer.SetOffer(&offer) + s.consumer = consumer case camera.TypeSelectedStreamConfiguration: var conf camera.SelectedStreamConfiguration @@ -100,47 +274,49 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a return } - log.Trace().Msgf("[homekit] %s stream id=%x cmd=%d", conn.RemoteAddr(), conf.Control.SessionID, conf.Control.Command) + log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command) switch conf.Control.Command { case camera.SessionCommandEnd: - if consumer := s.streams[conf.Control.SessionID]; consumer != nil { - _ = consumer.Stop() + for _, consumer := range s.conns { + if consumer, ok := consumer.(*homekit.Consumer); ok { + if consumer.SessionID() == conf.Control.SessionID { + _ = consumer.Stop() + return + } + } } case camera.SessionCommandStart: - if s.consumer == nil { + consumer := s.consumer + if consumer == nil { return } - if !s.consumer.SetConfig(&conf) { + if !consumer.SetConfig(&conf) { log.Warn().Msgf("[homekit] wrong config") return } - if s.streams == nil { - s.streams = map[string]*homekit.Consumer{} - } - - s.streams[conf.Control.SessionID] = s.consumer + s.AddConn(consumer) stream := streams.Get(s.stream) - if err := stream.AddConsumer(s.consumer); err != nil { + if err := stream.AddConsumer(consumer); err != nil { return } go func() { - _, _ = s.consumer.WriteTo(nil) - stream.RemoveConsumer(s.consumer) + _, _ = consumer.WriteTo(nil) + stream.RemoveConsumer(consumer) - delete(s.streams, conf.Control.SessionID) + s.DelConn(consumer) }() } } } func (s *server) GetImage(conn net.Conn, width, height int) []byte { - log.Trace().Msgf("[homekit] %s: get image width=%d height=%d", conn.RemoteAddr(), width, height) + log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height) stream := streams.Get(s.stream) cons := magic.NewKeyframe() @@ -166,69 +342,6 @@ func (s *server) GetImage(conn net.Conn, width, height int) []byte { return b } -func (s *server) GetPair(conn net.Conn, id string) []byte { - log.Trace().Msgf("[homekit] %s: get pair id=%s", conn.RemoteAddr(), id) - - for _, pairing := range s.pairings { - if !strings.Contains(pairing, id) { - continue - } - - query, err := url.ParseQuery(pairing) - if err != nil { - continue - } - - if query.Get("client_id") != id { - continue - } - - s := query.Get("client_public") - b, _ := hex.DecodeString(s) - return b - } - return nil -} - -func (s *server) AddPair(conn net.Conn, id string, public []byte, permissions byte) { - log.Trace().Msgf("[homekit] %s: add pair id=%s public=%x perm=%d", conn.RemoteAddr(), id, public, permissions) - - query := url.Values{ - "client_id": []string{id}, - "client_public": []string{hex.EncodeToString(public)}, - "permissions": []string{string('0' + permissions)}, - } - if s.GetPair(conn, id) == nil { - s.pairings = append(s.pairings, query.Encode()) - s.UpdateStatus() - s.PatchConfig() - } -} - -func (s *server) DelPair(conn net.Conn, id string) { - log.Trace().Msgf("[homekit] %s: del pair id=%s", conn.RemoteAddr(), id) - - id = "client_id=" + id - for i, pairing := range s.pairings { - if !strings.Contains(pairing, id) { - continue - } - - s.pairings = append(s.pairings[:i], s.pairings[i+1:]...) - s.UpdateStatus() - s.PatchConfig() - break - } -} - -func (s *server) PatchConfig() { - if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil { - log.Error().Err(err).Msgf( - "[homekit] can't save %s pairings=%v", s.stream, s.pairings, - ) - } -} - func calcName(name, seed string) string { if name != "" { return name diff --git a/pkg/hap/client.go b/pkg/hap/client.go index bde85277..ed4faa02 100644 --- a/pkg/hap/client.go +++ b/pkg/hap/client.go @@ -18,7 +18,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/hap/curve25519" "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/mdns" ) @@ -46,7 +45,7 @@ type Client struct { err error } -func NewClient(rawURL string) (*Client, error) { +func Dial(rawURL string) (*Client, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -61,6 +60,10 @@ func NewClient(rawURL string) (*Client, error) { ClientPrivate: DecodeKey(query.Get("client_private")), } + if err = c.Dial(); err != nil { + return nil, err + } + return c, nil } @@ -96,6 +99,7 @@ func (c *Client) Dial() (err error) { return false }) + // TODO: close conn on error if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil { return } @@ -219,7 +223,7 @@ func (c *Client) Dial() (err error) { rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn)) // like tls.Client wrapper over net.Conn - if c.Conn, err = secure.Client(c.Conn, rw, sessionShared, true); err != nil { + if c.Conn, err = NewConn(c.Conn, rw, sessionShared, true); err != nil { return } // new reader for new conn diff --git a/pkg/hap/client_pairing.go b/pkg/hap/client_pairing.go index a58526d9..f253783d 100644 --- a/pkg/hap/client_pairing.go +++ b/pkg/hap/client_pairing.go @@ -121,9 +121,7 @@ func (c *Client) Pair(feature, pin string) (err error) { username := []byte("Pair-Setup") // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) - pake, err := srp.NewSRP( - "rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username), - ) + pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username)) if err != nil { return } @@ -132,6 +130,7 @@ func (c *Client) Pair(feature, pin string) (err error) { // username: "Pair-Setup", password: PIN (with dashes) session := pake.NewClientSession(username, []byte(pin)) + sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey)) if err != nil { return diff --git a/pkg/hap/secure/secure.go b/pkg/hap/conn.go similarity index 74% rename from pkg/hap/secure/secure.go rename to pkg/hap/conn.go index a42c7dea..2b039dc8 100644 --- a/pkg/hap/secure/secure.go +++ b/pkg/hap/conn.go @@ -1,13 +1,16 @@ -package secure +package hap import ( "bufio" "encoding/binary" + "encoding/json" "errors" "io" "net" + "sync" "time" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" ) @@ -15,16 +18,33 @@ import ( type Conn struct { conn net.Conn rw *bufio.ReadWriter + wmu sync.Mutex encryptKey []byte decryptKey []byte encryptCnt uint64 decryptCnt uint64 + //ClientID string SharedKey []byte + + recv int + send int } -func Client(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) { +func (c *Conn) MarshalJSON() ([]byte, error) { + conn := core.Connection{ + ID: core.ID(c), + FormatName: "homekit", + Protocol: "hap", + RemoteAddr: c.conn.RemoteAddr().String(), + Recv: c.recv, + Send: c.send, + } + return json.Marshal(conn) +} + +func NewConn(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) { key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key") if err != nil { return nil, err @@ -52,8 +72,8 @@ func Client(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool } const ( - // PacketSizeMax is the max length of encrypted packets - PacketSizeMax = 0x400 + // packetSizeMax is the max length of encrypted packets + packetSizeMax = 0x400 VerifySize = 2 NonceSize = 8 @@ -61,18 +81,18 @@ const ( ) func (c *Conn) Read(b []byte) (n int, err error) { - if cap(b) < PacketSizeMax { + if cap(b) < packetSizeMax { return 0, errors.New("hap: read buffer is too small") } - verify := make([]byte, 2) // verify = plain message size + verify := make([]byte, VerifySize) // verify = plain message size if _, err = io.ReadFull(c.rw, verify); err != nil { return } n = int(binary.LittleEndian.Uint16(verify)) - ciphertext := make([]byte, n+Overhead) + ciphertext := make([]byte, n+Overhead) if _, err = io.ReadFull(c.rw, ciphertext); err != nil { return } @@ -82,18 +102,23 @@ func (c *Conn) Read(b []byte) (n int, err error) { c.decryptCnt++ _, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify) + + c.recv += n return } func (c *Conn) Write(b []byte) (n int, err error) { - buf := make([]byte, 0, PacketSizeMax+Overhead) + c.wmu.Lock() + defer c.wmu.Unlock() + + buf := make([]byte, 0, packetSizeMax+Overhead) nonce := make([]byte, NonceSize) verify := make([]byte, VerifySize) for len(b) > 0 { size := len(b) - if size > PacketSizeMax { - size = PacketSizeMax + if size > packetSizeMax { + size = packetSizeMax } binary.LittleEndian.PutUint16(verify, uint16(size)) @@ -118,6 +143,8 @@ func (c *Conn) Write(b []byte) (n int, err error) { } err = c.rw.Flush() + + c.send += n return } diff --git a/pkg/hap/hds/hds.go b/pkg/hap/hds/hds.go index a7b2c74a..60ee05d2 100644 --- a/pkg/hap/hds/hds.go +++ b/pkg/hap/hds/hds.go @@ -4,16 +4,18 @@ package hds import ( "bufio" "encoding/binary" + "encoding/json" "io" "net" "time" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" ) -func Client(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) { +func NewConn(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) { writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key") if err != nil { return nil, err @@ -49,6 +51,21 @@ type Conn struct { encryptKey []byte decryptCnt uint64 encryptCnt uint64 + + recv int + send int +} + +func (c *Conn) MarshalJSON() ([]byte, error) { + conn := core.Connection{ + ID: core.ID(c), + FormatName: "homekit", + Protocol: "hds", + RemoteAddr: c.conn.RemoteAddr().String(), + Recv: c.recv, + Send: c.send, + } + return json.Marshal(conn) } func (c *Conn) Read(p []byte) (n int, err error) { @@ -59,16 +76,18 @@ func (c *Conn) Read(p []byte) (n int, err error) { n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF) - ciphertext := make([]byte, n+secure.Overhead) + ciphertext := make([]byte, n+hap.Overhead) if _, err = io.ReadFull(c.rd, ciphertext); err != nil { return } - nonce := make([]byte, secure.NonceSize) + nonce := make([]byte, hap.NonceSize) binary.LittleEndian.PutUint64(nonce, c.decryptCnt) c.decryptCnt++ _, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify) + + c.recv += n return } @@ -81,11 +100,11 @@ func (c *Conn) Write(b []byte) (n int, err error) { return } - nonce := make([]byte, secure.NonceSize) + nonce := make([]byte, hap.NonceSize) binary.LittleEndian.PutUint64(nonce, c.encryptCnt) c.encryptCnt++ - buf := make([]byte, n+secure.Overhead) + buf := make([]byte, n+hap.Overhead) if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil { return } @@ -95,6 +114,8 @@ func (c *Conn) Write(b []byte) (n int, err error) { } err = c.wr.Flush() + + c.send += n return } diff --git a/pkg/hap/server.go b/pkg/hap/server.go index 99c86f6b..f962a440 100644 --- a/pkg/hap/server.go +++ b/pkg/hap/server.go @@ -6,28 +6,23 @@ import ( "encoding/base64" "errors" "fmt" - "net" "net/http" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/curve25519" "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/tadglines/go-pkgs/crypto/srp" ) -type HandlerFunc func(net.Conn) error - type Server struct { Pin string DeviceID string DevicePrivate []byte - GetPair func(conn net.Conn, id string) []byte - AddPair func(conn net.Conn, id string, public []byte, permissions byte) - - Handler HandlerFunc + // GetClientPublic may be nil, so client validation will be disabled + GetClientPublic func(id string) []byte } func (s *Server) ServerPublic() []byte { @@ -48,37 +43,240 @@ func (s *Server) SetupHash() string { return base64.StdEncoding.EncodeToString(b[:4]) } -func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error { - // Request from iPhone +func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) { + // STEP 1. Request from iPhone var plainM1 struct { - PublicKey string `tlv8:"3"` - State byte `tlv8:"6"` + State byte `tlv8:"6"` + Method byte `tlv8:"0"` + Flags uint32 `tlv8:"19"` } - if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { - return err + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { + return } if plainM1.State != StateM1 { - return newRequestError(plainM1) + err = newRequestError(plainM1) + return + } + + username := []byte("Pair-Setup") + + // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) + pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username)) + if err != nil { + return + } + + pake.SaltLength = 16 + + salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin)) + if err != nil { + return + } + + session := pake.NewServerSession(username, salt, verifier) + + // STEP 2. Response to iPhone + plainM2 := struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + Salt string `tlv8:"2"` + }{ + State: StateM2, + PublicKey: string(session.GetB()), + Salt: string(salt), + } + body, err := tlv8.Marshal(plainM2) + if err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + // STEP 3. Request from iPhone + if req, err = http.ReadRequest(rw.Reader); err != nil { + return + } + + var plainM3 struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + Proof string `tlv8:"4"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil { + return + } + if plainM3.State != StateM3 { + err = newRequestError(plainM3) + return + } + + // important to compute key before verify client + sessionShared, err := session.ComputeKey([]byte(plainM3.PublicKey)) + if err != nil { + return + } + + if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) { + err = errors.New("hap: VerifyClientAuthenticator") + return + } + + proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof + + // STEP 4. Response to iPhone + payloadM4 := struct { + State byte `tlv8:"6"` + Proof string `tlv8:"4"` + }{ + State: StateM4, + Proof: string(proof), + } + if body, err = tlv8.Marshal(payloadM4); err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + // STEP 5. Request from iPhone + if req, err = http.ReadRequest(rw.Reader); err != nil { + return + } + var cipherM5 struct { + State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil { + return + } + if cipherM5.State != StateM5 { + err = newRequestError(cipherM5) + return + } + + // decrypt message using session shared + encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info") + if err != nil { + return + } + + b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData)) + if err != nil { + return + } + + // unpack message from TLV8 + var plainM5 struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + } + if err = tlv8.Unmarshal(b, &plainM5); err != nil { + return + } + + // 3. verify client ID and Public + remoteSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info", + ) + if err != nil { + return + } + + b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey) + if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) { + err = errors.New("hap: ValidateSignature") + return + } + + // 4. generate signature to our ID and Public + localSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info", + ) + if err != nil { + return + } + + b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic + signature, err := ed25519.Signature(s.DevicePrivate, b) + if err != nil { + return + } + + // 5. pack our ID and Public + plainM6 := struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + }{ + Identifier: s.DeviceID, + PublicKey: string(s.ServerPublic()), + Signature: string(signature), + } + if b, err = tlv8.Marshal(plainM6); err != nil { + return + } + + // 6. encrypt message + b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b) + if err != nil { + return + } + + // STEP 6. Response to iPhone + cipherM6 := struct { + State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` + }{ + State: StateM6, + EncryptedData: string(b), + } + if body, err = tlv8.Marshal(cipherM6); err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + id = plainM5.Identifier + publicKey = []byte(plainM5.PublicKey) + + return +} + +func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter) (id string, sessionKey []byte, err error) { + // Request from iPhone + var plainM1 struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { + return + } + if plainM1.State != StateM1 { + err = newRequestError(plainM1) + return } // Generate the key pair sessionPublic, sessionPrivate := curve25519.GenerateKeyPair() sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey)) if err != nil { - return err + return } encryptKey, err := hkdf.Sha512( sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", ) if err != nil { - return err + return } b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey) signature, err := ed25519.Signature(s.DevicePrivate, b) if err != nil { - return err + return } // STEP M2. Response to iPhone @@ -90,12 +288,12 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co Signature: string(signature), } if b, err = tlv8.Marshal(plainM2); err != nil { - return err + return } b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b) if err != nil { - return err + return } cipherM2 := struct { @@ -109,30 +307,32 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co } body, err := tlv8.Marshal(cipherM2) if err != nil { - return err + return } if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err + return } // STEP M3. Request from iPhone if req, err = http.ReadRequest(rw.Reader); err != nil { - return err + return } var cipherM3 struct { - EncryptedData string `tlv8:"5"` State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` } if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil { - return err + return } if cipherM3.State != StateM3 { - return newRequestError(cipherM3) + err = newRequestError(cipherM3) + return } - if b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)); err != nil { - return err + b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)) + if err != nil { + return } var plainM3 struct { @@ -140,17 +340,21 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co Signature string `tlv8:"10"` } if err = tlv8.Unmarshal(b, &plainM3); err != nil { - return err + return } - clientPublic := s.GetPair(conn, plainM3.Identifier) - if clientPublic == nil { - return fmt.Errorf("hap: PairVerify from: %s, with unknown client_id: %s", conn.RemoteAddr(), plainM3.Identifier) - } + if s.GetClientPublic != nil { + clientPublic := s.GetClientPublic(plainM3.Identifier) + if clientPublic == nil { + err = errors.New("hap: PairVerify with unknown client_id: " + plainM3.Identifier) + return + } - b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic) - if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) { - return errors.New("new: ValidateSignature") + b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic) + if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) { + err = errors.New("hap: ValidateSignature") + return + } } // STEP M4. Response to iPhone @@ -160,15 +364,41 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co State: StateM4, } if body, err = tlv8.Marshal(payloadM4); err != nil { - return err + return } if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err + return } - if conn, err = secure.Client(conn, rw, sessionShared, false); err != nil { - return err - } + id = plainM3.Identifier + sessionKey = sessionShared - return s.Handler(conn) + return } + +func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error { + header := fmt.Sprintf( + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n", + statusCode, http.StatusText(statusCode), contentType, len(body), + ) + body = append([]byte(header), body...) + if _, err := w.Write(body); err != nil { + return err + } + return w.Flush() +} + +//func WriteBackoff(rw *bufio.ReadWriter) error { +// plainM2 := struct { +// State byte `tlv8:"6"` +// Error byte `tlv8:"7"` +// }{ +// State: StateM2, +// Error: 3, // BackoffError +// } +// body, err := tlv8.Marshal(plainM2) +// if err != nil { +// return err +// } +// return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body) +//} diff --git a/pkg/hap/server_pairing.go b/pkg/hap/server_pairing.go deleted file mode 100644 index 571ba7a2..00000000 --- a/pkg/hap/server_pairing.go +++ /dev/null @@ -1,266 +0,0 @@ -package hap - -import ( - "bufio" - "crypto/sha512" - "errors" - "fmt" - "net" - "net/http" - - "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" - "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" - "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" - "github.com/tadglines/go-pkgs/crypto/srp" -) - -const ( - PairMethodSetup = iota - PairMethodSetupWithAuth - PairMethodVerify - PairMethodAdd - PairMethodRemove - PairMethodList -) - -func (s *Server) HandleConn(conn net.Conn) error { - rd := bufio.NewReader(conn) - req, err := http.ReadRequest(rd) - if err != nil { - return err - } - - rw := bufio.NewReadWriter(rd, bufio.NewWriter(conn)) - - switch req.RequestURI { - case PathPairSetup: - return s.PairSetup(req, rw, conn) - case PathPairVerify: - return s.PairVerify(req, rw, conn) - } - - return errors.New("hap: unsupported request uri: " + req.RequestURI) -} - -func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error { - // STEP 1. Request from iPhone - var plainM1 struct { - Method byte `tlv8:"0"` - State byte `tlv8:"6"` - Flags uint32 `tlv8:"19"` - } - if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { - return err - } - if plainM1.State != StateM1 { - return newRequestError(plainM1) - } - - username := []byte("Pair-Setup") - - // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) - pake, err := srp.NewSRP( - "rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username), - ) - if err != nil { - return err - } - - pake.SaltLength = 16 - - salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin)) - - session := pake.NewServerSession(username, salt, verifier) - - // STEP 2. Response to iPhone - plainM2 := struct { - Salt string `tlv8:"2"` - PublicKey string `tlv8:"3"` - State byte `tlv8:"6"` - }{ - State: StateM2, - PublicKey: string(session.GetB()), - Salt: string(salt), - } - body, err := tlv8.Marshal(plainM2) - if err != nil { - return err - } - if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err - } - - // STEP 3. Request from iPhone - if req, err = http.ReadRequest(rw.Reader); err != nil { - return err - } - - var plainM3 struct { - SessionKey string `tlv8:"3"` - Proof string `tlv8:"4"` - State byte `tlv8:"6"` - } - if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil { - return err - } - if plainM3.State != StateM3 { - return newRequestError(plainM3) - } - - // important to compute key before verify client - sessionShared, err := session.ComputeKey([]byte(plainM3.SessionKey)) - if err != nil { - return err - } - - if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) { - return errors.New("hap: VerifyClientAuthenticator") - } - - proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof - - // STEP 4. Response to iPhone - payloadM4 := struct { - Proof string `tlv8:"4"` - State byte `tlv8:"6"` - }{ - Proof: string(proof), - State: StateM4, - } - if body, err = tlv8.Marshal(payloadM4); err != nil { - return err - } - if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err - } - - // STEP 5. Request from iPhone - if req, err = http.ReadRequest(rw.Reader); err != nil { - return err - } - var cipherM5 struct { - EncryptedData string `tlv8:"5"` - State byte `tlv8:"6"` - } - if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil { - return err - } - if cipherM5.State != StateM5 { - return newRequestError(cipherM5) - } - - // decrypt message using session shared - encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info") - if err != nil { - return err - } - - b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData)) - if err != nil { - return err - } - - // unpack message from TLV8 - var plainM5 struct { - Identifier string `tlv8:"1"` - PublicKey string `tlv8:"3"` - Signature string `tlv8:"10"` - } - if err = tlv8.Unmarshal(b, &plainM5); err != nil { - return err - } - - // 3. verify client ID and Public - remoteSign, err := hkdf.Sha512( - sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info", - ) - if err != nil { - return err - } - - b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey) - if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) { - return errors.New("hap: ValidateSignature") - } - - // 4. generate signature to our ID and Public - localSign, err := hkdf.Sha512( - sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info", - ) - if err != nil { - return err - } - - b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic - signature, err := ed25519.Signature(s.DevicePrivate, b) - if err != nil { - return err - } - - // 5. pack our ID and Public - plainM6 := struct { - Identifier string `tlv8:"1"` - PublicKey string `tlv8:"3"` - Signature string `tlv8:"10"` - }{ - Identifier: s.DeviceID, - PublicKey: string(s.ServerPublic()), - Signature: string(signature), - } - if b, err = tlv8.Marshal(plainM6); err != nil { - return err - } - - // 6. encrypt message - b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b) - if err != nil { - return err - } - - // STEP 6. Response to iPhone - cipherM6 := struct { - EncryptedData string `tlv8:"5"` - State byte `tlv8:"6"` - }{ - State: StateM6, - EncryptedData: string(b), - } - if body, err = tlv8.Marshal(cipherM6); err != nil { - return err - } - if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err - } - - s.AddPair(conn, plainM5.Identifier, []byte(plainM5.PublicKey), PermissionAdmin) - - return nil -} - -func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error { - header := fmt.Sprintf( - "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n", - statusCode, http.StatusText(statusCode), contentType, len(body), - ) - body = append([]byte(header), body...) - if _, err := w.Write(body); err != nil { - return err - } - return w.Flush() -} - -func WriteBackoff(rw *bufio.ReadWriter) error { - plainM2 := struct { - State byte `tlv8:"6"` - Error byte `tlv8:"7"` - }{ - State: StateM2, - Error: 3, // BackoffError - } - body, err := tlv8.Marshal(plainM2) - if err != nil { - return err - } - return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body) -} diff --git a/pkg/homekit/consumer.go b/pkg/homekit/consumer.go index 05204218..c1be7447 100644 --- a/pkg/homekit/consumer.go +++ b/pkg/homekit/consumer.go @@ -49,7 +49,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { Connection: core.Connection{ ID: core.NewID(), FormatName: "homekit", - Protocol: "udp", + Protocol: "rtp", RemoteAddr: conn.RemoteAddr().String(), Medias: medias, Transport: conn, @@ -59,6 +59,10 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { } } +func (c *Consumer) SessionID() string { + return c.sessionID +} + func (c *Consumer) SetOffer(offer *camera.SetupEndpointsRequest) { c.sessionID = offer.SessionID c.videoSession = &srtp.Session{ diff --git a/pkg/homekit/log/debug.go b/pkg/homekit/log/debug.go new file mode 100644 index 00000000..1fb60be2 --- /dev/null +++ b/pkg/homekit/log/debug.go @@ -0,0 +1,45 @@ +package log + +import ( + "bytes" + "io" + "log" + "net/http" +) + +func Debug(v any) { + switch v := v.(type) { + case *http.Request: + if v == nil { + return + } + if v.ContentLength != 0 { + b, err := io.ReadAll(v.Body) + if err != nil { + panic(err) + } + v.Body = io.NopCloser(bytes.NewReader(b)) + log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b) + } else { + log.Printf("[homekit] request: %s %s ", v.Method, v.RequestURI) + } + case *http.Response: + if v == nil { + return + } + if v.Header.Get("Content-Type") == "image/jpeg" { + log.Printf("[homekit] response: %d ", v.StatusCode) + return + } + if v.ContentLength != 0 { + b, err := io.ReadAll(v.Body) + if err != nil { + panic(err) + } + v.Body = io.NopCloser(bytes.NewReader(b)) + log.Printf("[homekit] response: %s %d\n%s", v.Proto, v.StatusCode, b) + } else { + log.Printf("[homekit] response: %s %d ", v.Proto, v.StatusCode) + } + } +} diff --git a/pkg/homekit/producer.go b/pkg/homekit/producer.go index 3351a736..04719612 100644 --- a/pkg/homekit/producer.go +++ b/pkg/homekit/producer.go @@ -5,7 +5,6 @@ import ( "fmt" "math/rand" "net" - "net/url" "time" "github.com/AlexxIT/go2rtc/pkg/core" @@ -34,24 +33,11 @@ type Client struct { } func Dial(rawURL string, server *srtp.Server) (*Client, error) { - u, err := url.Parse(rawURL) + conn, err := hap.Dial(rawURL) if err != nil { return nil, err } - query := u.Query() - conn := &hap.Client{ - DeviceAddress: u.Host, - DeviceID: query.Get("device_id"), - DevicePublic: hap.DecodeKey(query.Get("device_public")), - ClientID: query.Get("client_id"), - ClientPrivate: hap.DecodeKey(query.Get("client_private")), - } - - if err = conn.Dial(); err != nil { - return nil, err - } - client := &Client{ Connection: core.Connection{ ID: core.NewID(), diff --git a/pkg/homekit/proxy.go b/pkg/homekit/proxy.go index ac2f14d7..2132266c 100644 --- a/pkg/homekit/proxy.go +++ b/pkg/homekit/proxy.go @@ -4,31 +4,30 @@ import ( "bufio" "bytes" "encoding/json" - "fmt" "io" "net" "net/http" + "time" "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap/camera" "github.com/AlexxIT/go2rtc/pkg/hap/hds" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" ) -func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFunc { +type ServerProxy interface { + ServerPair + AddConn(conn any) + DelConn(conn any) +} + +func ProxyHandler(srv ServerProxy, acc net.Conn) HandlerFunc { return func(con net.Conn) error { defer con.Close() - acc, err := dial() - if err != nil { - return err - } - defer acc.Close() - pr := &Proxy{ - con: con.(*secure.Conn), - acc: acc.(*secure.Conn), + con: con.(*hap.Conn), + acc: acc.(*hap.Conn), res: make(chan *http.Response), } @@ -36,17 +35,17 @@ func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFun go pr.handleAcc() // controller => accessory - return pr.handleCon(pair) + return pr.handleCon(srv) } } type Proxy struct { - con *secure.Conn - acc *secure.Conn + con *hap.Conn + acc *hap.Conn res chan *http.Response } -func (p *Proxy) handleCon(pair ServerPair) error { +func (p *Proxy) handleCon(srv ServerProxy) error { var hdsCharIID uint64 rd := bufio.NewReader(p.con) @@ -61,7 +60,7 @@ func (p *Proxy) handleCon(pair ServerPair) error { switch { case req.Method == "POST" && req.URL.Path == hap.PathPairings: var res *http.Response - if res, err = handlePairings(p.con, req, pair); err != nil { + if res, err = handlePairings(req, srv); err != nil { return err } if err = res.Write(p.con); err != nil { @@ -117,7 +116,7 @@ func (p *Proxy) handleCon(pair ServerPair) error { hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort) // swtich accPort to conPort - hdsPort, err = p.listenHDS(hdsPort, hdsConSalt+hdsAccSalt) + hdsPort, err = p.listenHDS(srv, hdsPort, hdsConSalt+hdsAccSalt) if err != nil { return err } @@ -166,7 +165,8 @@ func (p *Proxy) handleAcc() error { } } -func (p *Proxy) listenHDS(accPort int, salt string) (int, error) { +func (p *Proxy) listenHDS(srv ServerProxy, accPort int, salt string) (int, error) { + // The TCP port range for HDS must be >= 32768. ln, err := net.ListenTCP("tcp", nil) if err != nil { return 0, err @@ -175,30 +175,36 @@ func (p *Proxy) listenHDS(accPort int, salt string) (int, error) { go func() { defer ln.Close() + _ = ln.SetDeadline(time.Now().Add(30 * time.Second)) + // raw controller conn - con, err := ln.Accept() + conn1, err := ln.Accept() if err != nil { return } - defer con.Close() + + defer conn1.Close() // secured controller conn (controlle=false because we are accessory) - con, err = hds.Client(con, p.con.SharedKey, salt, false) + con, err := hds.NewConn(conn1, p.con.SharedKey, salt, false) if err != nil { return } + srv.AddConn(con) + defer srv.DelConn(con) + accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP // raw accessory conn - acc, err := net.Dial("tcp", fmt.Sprintf("%s:%d", accIP, accPort)) + conn2, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: accIP, Port: accPort}) if err != nil { return } - defer acc.Close() + defer conn2.Close() // secured accessory conn (controller=true because we are controller) - acc, err = hds.Client(acc, p.acc.SharedKey, salt, true) + acc, err := hds.NewConn(conn2, p.acc.SharedKey, salt, true) if err != nil { return } diff --git a/pkg/homekit/server.go b/pkg/homekit/server.go index 2d00deab..75ba2a0f 100644 --- a/pkg/homekit/server.go +++ b/pkg/homekit/server.go @@ -15,15 +15,17 @@ import ( "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" ) +type HandlerFunc func(net.Conn) error + type Server interface { ServerPair ServerAccessory } type ServerPair interface { - GetPair(conn net.Conn, id string) []byte - AddPair(conn net.Conn, id string, public []byte, permissions byte) - DelPair(conn net.Conn, id string) + GetPair(id string) []byte + AddPair(id string, public []byte, permissions byte) + DelPair(id string) } type ServerAccessory interface { @@ -33,11 +35,11 @@ type ServerAccessory interface { GetImage(conn net.Conn, width, height int) []byte } -func ServerHandler(server Server) hap.HandlerFunc { +func ServerHandler(server Server) HandlerFunc { return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) { switch req.URL.Path { case hap.PathPairings: - return handlePairings(conn, req, server) + return handlePairings(req, server) case hap.PathAccessories: body := hap.JSONAccessories{Value: server.GetAccessories(conn)} @@ -103,7 +105,7 @@ func ServerHandler(server Server) hap.HandlerFunc { }) } -func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) hap.HandlerFunc { +func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) HandlerFunc { return func(conn net.Conn) error { rw := bufio.NewReaderSize(conn, 16*1024) wr := bufio.NewWriterSize(conn, 16*1024) @@ -130,7 +132,7 @@ func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response } } -func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Response, error) { +func handlePairings(req *http.Request, srv ServerPair) (*http.Response, error) { cmd := struct { Method byte `tlv8:"0"` Identifier string `tlv8:"1"` @@ -145,9 +147,9 @@ func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Re switch cmd.Method { case 3: // add - pair.AddPair(conn, cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions) + srv.AddPair(cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions) case 4: // delete - pair.DelPair(conn, cmd.Identifier) + srv.DelPair(cmd.Identifier) } body := struct { @@ -190,40 +192,3 @@ func makeResponse(mime string, v any) (*http.Response, error) { } return res, nil } - -//func debug(v any) { -// switch v := v.(type) { -// case *http.Request: -// if v == nil { -// return -// } -// if v.ContentLength != 0 { -// b, err := io.ReadAll(v.Body) -// if err != nil { -// panic(err) -// } -// v.Body = io.NopCloser(bytes.NewReader(b)) -// log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b) -// } else { -// log.Printf("[homekit] request: %s %s ", v.Method, v.RequestURI) -// } -// case *http.Response: -// if v == nil { -// return -// } -// if v.Header.Get("Content-Type") == "image/jpeg" { -// log.Printf("[homekit] response: %d ", v.StatusCode) -// return -// } -// if v.ContentLength != 0 { -// b, err := io.ReadAll(v.Body) -// if err != nil { -// panic(err) -// } -// v.Body = io.NopCloser(bytes.NewReader(b)) -// log.Printf("[homekit] response: %d\n%s", v.StatusCode, b) -// } else { -// log.Printf("[homekit] response: %d ", v.StatusCode) -// } -// } -//} diff --git a/www/add.html b/www/add.html index 53d6b3dc..17c05059 100644 --- a/www/add.html +++ b/www/add.html @@ -100,7 +100,7 @@
- +
@@ -112,7 +112,7 @@
+ - - -
-
- - - -
-
- - - - -
-
-
- - - - -
-
- - - - -
-
- - -
-
-
- + const r = await fetch(url, {method: 'PUT'}); + alert(r.ok ? 'OK' : 'ERROR: ' + await r.text()); + }); + - -
-
-
- + +
+
+
+ - -
-
-
- + +
+
+ + + + +
+
+ + +
+
+
+ - - - -
-
- - - - - -
-
-
- - - -
-
- - - - -
-
- - -
-
-
- + + + +
+
+
+ + + + +
+
+
+ + + + +
+
+
+ + + + +
+
+ + + + + +
+
+
+ + + +
+
+
+ + + + +
+
+
+ + + + +
+
+ + +
+
+
+ + + + +
+
+ + + + +
+
+ + +
+
+
+ - - -
-
-
- + document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth); + document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth); + - -
-
-
- + +
+
+ + + +
+ +
+
+ - -
-
- - -
-
-
- + +
+
+
+ - -
-
- - - -
- -
-
- - - - -
-
-
- - - - -
-
-
- - + +
+
+
+ + diff --git a/www/editor.html b/www/config.html similarity index 75% rename from www/editor.html rename to www/config.html index cb455f4d..9e1853c2 100644 --- a/www/editor.html +++ b/www/config.html @@ -1,41 +1,36 @@ - go2rtc - File Editor - - + + + go2rtc - Config - -
- -
-
-
- + +
+
+ +
+
+
+ + + diff --git a/www/index.html b/www/index.html index 63fedcec..2f7ac967 100644 --- a/www/index.html +++ b/www/index.html @@ -1,61 +1,49 @@ - - - - - - + + go2rtc + -
-
- - - - - -
- - - - - - - - - - -
OnlineCommands
+ +
+
+ + modes + + + + +
+ + + + + + + + + + +
onlinecommands
+
+
+ + diff --git a/www/links.html b/www/links.html index 3b651762..a54fcf8f 100644 --- a/www/links.html +++ b/www/links.html @@ -1,27 +1,10 @@ + + go2rtc - links - - - - - + +
+ + + }); + -
-

Play audio

-
-
-
-
- - send / cameras with two way audio support -
- +
+

Play audio

+ + + +
+ + + / cameras with two way audio support +
+ -
-

Publish stream

-
YouTube:  rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
+    
+

Publish stream

+
YouTube:  rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
 Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
- - send / Telegram RTMPS server -
- + + + / Telegram RTMPS server +
+ -
-

WebRTC Magic

-
-
-
-
+
+

WebRTC Magic

+ + + + -
-
  • webrtc.html local WebRTC viewer
  • +
    +
  • webrtc.html local WebRTC viewer
  • -
  • - share link - copy link - delete - external WebRTC viewer -
  • -
    - + +
    diff --git a/www/log.html b/www/log.html index 84ec0675..67476603 100644 --- a/www/log.html +++ b/www/log.html @@ -1,69 +1,64 @@ + + go2rtc - Logs - - + -
    - - - -
    -
    - - - - - - - - - - -
    TimeLevelMessage
    + +
    +
    + + + +
    + + + + + + + + + + +
    TimeLevelMessage
    +
    + + diff --git a/www/main.js b/www/main.js index 714c9127..36b04495 100644 --- a/www/main.js +++ b/www/main.js @@ -1,200 +1,134 @@ -// main menu -document.body.innerHTML = ` +document.head.innerHTML += ` - +`; + +document.body.innerHTML = ` +
    + +
    ` + document.body.innerHTML; - -const sunIcon = '☀️'; -const moonIcon = '🌕'; - -document.addEventListener('DOMContentLoaded', () => { - const darkModeToggle = document.getElementById('darkModeToggle'); - const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); - - const isDarkModeEnabled = () => document.body.classList.contains('dark-mode'); - - // Update the toggle button based on the dark mode state - const updateToggleButton = () => { - if (isDarkModeEnabled()) { - darkModeToggle.innerHTML = sunIcon; - darkModeToggle.setAttribute('aria-label', 'Enable light mode'); - } else { - darkModeToggle.innerHTML = moonIcon; - darkModeToggle.setAttribute('aria-label', 'Enable dark mode'); - } - }; - - const updateDarkMode = () => { - if (localStorage.getItem('darkMode') === 'enabled' || prefersDarkScheme.matches && localStorage.getItem('darkMode') !== 'disabled') { - document.body.classList.add('dark-mode'); - } else { - document.body.classList.remove('dark-mode'); - } - updateEditorTheme(); - updateToggleButton(); - }; - - // Update the editor theme based on the dark mode state - const updateEditorTheme = () => { - if (typeof editor !== 'undefined') { - editor.setTheme(isDarkModeEnabled() ? 'ace/theme/tomorrow_night_eighties' : 'ace/theme/github'); - } - }; - - // Initial update for dark mode and toggle button - updateDarkMode(); - - // Listen for changes in the system's color scheme preference - prefersDarkScheme.addEventListener('change', updateDarkMode); // Modern approach - - // Toggle dark mode and update local storage on button click - darkModeToggle.addEventListener('click', () => { - const enabled = document.body.classList.toggle('dark-mode'); - localStorage.setItem('darkMode', enabled ? 'enabled' : 'disabled'); - updateToggleButton(); // Update the button after toggling - updateEditorTheme(); - }); -}); diff --git a/www/network.html b/www/net.html similarity index 87% rename from www/network.html rename to www/net.html index 79875012..8f0f91ac 100644 --- a/www/network.html +++ b/www/net.html @@ -2,31 +2,21 @@ + go2rtc - Network -
    + + +
    + + From ac80f1470ef09093c3b59aae628ffd6f9849883e Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 16 Nov 2025 18:20:53 +0300 Subject: [PATCH 081/241] Add errors output to streams API --- internal/hass/api.go | 4 ++-- internal/hls/ws.go | 2 +- internal/mjpeg/init.go | 4 ++-- internal/mp4/mp4.go | 2 +- internal/mp4/ws.go | 4 ++-- internal/streams/api.go | 8 ++++---- internal/streams/streams.go | 35 ++++++++++++++++++++--------------- internal/webrtc/webrtc.go | 2 +- 8 files changed, 33 insertions(+), 28 deletions(-) diff --git a/internal/hass/api.go b/internal/hass/api.go index e3de23b3..9f110fc8 100644 --- a/internal/hass/api.go +++ b/internal/hass/api.go @@ -30,10 +30,10 @@ func apiStream(w http.ResponseWriter, r *http.Request) { // 1. link to go2rtc stream: rtsp://...:8554/{stream_name} // 2. static link to Hass camera // 3. dynamic link to Hass camera - if streams.Patch(v.Name, v.Channels.First.Url) != nil { + if _, err := streams.Patch(v.Name, v.Channels.First.Url); err == nil { apiOK(w, r) } else { - http.Error(w, "", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) } // /stream/{id}/channel/0/webrtc diff --git a/internal/hls/ws.go b/internal/hls/ws.go index 608f515f..00eedfe2 100644 --- a/internal/hls/ws.go +++ b/internal/hls/ws.go @@ -11,7 +11,7 @@ import ( ) func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index 27c557e4..2fa9fa32 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -36,7 +36,7 @@ func Init() { var log zerolog.Logger func handlerKeyframe(w http.ResponseWriter, r *http.Request) { - stream := streams.GetOrPatch(r.URL.Query()) + stream, _ := streams.GetOrPatch(r.URL.Query()) if stream == nil { http.Error(w, api.StreamNotFound, http.StatusNotFound) return @@ -145,7 +145,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) { } func handlerWS(tr *ws.Transport, _ *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go index cca5220c..d0a6d971 100644 --- a/internal/mp4/mp4.go +++ b/internal/mp4/mp4.go @@ -91,7 +91,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { return } - stream := streams.GetOrPatch(query) + stream, _ := streams.GetOrPatch(query) if stream == nil { http.Error(w, api.StreamNotFound, http.StatusNotFound) return diff --git a/internal/mp4/ws.go b/internal/mp4/ws.go index c880fb58..c1afac24 100644 --- a/internal/mp4/ws.go +++ b/internal/mp4/ws.go @@ -11,7 +11,7 @@ import ( ) func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } @@ -43,7 +43,7 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { } func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } diff --git a/internal/streams/api.go b/internal/streams/api.go index 28f09708..c2b93b91 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -52,8 +52,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) { name = src } - if New(name, query["src"]...) == nil { - http.Error(w, "", http.StatusBadRequest) + if _, err := New(name, query["src"]...); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -69,8 +69,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) { } // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass - if Patch(name, src) == nil { - http.Error(w, "", http.StatusBadRequest) + if _, err := Patch(name, src); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) } case "POST": diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 633ad2d1..2bc65486 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -1,6 +1,7 @@ package streams import ( + "errors" "net/url" "sync" "time" @@ -48,10 +49,14 @@ func Init() { }) } -func New(name string, sources ...string) *Stream { +func New(name string, sources ...string) (*Stream, error) { for _, source := range sources { - if Validate(source) != nil { - return nil + if !HasProducer(source) { + return nil, errors.New("streams: source not supported") + } + + if err := Validate(source); err != nil { + return nil, err } } @@ -61,10 +66,10 @@ func New(name string, sources ...string) *Stream { streams[name] = stream streamsMu.Unlock() - return stream + return stream, nil } -func Patch(name string, source string) *Stream { +func Patch(name string, source string) (*Stream, error) { streamsMu.Lock() defer streamsMu.Unlock() @@ -76,7 +81,7 @@ func Patch(name string, source string) *Stream { // link (alias) streams[name] to streams[rtspName] streams[name] = stream } - return stream + return stream, nil } } @@ -85,40 +90,40 @@ func Patch(name string, source string) *Stream { // link (alias) streams[name] to streams[source] streams[name] = stream } - return stream + return stream, nil } // check if src has supported scheme if !HasProducer(source) { - return nil + return nil, errors.New("streams: source not supported") } - if Validate(source) != nil { - return nil + if err := Validate(source); err != nil { + return nil, err } // check an existing stream with this name if stream, ok := streams[name]; ok { stream.SetSource(source) - return stream + return stream, nil } // create new stream with this name stream := NewStream(source) streams[name] = stream - return stream + return stream, nil } -func GetOrPatch(query url.Values) *Stream { +func GetOrPatch(query url.Values) (*Stream, error) { // check if src param exists source := query.Get("src") if source == "" { - return nil + return nil, errors.New("streams: source empty") } // check if src is stream name if stream := Get(source); stream != nil { - return stream + return stream, nil } // check if name param provided diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index 11e9db89..eca1e12b 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -95,7 +95,7 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) { query := tr.Request.URL.Query() if name := query.Get("src"); name != "" { - stream = streams.GetOrPatch(query) + stream, _ = streams.GetOrPatch(query) mode = core.ModePassiveConsumer log.Debug().Str("src", name).Msg("[webrtc] new consumer") } else if name = query.Get("dst"); name != "" { From 3897f10a4dd7337b027566ae9b172d1953ef7578 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 16 Nov 2025 16:32:58 +0100 Subject: [PATCH 082/241] Add api endpoint to return supported schema --- .vscode/launch.json | 19 +++++++ internal/streams/api.go | 10 ++++ internal/streams/api_test.go | 102 +++++++++++++++++++++++++++++++++++ internal/streams/handlers.go | 13 +++++ internal/streams/streams.go | 1 + 5 files changed, 145 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 internal/streams/api_test.go diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..6242075e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Debug go2rtc", + "type": "go", + "request": "launch", + "mode": "auto", + "env": { + "CGO_ENABLED": "0" + }, + "program": "main.go", + } + ] +} \ No newline at end of file diff --git a/internal/streams/api.go b/internal/streams/api.go index 28f09708..bd9e7f7c 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -176,3 +176,13 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { http.Error(w, "", http.StatusMethodNotAllowed) } } + +func apiSchemes(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + + schemes := GetSupportedSchemes() + api.ResponseJSON(w, schemes) +} diff --git a/internal/streams/api_test.go b/internal/streams/api_test.go new file mode 100644 index 00000000..0a4b4c06 --- /dev/null +++ b/internal/streams/api_test.go @@ -0,0 +1,102 @@ +package streams + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestApiSchemes(t *testing.T) { + // Setup: Register some test handlers and redirects + HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil }) + HandleFunc("rtmp", func(url string) (core.Producer, error) { return nil, nil }) + RedirectFunc("http", func(url string) (string, error) { return "", nil }) + + t.Run("GET request returns schemes", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var schemes []string + err := json.Unmarshal(w.Body.Bytes(), &schemes) + require.NoError(t, err) + require.NotEmpty(t, schemes) + + // Check that our test schemes are in the response + require.Contains(t, schemes, "rtsp") + require.Contains(t, schemes, "rtmp") + require.Contains(t, schemes, "http") + }) + + t.Run("POST request returns method not allowed", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusMethodNotAllowed, w.Code) + }) + + t.Run("PUT request returns method not allowed", func(t *testing.T) { + req := httptest.NewRequest("PUT", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusMethodNotAllowed, w.Code) + }) + + t.Run("DELETE request returns method not allowed", func(t *testing.T) { + req := httptest.NewRequest("DELETE", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusMethodNotAllowed, w.Code) + }) + + t.Run("PATCH request returns method not allowed", func(t *testing.T) { + req := httptest.NewRequest("PATCH", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusMethodNotAllowed, w.Code) + }) +} + +func TestApiSchemesNoDuplicates(t *testing.T) { + // Setup: Register a scheme in both handlers and redirects + HandleFunc("duplicate", func(url string) (core.Producer, error) { return nil, nil }) + RedirectFunc("duplicate", func(url string) (string, error) { return "", nil }) + + req := httptest.NewRequest("GET", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var schemes []string + err := json.Unmarshal(w.Body.Bytes(), &schemes) + require.NoError(t, err) + + // Count occurrences of "duplicate" + count := 0 + for _, scheme := range schemes { + if scheme == "duplicate" { + count++ + } + } + + // Should only appear once + require.Equal(t, 1, count, "scheme 'duplicate' should appear exactly once") +} diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 8922bb8d..91efb975 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -2,7 +2,9 @@ package streams import ( "errors" + "maps" "regexp" + "slices" "strings" "github.com/AlexxIT/go2rtc/pkg/core" @@ -16,6 +18,17 @@ func HandleFunc(scheme string, handler Handler) { handlers[scheme] = handler } +func GetSupportedSchemes() []string { + unique := make(map[string]bool) + for scheme := range handlers { + unique[scheme] = true + } + for scheme := range redirects { + unique[scheme] = true + } + return slices.Collect(maps.Keys(unique)) +} + func HasProducer(url string) bool { if i := strings.IndexByte(url, ':'); i > 0 { scheme := url[:i] diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 633ad2d1..8d5a9fe7 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -28,6 +28,7 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) api.HandleFunc("api/preload", apiPreload) + api.HandleFunc("api/schemes", apiSchemes) if cfg.Publish == nil && cfg.Preload == nil { return From e2b63a4f6c78f6432b938287ed42e1a45cb976b5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 16 Nov 2025 16:40:04 +0100 Subject: [PATCH 083/241] Remove duplicate code --- internal/streams/api_test.go | 42 +++++++++--------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/internal/streams/api_test.go b/internal/streams/api_test.go index 0a4b4c06..414a9380 100644 --- a/internal/streams/api_test.go +++ b/internal/streams/api_test.go @@ -36,40 +36,18 @@ func TestApiSchemes(t *testing.T) { require.Contains(t, schemes, "http") }) - t.Run("POST request returns method not allowed", func(t *testing.T) { - req := httptest.NewRequest("POST", "/api/schemes", nil) - w := httptest.NewRecorder() + t.Run("non-GET requests return method not allowed", func(t *testing.T) { + methods := []string{"POST", "PUT", "DELETE", "PATCH"} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/schemes", nil) + w := httptest.NewRecorder() - apiSchemes(w, req) + apiSchemes(w, req) - require.Equal(t, http.StatusMethodNotAllowed, w.Code) - }) - - t.Run("PUT request returns method not allowed", func(t *testing.T) { - req := httptest.NewRequest("PUT", "/api/schemes", nil) - w := httptest.NewRecorder() - - apiSchemes(w, req) - - require.Equal(t, http.StatusMethodNotAllowed, w.Code) - }) - - t.Run("DELETE request returns method not allowed", func(t *testing.T) { - req := httptest.NewRequest("DELETE", "/api/schemes", nil) - w := httptest.NewRecorder() - - apiSchemes(w, req) - - require.Equal(t, http.StatusMethodNotAllowed, w.Code) - }) - - t.Run("PATCH request returns method not allowed", func(t *testing.T) { - req := httptest.NewRequest("PATCH", "/api/schemes", nil) - w := httptest.NewRecorder() - - apiSchemes(w, req) - - require.Equal(t, http.StatusMethodNotAllowed, w.Code) + require.Equal(t, http.StatusMethodNotAllowed, w.Code) + }) + } }) } From 0bae158e41a16fe8df037e4f355b226d2bbf60ad Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 16 Nov 2025 19:01:06 +0300 Subject: [PATCH 084/241] Code refactoring for #1939 --- .vscode/launch.json | 19 ------------------- internal/streams/api.go | 8 +------- internal/streams/api_test.go | 14 -------------- internal/streams/handlers.go | 16 +++++++++------- 4 files changed, 10 insertions(+), 47 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 6242075e..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - - { - "name": "Debug go2rtc", - "type": "go", - "request": "launch", - "mode": "auto", - "env": { - "CGO_ENABLED": "0" - }, - "program": "main.go", - } - ] -} \ No newline at end of file diff --git a/internal/streams/api.go b/internal/streams/api.go index bd9e7f7c..0cc537c3 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -178,11 +178,5 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { } func apiSchemes(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "", http.StatusMethodNotAllowed) - return - } - - schemes := GetSupportedSchemes() - api.ResponseJSON(w, schemes) + api.ResponseJSON(w, SupportedSchemes()) } diff --git a/internal/streams/api_test.go b/internal/streams/api_test.go index 414a9380..2cb93d2a 100644 --- a/internal/streams/api_test.go +++ b/internal/streams/api_test.go @@ -35,20 +35,6 @@ func TestApiSchemes(t *testing.T) { require.Contains(t, schemes, "rtmp") require.Contains(t, schemes, "http") }) - - t.Run("non-GET requests return method not allowed", func(t *testing.T) { - methods := []string{"POST", "PUT", "DELETE", "PATCH"} - for _, method := range methods { - t.Run(method, func(t *testing.T) { - req := httptest.NewRequest(method, "/api/schemes", nil) - w := httptest.NewRecorder() - - apiSchemes(w, req) - - require.Equal(t, http.StatusMethodNotAllowed, w.Code) - }) - } - }) } func TestApiSchemesNoDuplicates(t *testing.T) { diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 91efb975..9433044b 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -2,9 +2,7 @@ package streams import ( "errors" - "maps" "regexp" - "slices" "strings" "github.com/AlexxIT/go2rtc/pkg/core" @@ -18,15 +16,19 @@ func HandleFunc(scheme string, handler Handler) { handlers[scheme] = handler } -func GetSupportedSchemes() []string { - unique := make(map[string]bool) +func SupportedSchemes() []string { + uniqueKeys := make(map[string]struct{}, len(handlers)+len(redirects)) for scheme := range handlers { - unique[scheme] = true + uniqueKeys[scheme] = struct{}{} } for scheme := range redirects { - unique[scheme] = true + uniqueKeys[scheme] = struct{}{} } - return slices.Collect(maps.Keys(unique)) + resultKeys := make([]string, 0, len(uniqueKeys)) + for key := range uniqueKeys { + resultKeys = append(resultKeys, key) + } + return resultKeys } func HasProducer(url string) bool { From cb22ae78339faff17f9d5b2218fafef993f14719 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 16 Nov 2025 18:31:38 +0300 Subject: [PATCH 085/241] Add security notes to readme --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index b6d2ad93..ee647bf3 100644 --- a/README.md +++ b/README.md @@ -879,6 +879,7 @@ api: listen: ":1984" # default ":1984", HTTP API port ("" - disabled) username: "admin" # default "", Basic auth for WebUI password: "pass" # default "", Basic auth for WebUI + local_auth: true # default false, Enable auth check for localhost requests base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api) static_dir: "www" # default "", folder for static files (custom web interface) origin: "*" # default "", allow CORS requests (only * supported) @@ -1201,6 +1202,27 @@ log: ## Security +> [!IMPORTANT] +> If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server. + +For maximum (paranoid) security, go2rtc has special settings: + +```yaml +app: + # use only allowed modules + modules: [api, rtsp, webrtc, exec, ffmpeg, mjpeg] + +api: + # use only allowed API paths + allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg] + # enable auth for localhost (used together with username and password) + local_auth: true + +exec: + # use only allowed exec paths + allow_paths: [ffmpeg] +``` + By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on. This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config: From 2dc0d58ba73f349e7ff28156068788bc5f99037f Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 16 Nov 2025 19:08:34 +0300 Subject: [PATCH 086/241] Update version to 1.9.12 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index b2959c55..95e59ddd 100644 --- a/main.go +++ b/main.go @@ -46,7 +46,7 @@ import ( ) func main() { - app.Version = "1.9.11" + app.Version = "1.9.12" type module struct { name string From e246e2e75622c1d7c54edebfad7c355155d9d791 Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 17 Nov 2025 12:21:45 +0300 Subject: [PATCH 087/241] Fix WebUI for Hass black theme --- www/main.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/main.js b/www/main.js index 36b04495..c901f300 100644 --- a/www/main.js +++ b/www/main.js @@ -1,6 +1,7 @@ document.head.innerHTML += ` @@ -22,44 +28,413 @@
    + diff --git a/www/schema.json b/www/schema.json new file mode 100644 index 00000000..fbf8c022 --- /dev/null +++ b/www/schema.json @@ -0,0 +1,477 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "go2rtc", + "type": "object", + "additionalProperties": false, + "definitions": { + "listen": { + "type": "string", + "anyOf": [ + { + "type": "string", + "pattern": ":[0-9]{1,5}$" + }, + { + "type": "string", + "const": "" + } + ] + }, + "log_level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error" + ] + } + }, + "properties": { + "api": { + "type": "object", + "properties": { + "listen": { + "default": ":1984", + "examples": [ + "127.0.0.1:8080" + ], + "$ref": "#/definitions/listen" + }, + "username": { + "type": "string", + "examples": [ + "admin" + ] + }, + "password": { + "type": "string" + }, + "local_auth": { + "description": "Will use Auth header from local network", + "type": "boolean", + "default": false + }, + "base_path": { + "type": "string", + "default": "" + }, + "static_dir": { + "type": "string", + "default": "" + }, + "origin": { + "type": "string", + "default": "*" + }, + "tls_listen": { + "$ref": "#/definitions/listen" + }, + "tls_cert": { + "type": "string", + "examples": [ + "/etc/ssl/certs/my_certificate.pem" + ] + }, + "tls_key": { + "type": "string", + "examples": [ + "/etc/ssl/private/my_certificate_key.pem" + ] + }, + "unix_listen": { + "type": "string", + "examples": [ + "/tmp/go2rtc.sock" + ] + } + } + }, + "ffmpeg": { + "type": "object", + "properties": { + "bin": { + "type": "string", + "default": "ffmpeg" + } + }, + "additionalProperties": { + "description": "FFmpeg template", + "type": "string" + } + }, + "hass": { + "type": "object", + "properties": { + "config": { + "type": "string", + "examples": [ + "/config/go2rtc.yaml" + ] + }, + "api": { + "type": "object", + "properties": { + "listen": { + "default": ":8124", + "$ref": "#/definitions/listen" + } + } + } + } + }, + "homekit": { + "type": "object", + "properties": { + "pin": { + "type": "string", + "default": "123-45-678" + }, + "name": { + "type": "string", + "default": "go2rtc" + }, + "device_id": { + "type": "string", + "default": "" + }, + "device_private": { + "type": "string", + "default": "" + }, + "category_id": { + "type": "integer", + "default": 2 + }, + "pairings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "log": { + "type": "object", + "properties": { + "format": { + "type": "string", + "default": "color", + "enum": [ + "color", + "json", + "text" + ] + }, + "level": { + "description": "Defaul log level", + "default": "info", + "$ref": "#/definitions/log_level" + }, + "output": { + "type": "string", + "default": "stdout", + "enum": [ + "", + "stdout", + "stderr" + ] + }, + "time": { + "type": "string", + "default": "UNIXMS", + "anyOf": [ + { + "type": "string", + "enum": [ + "", + "UNIXMS", + "UNIXMICRO", + "UNIXNANO", + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05.999999999Z07:00" + ] + }, + { + "type": "string" + } + ] + }, + "api": { + "$ref": "#/definitions/log_level" + }, + "echo": { + "$ref": "#/definitions/log_level" + }, + "exec": { + "description": "Value `exec: debug` will print stderr", + "$ref": "#/definitions/log_level" + }, + "expr": { + "$ref": "#/definitions/log_level" + }, + "ffmpeg": { + "description": "Will only be displayed with `exec: debug` setting", + "default": "error", + "$ref": "#/definitions/log_level" + }, + "hass": { + "$ref": "#/definitions/log_level" + }, + "hls": { + "$ref": "#/definitions/log_level" + }, + "homekit": { + "$ref": "#/definitions/log_level" + }, + "mp4": { + "$ref": "#/definitions/log_level" + }, + "ngrok": { + "$ref": "#/definitions/log_level" + }, + "onvif": { + "$ref": "#/definitions/log_level" + }, + "rtmp": { + "$ref": "#/definitions/log_level" + }, + "rtsp": { + "$ref": "#/definitions/log_level" + }, + "streams": { + "$ref": "#/definitions/log_level" + }, + "webrtc": { + "$ref": "#/definitions/log_level" + }, + "webtorrent": { + "$ref": "#/definitions/log_level" + } + } + }, + "ngrok": { + "type": "object", + "properties": { + "command": { + "description": "ngrok start --all --config ngrok.yaml", + "type": "string" + } + } + }, + "publish": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "examples": [ + "rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx", + "rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx" + ] + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "rtmp": { + "type": "object", + "properties": { + "listen": { + "examples": [ + ":1935" + ], + "$ref": "#/definitions/listen" + } + } + }, + "rtsp": { + "type": "object", + "properties": { + "listen": { + "default": ":8554", + "$ref": "#/definitions/listen" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "default_query": { + "type": "string", + "default": "" + }, + "pkt_size": { + "type": "integer", + "default": 0 + } + } + }, + "srtp": { + "type": "object", + "properties": { + "listen": { + "default": ":8443", + "$ref": "#/definitions/listen" + } + } + }, + "streams": { + "type": "object", + "additionalProperties": { + "title": "Stream", + "anyOf": [ + { + "description": "Source", + "type": "string", + "examples": [ + "rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif", + "rtsp://username:password@192.168.1.123/stream1", + "rtsp://username:password@192.168.1.123/h264Preview_01_main", + "rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password", + "http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password", + "http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1", + "ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy", + "ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M", + "bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0", + "dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0", + "exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}", + "isapi://username:password@192.168.1.123:80/", + "kasa://username:password@192.168.1.123:19443/https/stream/mixed", + "onvif://username:password@192.168.1.123:80?subtype=0", + "tapo://password@192.168.1.123:8800?channel=0&subtype=0", + "webtorrent:?share=xxx&pwd=xxx" + ] + }, + { + "type": "array", + "items": { + "description": "Source", + "type": "string" + } + } + ] + } + }, + "webrtc": { + "type": "object", + "properties": { + "listen": { + "default": ":8555", + "$ref": "#/definitions/listen" + }, + "candidates": { + "type": "array", + "items": { + "type": "string", + "examples": [ + "192.168.1.123", + "stun:stun.l.google.com:19302" + ] + } + }, + "ice_servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": { + "type": "string", + "examples": [ + "stun:stun.l.google.com:19302", + "turn:123.123.123.123:3478" + ] + } + }, + "username": { + "type": "string" + }, + "credential": { + "type": "string" + } + } + } + }, + "filters": { + "type": "object", + "properties": { + "candidates": { + "description": "Keep only these candidates", + "type": "array", + "items": { + "type": "string" + } + }, + "interfaces": { + "description": "Keep only these interfaces", + "type": "array", + "items": { + "type": "string" + } + }, + "ips": { + "description": "Keep only these IP-addresses", + "type": "array", + "items": { + "type": "string" + } + }, + "networks": { + "description": "Use only these network types", + "type": "array", + "items": { + "enum": [ + "tcp4", + "tcp6", + "udp4", + "udp6" + ], + "type": "string" + } + }, + "udp_ports": { + "description": "Use only these UDP ports range [min, max]", + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 2, + "minItems": 2 + } + } + } + } + }, + "webtorrent": { + "type": "object", + "properties": { + "trackers": { + "type": "array", + "items": { + "type": "string" + } + }, + "shares": { + "type": "object", + "properties": { + "pwd": { + "type": "string" + }, + "src": { + "type": "string" + } + } + } + } + } + } +} diff --git a/www/static.go b/www/static.go index 01f50906..064fec36 100644 --- a/www/static.go +++ b/www/static.go @@ -4,4 +4,5 @@ import "embed" //go:embed *.html //go:embed *.js +//go:embed *.json var Static embed.FS From ce9138b3548c8734453395c55376e15d9c928337 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sun, 14 Dec 2025 04:52:35 +0300 Subject: [PATCH 120/241] update monaco to 0.55.1 --- www/config.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/config.html b/www/config.html index 345bddd3..62cceab4 100644 --- a/www/config.html +++ b/www/config.html @@ -28,10 +28,10 @@
    - + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + + +
    From b0f46bc919080ff2c358c056306451b4e8a7e76c Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 17:17:52 +0300 Subject: [PATCH 123/241] Fix backchannel audio for xiaomi isa.camera.hlc6 --- pkg/xiaomi/backchannel.go | 28 +++++++++++++++++++++------- pkg/xiaomi/producer.go | 2 ++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/pkg/xiaomi/backchannel.go b/pkg/xiaomi/backchannel.go index 3e1b0f4c..0c4cefc4 100644 --- a/pkg/xiaomi/backchannel.go +++ b/pkg/xiaomi/backchannel.go @@ -4,12 +4,11 @@ import ( "time" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" "github.com/pion/rtp" ) -const size8bit40ms = 8000 * 0.040 - func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { if err := p.client.SpeakerStart(); err != nil { return err @@ -23,11 +22,26 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv case core.CodecPCMA: var buf []byte - sender.Handler = func(pkt *rtp.Packet) { - buf = append(buf, pkt.Payload...) - for len(buf) >= size8bit40ms { - _ = p.client.WriteAudio(miss.CodecPCMA, buf[:size8bit40ms]) - buf = buf[size8bit40ms:] + if p.model == "isa.camera.hlc6" { + dst := &core.Codec{Name: core.CodecPCML, ClockRate: 8000} + transcode := pcm.Transcode(dst, track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, transcode(pkt.Payload)...) + const size = 2 * 8000 * 0.040 // 16bit 40ms + for len(buf) >= size { + _ = p.client.WriteAudio(miss.CodecPCM, buf[:size]) + buf = buf[size:] + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, pkt.Payload...) + const size = 8000 * 0.040 // 8bit 40 ms + for len(buf) >= size { + _ = p.client.WriteAudio(miss.CodecPCMA, buf[:size]) + buf = buf[size:] + } } } case core.CodecOpus: diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go index 6a7537e4..f9164d0b 100644 --- a/pkg/xiaomi/producer.go +++ b/pkg/xiaomi/producer.go @@ -16,6 +16,7 @@ import ( type Producer struct { core.Connection client *miss.Client + model string } func Dial(rawURL string) (core.Producer, error) { @@ -60,6 +61,7 @@ func Dial(rawURL string) (core.Producer, error) { Transport: client, }, client: client, + model: query.Get("model"), }, nil } From 57b0ace8025590fe88cd44844b0d577b7ffafbd3 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 17:18:25 +0300 Subject: [PATCH 124/241] Add vendor name to xiaomi unsupported vendor message --- pkg/xiaomi/miss/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/xiaomi/miss/client.go b/pkg/xiaomi/miss/client.go index 49def2ae..a1e3ded9 100644 --- a/pkg/xiaomi/miss/client.go +++ b/pkg/xiaomi/miss/client.go @@ -23,8 +23,8 @@ func Dial(rawURL string) (*Client, error) { } query := u.Query() - if query.Get("vendor") != "cs2" { - return nil, fmt.Errorf("miss: unsupported vendor") + if s := query.Get("vendor"); s != "cs2" { + return nil, fmt.Errorf("miss: unsupported vendor %s", s) } clientPrivate := query.Get("client_private") From 7119384184c819b8b39ef7179920cca2ee11216f Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 17:58:17 +0300 Subject: [PATCH 125/241] Fix backchannel audio for xiaomi chuangmi.camera.72ac1 --- pkg/opus/opus.go | 31 +++++++++++++++++++++++++++++++ pkg/xiaomi/backchannel.go | 19 +++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pkg/opus/opus.go b/pkg/opus/opus.go index 95956b93..fb67c66d 100644 --- a/pkg/opus/opus.go +++ b/pkg/opus/opus.go @@ -85,3 +85,34 @@ func parseFrames(c byte) byte { } return 0xFF } + +func JoinFrames(b1, b2 []byte) []byte { + // can't join + if b1[0]&0b11 != 0 || b2[0]&0b11 != 0 { + return append(b1, b2...) + } + + size1, size2 := len(b1)-1, len(b2)-1 + + // join same sizes + if size1 == size2 { + b := make([]byte, 1+size1+size2) + copy(b, b1) + copy(b[1+size1:], b2[1:]) + b[0] |= 0b01 + return b + } + + b := make([]byte, 1, 3+size1+size2) + b[0] = b1[0] | 0b10 + if size1 >= 252 { + b0 := 252 + byte(size1)&0b11 + b = append(b, b0, byte(size1/4)-b0) + } else { + b = append(b, byte(size1)) + } + + b = append(b, b1[1:]...) + b = append(b, b2[1:]...) + return b +} diff --git a/pkg/xiaomi/backchannel.go b/pkg/xiaomi/backchannel.go index 0c4cefc4..0224a594 100644 --- a/pkg/xiaomi/backchannel.go +++ b/pkg/xiaomi/backchannel.go @@ -4,6 +4,7 @@ import ( "time" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/opus" "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" "github.com/pion/rtp" @@ -45,8 +46,22 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv } } case core.CodecOpus: - sender.Handler = func(pkt *rtp.Packet) { - _ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload) + if p.model == "chuangmi.camera.72ac1" { + var buf []byte + sender.Handler = func(pkt *rtp.Packet) { + if buf == nil { + buf = pkt.Payload + } else { + // convert two 20ms to one 40ms + buf = opus.JoinFrames(buf, pkt.Payload) + _ = p.client.WriteAudio(miss.CodecOPUS, buf) + buf = nil + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + _ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload) + } } } From 8636e96379cd2679d7d952b636800bb163ed7205 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 17:59:07 +0300 Subject: [PATCH 126/241] Change ffmpeg transcoder from opus to opus/16000 --- internal/ffmpeg/producer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ffmpeg/producer.go b/internal/ffmpeg/producer.go index 2f731fc1..fb044467 100644 --- a/internal/ffmpeg/producer.go +++ b/internal/ffmpeg/producer.go @@ -96,7 +96,7 @@ func (p *Producer) newURL() string { codec := receiver.Codec switch codec.Name { case core.CodecOpus: - s += "#audio=opus" + s += "#audio=opus/16000" case core.CodecAAC: s += "#audio=aac/16000" case core.CodecPCML: From 28821c41e0c21901babd612dfcb4baf53b8d6983 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 20:17:13 +0300 Subject: [PATCH 127/241] Code refactoring for #1977 --- internal/streams/api.go | 15 +++------- internal/streams/preload.go | 60 ++++++++++++++----------------------- internal/streams/streams.go | 4 +-- 3 files changed, 28 insertions(+), 51 deletions(-) diff --git a/internal/streams/api.go b/internal/streams/api.go index 53879252..d6142eb0 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -130,21 +130,14 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { } func apiPreload(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - src := query.Get("src") - // GET - return all preloads if r.Method == "GET" { api.ResponseJSON(w, GetPreloads()) return } - // check if stream exists - stream := Get(src) - if stream == nil { - http.Error(w, "", http.StatusNotFound) - return - } + query := r.URL.Query() + src := query.Get("src") switch r.Method { case "PUT": @@ -159,7 +152,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { rawQuery := query.Encode() - if err := AddPreload(stream, rawQuery); err != nil { + if err := AddPreload(src, rawQuery); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -169,7 +162,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { } case "DELETE": - if err := DelPreload(stream); err != nil { + if err := DelPreload(src); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/streams/preload.go b/internal/streams/preload.go index 313c0c73..447b5ac3 100644 --- a/internal/streams/preload.go +++ b/internal/streams/preload.go @@ -1,28 +1,24 @@ package streams import ( - "errors" + "fmt" + "maps" "net/url" "sync" "github.com/AlexxIT/go2rtc/pkg/probe" ) -type preload struct { - cons *probe.Probe - query string +type Preload struct { + stream *Stream // Don't output the stream to JSON to not worry about its secrets. + Cons *probe.Probe `json:"consumer"` + Query string `json:"query"` } -var preloads = map[*Stream]*preload{} +var preloads = map[string]*Preload{} var preloadsMu sync.Mutex -func Preload(stream *Stream, rawQuery string) { - if err := AddPreload(stream, rawQuery); err != nil { - log.Error().Err(err).Caller().Send() - } -} - -func AddPreload(stream *Stream, rawQuery string) error { +func AddPreload(name, rawQuery string) error { if rawQuery == "" { rawQuery = "video&audio" } @@ -35,51 +31,39 @@ func AddPreload(stream *Stream, rawQuery string) error { preloadsMu.Lock() defer preloadsMu.Unlock() - if p := preloads[stream]; p != nil { - stream.RemoveConsumer(p.cons) + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) } + stream := Get(name) + if stream == nil { + return fmt.Errorf("streams: stream not found: %s", name) + } cons := probe.Create("preload", query) if err = stream.AddConsumer(cons); err != nil { return err } - preloads[stream] = &preload{cons: cons, query: rawQuery} + preloads[name] = &Preload{stream: stream, Cons: cons, Query: rawQuery} return nil } -func DelPreload(stream *Stream) error { +func DelPreload(name string) error { preloadsMu.Lock() defer preloadsMu.Unlock() - if p := preloads[stream]; p != nil { - stream.RemoveConsumer(p.cons) - delete(preloads, stream) + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) + delete(preloads, name) return nil } - return errors.New("streams: preload not found") + return fmt.Errorf("streams: preload not found: %s", name) } -func GetPreloads() map[string]string { - streamsMu.Lock() - defer streamsMu.Unlock() - +func GetPreloads() map[string]*Preload { preloadsMu.Lock() defer preloadsMu.Unlock() - - // build reverse lookup: stream -> name - streamNames := make(map[*Stream]string, len(streams)) - for name, stream := range streams { - streamNames[stream] = name - } - - result := make(map[string]string, len(preloads)) - for stream, p := range preloads { - if name, ok := streamNames[stream]; ok { - result[name] = p.query - } - } - return result + return maps.Clone(preloads) } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 433f9d36..f3b8df03 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -43,8 +43,8 @@ func Init() { } } for name, rawQuery := range cfg.Preload { - if stream := Get(name); stream != nil { - Preload(stream, rawQuery) + if err := AddPreload(name, rawQuery); err != nil { + log.Error().Err(err).Caller().Send() } } }) From 7eaa4a1b55abc3537840decc02f7f98b01162840 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 21:28:37 +0300 Subject: [PATCH 128/241] Code refactoring for #1966 --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4babd652..efcbc735 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ Available source types: - [hass](#source-hass) - Home Assistant integration - [isapi](#source-isapi) - two-way audio for Hikvision (ISAPI) cameras - [roborock](#source-roborock) - Roborock vacuums with cameras -- [Doorbird](#source-doorbird) - Doorbird cameras +- [doorbird](#source-doorbird) - Doorbird cameras with [two way audio](#two-way-audio) support - [webrtc](#source-webrtc) - WebRTC/WHEP sources - [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc @@ -228,7 +228,7 @@ Supported sources: - [TP-Link Tapo](#source-tapo) cameras - [Hikvision ISAPI](#source-isapi) cameras - [Roborock vacuums](#source-roborock) models with cameras -- [Doorbird](#source-doorbird) - Doorbird cameras +- [Doorbird](#source-doorbird) cameras - [Exec](#source-exec) audio on server - [Ring](#source-ring) cameras - [Tuya](#source-tuya) cameras @@ -728,10 +728,10 @@ This source type supports Doorbird devices including MJPEG stream, audio stream ```yaml streams: doorbird1: - - rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp #RTSP stream - - doorbird://admin:password@192.168.1.123?media=video #MJPEG stream - - doorbird://admin:password@192.168.1.123?media=audio #Audio stream - - doorbird://admin:password@192.168.1.123 #Backchannel connection + - rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream + - doorbird://admin:password@192.168.1.123?media=video # MJPEG stream + - doorbird://admin:password@192.168.1.123?media=audio # audio stream + - doorbird://admin:password@192.168.1.123 # two-way audio ``` #### Source: WebRTC From df484cc904763769c619e781de44c59fba73d997 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 21:45:44 +0300 Subject: [PATCH 129/241] Add eseecloud source to readme --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index efcbc735..270e2af0 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Available source types: - [homekit](#source-homekit) - streaming from HomeKit Camera - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR +- [eseecloud](#source-eseecloud) - streaming from ESeeCloud/dvr163 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 @@ -535,6 +536,15 @@ streams: - dvrip://username:password@192.168.1.123:34567?backchannel=1 ``` +#### Source: EseeCloud + +*[New in v1.9.10](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10)* + +```yaml +streams: + camera1: eseecloud://user:pass@192.168.1.123:80/livestream/12 +``` + #### Source: Tapo *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* From 2409bb56d71ef5d3f431fe6eeec6298723528c0a Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 21:54:06 +0300 Subject: [PATCH 130/241] Move tuya docs to separate page --- README.md | 35 ++--------------------------------- internal/tuya/README.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 internal/tuya/README.md diff --git a/README.md b/README.md index 270e2af0..bd3d01bd 100644 --- a/README.md +++ b/README.md @@ -593,40 +593,9 @@ 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`. +*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* -**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 -``` +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/tuya/README.md). #### Source: GoPro diff --git a/internal/tuya/README.md b/internal/tuya/README.md new file mode 100644 index 00000000..b37a27c3 --- /dev/null +++ b/internal/tuya/README.md @@ -0,0 +1,39 @@ +# Tuya + +*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* + +[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). + +## Configuration + +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 +``` From d734140eaf0772b88116a1cf504292adf2e423f3 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 22:31:13 +0300 Subject: [PATCH 131/241] Update reamde --- README.md | 61 ++++++++++++++++--------------------------------------- 1 file changed, 17 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index bd3d01bd..c4765095 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,12 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4), [HomeKit](#module-homekit) [HLS](#module-hls) or [MJPEG](#module-mjpeg) - [publish](#publish-stream) any source to popular streaming services (YouTube, Telegram, etc.) - first project in the World with support streaming from [HomeKit Cameras](#source-homekit) -- support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5)) - on-the-fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg) - play audio files and live streams on some cameras with [speaker](#stream-to-camera) -- multi-source 2-way [codecs negotiation](#codecs-negotiation) +- multi-source two-way [codecs negotiation](#codecs-negotiation) - mixing tracks from different sources to single stream - auto-match client-supported codecs - - [2-way audio](#two-way-audio) for some cameras + - [two-way audio](#two-way-audio) for some cameras - can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary) **Inspired by:** @@ -1309,25 +1308,22 @@ Some examples: ## Codecs madness -`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. It's all about patents and money; you can't do anything about it. +`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. -| Device | WebRTC | MSE | HTTP* | HLS | -|--------------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| -| *latency* | best | medium | bad | bad | -| - Desktop Chrome 107+
    - Desktop Edge
    - Android Chrome 107+ | H264
    PCMU, PCMA
    OPUS | H264, H265*
    AAC, FLAC*
    OPUS | H264, H265*
    AAC, FLAC*
    OPUS, MP3 | no | -| Desktop Firefox | H264
    PCMU, PCMA
    OPUS | H264
    AAC, FLAC*
    OPUS | H264
    AAC, FLAC*
    OPUS | no | -| - Desktop Safari 14+
    - iPad Safari 14+
    - iPhone Safari 17.1+ | H264, H265*
    PCMU, PCMA
    OPUS | H264, H265
    AAC, FLAC* | **no!** | H264, H265
    AAC, FLAC* | -| iPhone Safari 14+ | H264, H265*
    PCMU, PCMA
    OPUS | **no!** | **no!** | H264, H265
    AAC, FLAC* | -| macOS [Hass App][1] | no | no | no | H264, H265
    AAC, FLAC* | +| Device | WebRTC | MSE | HTTP* | HLS | +|--------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| +| *latency* | best | medium | bad | bad | +| Desktop Chrome 136+
    Desktop Edge
    Android Chrome 136+ | H264, H265*
    PCMU, PCMA
    OPUS | H264, H265*
    AAC, FLAC*
    OPUS | H264, H265*
    AAC, FLAC*
    OPUS, MP3 | no | +| Desktop Firefox | H264
    PCMU, PCMA
    OPUS | H264
    AAC, FLAC*
    OPUS | H264
    AAC, FLAC*
    OPUS | no | +| Desktop Safari 14+
    iPad Safari 14+
    iPhone Safari 17.1+ | H264, H265*
    PCMU, PCMA
    OPUS | H264, H265
    AAC, FLAC* | **no!** | H264, H265
    AAC, FLAC* | +| iPhone Safari 14+ | H264, H265*
    PCMU, PCMA
    OPUS | **no!** | **no!** | H264, H265
    AAC, FLAC* | +| macOS [Hass App][1] | no | no | no | H264, H265
    AAC, FLAC* | [1]: https://apps.apple.com/app/home-assistant/id1099568401 -`HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end - -- Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding) -- Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/) -- Desktop Safari H265: Menu > Develop > Experimental > WebRTC H265 -- iOS Safari H265: Settings > Safari > Advanced > Experimental > WebRTC H265 +- `HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end +- `WebRTC H265` - supported in [Chrome 136+](https://developer.chrome.com/release-notes/136), supported in [Safari 18+](https://developer.apple.com/documentation/safari-release-notes/safari-18-release-notes) +- `MSE iPhone` - supported in [iOS 17.1+](https://webkit.org/blog/14735/webkit-features-in-safari-17-1/) **Audio** @@ -1338,7 +1334,7 @@ Some examples: **Apple devices** - all Apple devices don't support HTTP progressive streaming -- iPhones don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple +- old iPhone firmwares don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple - HLS is the worst technology for **live** streaming, it still exists only because of iPhones **Codec names** @@ -1411,7 +1407,8 @@ streams: ## Projects using go2rtc -- [Frigate](https://frigate.video/) 0.12+ - open-source NVR built around real-time AI object detection +- [Home Assistant](https://www.home-assistant.io/) [2024.11+](https://www.home-assistant.io/integrations/go2rtc/) - top open-source smart home project +- [Frigate](https://frigate.video/) [0.12+](https://docs.frigate.video/guides/configuring_go2rtc/) - open-source NVR built around real-time AI object detection - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card) - custom card for Home Assistant - [OpenIPC](https://github.com/OpenIPC/firmware/tree/master/general/package/go2rtc) - alternative IP camera firmware from an open community - [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - custom firmware for Wyze cameras @@ -1452,27 +1449,3 @@ streams: **Snapshots to Telegram** [read more](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram) - -## FAQ - -**Q. What's the difference between go2rtc, WebRTC Camera and RTSPtoWebRTC?** - -**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. It is compatible with native Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration. So you [can use](#module-hass) default Lovelace Picture Entity or Picture Glance. - -**Q. Should I use the go2rtc add-on or WebRTC Camera integration?** - -**go2rtc** is more than just viewing your stream online with WebRTC/MSE/HLS/etc. You can use it all the time for your various tasks. But every time Hass is rebooted, all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks. - -Basic users can use the **WebRTC Camera** integration. Advanced users can use the go2rtc add-on or the Frigate 0.12+ add-on. - -**Q. Which RTSP link should I use inside Hass?** - -You can use a direct link to your cameras there (as you always do). **go2rtc** supports zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Hass. And your cameras will have multiple connections. Some from Hass directly and one from **go2rtc**. - -Also, you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this add-on with additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as a source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connections - some from Hass via RTSP protocol, some from your browser via WebRTC/MSE/HLS protocols. - -Use any config that you like. - -**Q. What about Lovelace card with support for two-way audio?** - -At this moment, I am focused on improving stability and adding new features to **go2rtc**. Maybe someone could write such a card themselves. It's not difficult, I have [some sketches](https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html). From 353262307b96927c3aaf147b9bde02e8d0b5747d Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 22:34:43 +0300 Subject: [PATCH 132/241] Update links to icons in resources --- website/manifest.json | 4 ++-- www/stream.html | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/website/manifest.json b/website/manifest.json index b33a6064..1bfd3571 100644 --- a/website/manifest.json +++ b/website/manifest.json @@ -2,12 +2,12 @@ "name": "go2rtc", "icons": [ { - "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-192x192.png", + "src": "https://go2rtc.org/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-512x512.png", + "src": "https://go2rtc.org/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } diff --git a/www/stream.html b/www/stream.html index 90797ef2..de7ad123 100644 --- a/www/stream.html +++ b/www/stream.html @@ -2,9 +2,9 @@ - - - + + + stream - go2rtc - -
    - - - -
    - - - +go2rtc +github.com/AlexxIT/go2rtc - \ No newline at end of file + diff --git a/website/webtorrent/index.html b/website/webtorrent/index.html new file mode 100644 index 00000000..6a4b9057 --- /dev/null +++ b/website/webtorrent/index.html @@ -0,0 +1,189 @@ + + + + + webtorrent - go2rtc + + + + +
    + + + +
    + + + + + \ No newline at end of file diff --git a/www/README.md b/www/README.md index 4da49485..e4d36cd2 100644 --- a/www/README.md +++ b/www/README.md @@ -81,11 +81,11 @@ https://webrtc.org/getting-started/unified-plan-transition-guide?hl=en ```html - + - + - + ``` ## Useful links diff --git a/www/links.html b/www/links.html index f2b35c22..466d30a5 100644 --- a/www/links.html +++ b/www/links.html @@ -186,7 +186,7 @@ Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`; const share = document.getElementById('shareget'); - share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`; + share.href = `https://go2rtc.org/webtorrent/#${share.dataset.auth}&media=${media}`; } function share(method) { From a09e1b2f902c342fba7521e29c626147698f3d37 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 27 Dec 2025 17:43:19 +0300 Subject: [PATCH 162/241] Update openapi --- api/openapi.yaml | 770 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 687 insertions(+), 83 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 6ec0b492..b6110572 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,8 +2,8 @@ openapi: 3.1.0 info: title: go2rtc + version: 1.9.13 license: { name: MIT,url: https://opensource.org/licenses/MIT } - version: 1.0.0 contact: { url: https://github.com/AlexxIT/go2rtc } description: | Ultimate camera streaming application with support RTSP, RTMP, HTTP-FLV, WebRTC, MSE, HLS, MP4, MJPEG, HomeKit, FFmpeg, etc. @@ -11,6 +11,28 @@ info: servers: - url: http://localhost:1984 +tags: + - name: Application + description: "[Module: API](https://github.com/AlexxIT/go2rtc#module-api)" + - name: Config + description: "[Configuration](https://github.com/AlexxIT/go2rtc#configuration)" + - name: Streams list + description: "[Module: Streams](https://github.com/AlexxIT/go2rtc#module-streams)" + - name: Consume stream + - name: HLS + - name: Snapshot + - name: Produce stream + - name: WebSocket + description: "WebSocket API endpoint: `/api/ws` (see `api/README.md`)" + - name: Discovery + - name: HomeKit + - name: ONVIF + - name: RTSPtoWebRTC + - name: WebTorrent + description: "[Module: WebTorrent](https://github.com/AlexxIT/go2rtc#module-webtorrent)" + - name: FFmpeg + - name: Debug + components: parameters: stream_src_path: @@ -20,6 +42,7 @@ components: required: true schema: { type: string } example: camera1 + stream_dst_path: name: dst in: path @@ -27,6 +50,7 @@ components: required: true schema: { type: string } example: camera1 + stream_src_query: name: src in: query @@ -34,6 +58,15 @@ components: required: true schema: { type: string } example: camera1 + + hls_session_id_path: + name: id + in: path + description: HLS session ID (passed as query param `id`) + required: true + schema: { type: string } + example: DvmHdd9w + mp4_filter: name: mp4 in: query @@ -43,6 +76,7 @@ components: type: string enum: [ "", flac, all ] example: flac + video_filter: name: video in: query @@ -51,6 +85,7 @@ components: type: string enum: [ "", all, h264, h265, mjpeg ] example: h264,h265 + audio_filter: name: audio in: query @@ -59,35 +94,20 @@ components: type: string enum: [ "", all, aac, opus, pcm, pcmu, pcma ] example: aac + responses: discovery: description: "" content: application/json: example: { streams: [ { "name": "Camera 1","url": "..." } ] } + webtorrent: description: "" content: application/json: example: { share: AKDypPy4zz, pwd: H0Km1HLTTP } -tags: - - name: Application - description: "[Module: API](https://github.com/AlexxIT/go2rtc#module-api)" - - name: Config - description: "[Configuration](https://github.com/AlexxIT/go2rtc#configuration)" - - name: Streams list - description: "[Module: Streams](https://github.com/AlexxIT/go2rtc#module-streams)" - - name: Consume stream - - name: Snapshot - - name: Produce stream - - name: Discovery - - name: ONVIF - - name: RTSPtoWebRTC - - name: WebTorrent - description: "[Module: WebTorrent](https://github.com/AlexxIT/go2rtc#module-webtorrent)" - - name: Debug - paths: /api: get: @@ -98,7 +118,17 @@ paths: description: "" content: application/json: - example: { config_path: "/config/go2rtc.yaml",host: "192.168.1.123:1984",rtsp: { listen: ":8554",default_query: "video&audio" },version: "1.5.0" } + schema: + type: object + properties: + config_path: { type: string, example: "/config/go2rtc.yaml" } + host: { type: string, example: "192.168.1.123:1984" } + rtsp: + type: object + properties: + listen: { type: string, example: ":8554" } + default_query: { type: string, example: "video&audio" } + version: { type: string, example: "1.9.12" } /api/exit: post: @@ -112,17 +142,39 @@ paths: schema: { type: integer } example: 100 responses: - default: - description: Default response + default: + description: "" /api/restart: post: - summary: Restart Daemon + summary: Restart daemon description: Restarts the daemon. tags: [ Application ] responses: - default: - description: Default response + default: + description: "" + + /api/log: + get: + summary: Get in-memory logs buffer + description: | + Returns current log output from the in-memory circular buffer. + tags: [ Application ] + responses: + "200": + description: OK + content: + application/jsonlines: + example: | + {"level":"info","version":"1.9.13","platform":"linux/amd64","revision":"dfe4755","time":1766841087331,"message":"go2rtc"} + delete: + summary: Clear in-memory logs buffer + tags: [ Application ] + responses: + "200": + description: "" + content: + text/plain: { example: "" } /api/config: get: @@ -133,6 +185,8 @@ paths: description: "" content: application/yaml: { example: "streams:..." } + "404": + description: Config file not found post: summary: Rewrite main config file tags: [ Config ] @@ -140,8 +194,8 @@ paths: content: "*/*": { example: "streams:..." } responses: - default: - description: Default response + default: + description: "" patch: summary: Merge changes to main config file tags: [ Config ] @@ -149,8 +203,8 @@ paths: content: "*/*": { example: "streams:..." } responses: - default: - description: Default response + default: + description: "" @@ -162,7 +216,16 @@ paths: "200": description: "" content: - application/json: { example: { camera1: { producers: [ ],consumers: [ ] } } } + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + producers: + type: array + consumers: + type: array put: summary: Create new stream tags: [ Streams list ] @@ -180,8 +243,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: "" patch: summary: Update stream source tags: [ Streams list ] @@ -199,8 +262,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: "" delete: summary: Delete stream tags: [ Streams list ] @@ -212,8 +275,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: "" post: summary: Send stream from source to destination description: "[Stream to camera](https://github.com/AlexxIT/go2rtc#stream-to-camera)" @@ -232,10 +295,26 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response - + default: + description: "" + /api/streams.dot: + get: + summary: Get streams graph in Graphviz DOT format + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream name filter. Repeat `src` to include multiple streams. + required: false + schema: { type: string } + example: camera1 + responses: + "200": + description: OK + content: + text/vnd.graphviz: + example: "digraph { ... }" /api/preload: get: @@ -245,7 +324,17 @@ paths: "200": description: "" content: - application/json: { example: { camera1: "video&audio", camera2: "video" } } + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + consumer: + type: object + query: + type: string + example: "video&audio" put: summary: Preload new stream tags: [ Streams list ] @@ -275,8 +364,8 @@ paths: schema: { type: string } example: all,aac,opus,... responses: - default: - description: Default response + default: + description: "" delete: summary: Delete preloaded stream tags: [ Streams list ] @@ -288,9 +377,22 @@ paths: schema: { type: string } example: "camera1" responses: - default: - description: Default response + default: + description: "" + /api/schemes: + get: + summary: Get supported source URL schemes + tags: [ Streams list ] + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: { type: string } + example: [ rtsp, rtmp, webrtc, ffmpeg, hass ] /api/streams?src={src}: @@ -304,7 +406,17 @@ paths: description: "" content: application/json: - example: { producers: [ { url: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0" } ], consumers: [ ] } + schema: + type: object + additionalProperties: + type: object + properties: + producers: + type: array + items: { type: object } + consumers: + type: array + items: { type: object } /api/webrtc?src={src}: post: @@ -324,7 +436,6 @@ paths: application/json: { example: { type: offer, sdp: "v=0..." } } "application/sdp": { example: "v=0..." } "*/*": { example: "v=0..." } - responses: "200": description: "Response on JSON or raw SDP" @@ -355,6 +466,16 @@ paths: required: false schema: { type: string } example: camera1.mp4 + - name: rotate + in: query + description: "Rotate video (degrees). Supported values: 90, 180, 270." + required: false + schema: { type: integer, enum: [ 90, 180, 270 ] } + - name: scale + in: query + description: Scale video in format `width:height` + required: false + schema: { type: string, example: "1280:720" } - $ref: "#/components/parameters/mp4_filter" - $ref: "#/components/parameters/video_filter" - $ref: "#/components/parameters/audio_filter" @@ -367,7 +488,7 @@ paths: get: summary: Get stream in HLS format description: "[Module: HLS](https://github.com/AlexxIT/go2rtc#module-hls)" - tags: [ Consume stream ] + tags: [ Consume stream, HLS ] parameters: - $ref: "#/components/parameters/stream_src_path" - $ref: "#/components/parameters/mp4_filter" @@ -378,6 +499,62 @@ paths: description: "" content: { application/vnd.apple.mpegurl: { example: "" } } + /api/hls/playlist.m3u8?id={id}: + get: + summary: Get HLS media playlist for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + application/vnd.apple.mpegurl: { example: "" } + "404": + description: Session not found + + /api/hls/segment.ts?id={id}: + get: + summary: Get HLS MPEG-TS segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/mp2t: { example: "" } + "404": + description: Segment or session not found + + /api/hls/init.mp4?id={id}: + get: + summary: Get HLS fMP4 init segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/mp4: { example: "" } + "404": + description: Segment or session not found + + /api/hls/segment.m4s?id={id}: + get: + summary: Get HLS fMP4 media segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/iso.segment: { example: "" } + "404": + description: Segment or session not found + /api/stream.mjpeg?src={src}: get: summary: Get stream in MJPEG format @@ -390,7 +567,91 @@ paths: description: "" content: { multipart/x-mixed-replace: { example: "" } } + /api/stream.ascii?src={src}: + get: + summary: Get stream in ASCII-art format (ANSI escape codes) + description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)" + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - name: color + in: query + description: Foreground mode (`8`, `256`, `rgb` or ANSI SGR code) + required: false + schema: { type: string } + - name: back + in: query + description: Background mode (`8`, `256`, `rgb` or ANSI SGR code) + required: false + schema: { type: string } + - name: text + in: query + description: Charset preset (empty/default, `block`) or custom characters + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + text/plain: { example: "" } + "404": + description: Stream not found + /api/stream.y4m?src={src}: + get: + summary: Get stream in YUV4MPEG2 format (y4m) + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + application/octet-stream: { example: "" } + "404": + description: Stream not found + + /api/stream.ts?src={src}: + get: + summary: Get stream in MPEG-TS format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + video/mp2t: { example: "" } + "404": + description: Stream not found + + /api/stream.aac?src={src}: + get: + summary: Get stream audio in AAC (ADTS) format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + audio/aac: { example: "" } + "404": + description: Stream not found + + /api/stream.flv?src={src}: + get: + summary: Get stream in FLV format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + video/x-flv: { example: "" } + "404": + description: Stream not found /api/frame.jpeg?src={src}: get: @@ -399,10 +660,37 @@ paths: tags: [ Snapshot ] parameters: - $ref: "#/components/parameters/stream_src_path" + - name: name + in: query + description: Optional stream name to create/update if `src` is a URL + required: false + schema: { type: string } + - name: width + in: query + description: "Scale output width (alias: `w`)" + required: false + schema: { type: integer, minimum: 1 } + - name: height + in: query + description: "Scale output height (alias: `h`)" + required: false + schema: { type: integer, minimum: 1 } + - name: rotate + in: query + description: "Rotate output (degrees). Supported values: 90, 180, 270." + required: false + schema: { type: integer, enum: [ 90, 180, 270 ] } + - name: hardware + in: query + description: "Hardware acceleration engine for FFmpeg snapshot transcoding (alias: `hw`)" + required: false + schema: { type: string } responses: - 200: + "200": description: "" - content: { image/jpeg: { example: "" } } + content: + image/jpeg: { example: "" } + /api/frame.mp4?src={src}: get: summary: Get snapshot in MP4 format @@ -410,23 +698,51 @@ paths: tags: [ Snapshot ] parameters: - $ref: "#/components/parameters/stream_src_path" + - name: filename + in: query + description: Download as a file with this name + required: false + schema: { type: string } + example: camera1.mp4 responses: 200: description: "" - content: { video/mp4: { example: "" } } + content: + video/mp4: { example: "" } /api/webrtc?dst={dst}: post: - summary: Post stream in WebRTC format + summary: Post stream in WebRTC format (WHIP) description: "[Incoming: WebRTC/WHIP](https://github.com/AlexxIT/go2rtc#incoming-webrtcwhip)" tags: [ Produce stream ] parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + "201": + description: Created + headers: + Location: + description: Resource URL for session + schema: { type: string } + content: + application/sdp: { example: "v=0..." } + "404": + description: Stream not found + + /api/stream?dst={dst}: + post: + summary: Post stream in auto-detected format + description: | + Incoming source with automatic format detection. Use for pushing a stream into an existing `dst` stream. + tags: [ Produce stream ] + parameters: + - $ref: "#/components/parameters/stream_dst_path" + responses: + default: + description: "" + /api/stream.flv?dst={dst}: post: summary: Post stream in FLV format @@ -435,8 +751,9 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: "" + /api/stream.ts?dst={dst}: post: summary: Post stream in MPEG-TS format @@ -445,8 +762,9 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: "" + /api/stream.mjpeg?dst={dst}: post: summary: Post stream in MJPEG format @@ -455,10 +773,55 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: "" + /api/ffmpeg: + post: + summary: Play file/live/TTS into a stream via FFmpeg + description: | + Helper endpoint for "stream to camera" scenarios. + Exactly one of `file`, `live`, `text` should be provided. + tags: [ FFmpeg ] + parameters: + - name: dst + in: query + description: Destination stream name + required: true + schema: { type: string } + example: camera1 + - name: file + in: query + description: Input URL to treat as file (`#input=file`) + required: false + schema: { type: string } + example: "http://example.com/song.mp3" + - name: live + in: query + description: Live input URL + required: false + schema: { type: string } + example: "http://example.com/live.mp3" + - name: text + in: query + description: Text-to-speech phrase + required: false + schema: { type: string } + example: "Hello" + - name: voice + in: query + description: Optional TTS voice (engine-dependent) + required: false + schema: { type: string } + responses: + "200": + description: OK + "400": + description: Invalid parameters + "404": + description: Stream not found + /api/dvrip: get: @@ -466,8 +829,7 @@ paths: description: "[Source: DVRIP](https://github.com/AlexxIT/go2rtc#source-dvrip)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } /api/ffmpeg/devices: get: @@ -475,56 +837,275 @@ paths: description: "[Source: FFmpeg Device](https://github.com/AlexxIT/go2rtc#source-ffmpeg-device)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/ffmpeg/hardware: get: summary: FFmpeg hardware transcoding discovery description: "[Hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + + /api/v4l2: + get: + summary: V4L2 video devices discovery (Linux) + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/alsa: + get: + summary: ALSA audio devices discovery (Linux) + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/gopro: + get: + summary: GoPro cameras discovery + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/ring: + get: + summary: Ring cameras discovery + description: | + Provide either `email`/`password` (and optional `code` for 2FA) or `refresh_token`. + If 2FA is required, returns a JSON prompt instead of sources. + tags: [ Discovery ] + parameters: + - name: email + in: query + required: false + schema: { type: string } + - name: password + in: query + required: false + schema: { type: string } + - name: code + in: query + required: false + schema: { type: string } + - name: refresh_token + in: query + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: "" } + + /api/tuya: + get: + summary: Tuya cameras discovery + tags: [ Discovery ] + parameters: + - name: region + in: query + description: Tuya API host (region) + required: true + schema: { type: string } + example: "openapi.tuyaus.com" + - name: email + in: query + required: true + schema: { type: string } + - name: password + in: query + required: true + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/discovery" } + "400": + description: Invalid parameters + "404": + description: No cameras found + /api/hass: get: summary: Home Assistant cameras discovery description: "[Source: Hass](https://github.com/AlexxIT/go2rtc#source-hass)" tags: [ Discovery ] responses: - default: - description: Default response - /api/homekit: + "200": { $ref: "#/components/responses/discovery" } + "404": { description: No Hass config } + + /api/discovery/homekit: get: summary: HomeKit cameras discovery description: "[Source: HomeKit](https://github.com/AlexxIT/go2rtc#source-homekit)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/nest: get: summary: Nest cameras discovery tags: [ Discovery ] + parameters: + - name: client_id + in: query + required: true + schema: { type: string } + - name: client_secret + in: query + required: true + schema: { type: string } + - name: refresh_token + in: query + required: true + schema: { type: string } + - name: project_id + in: query + required: true + schema: { type: string } responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/onvif: get: summary: ONVIF cameras discovery description: "[Source: ONVIF](https://github.com/AlexxIT/go2rtc#source-onvif)" tags: [ Discovery ] + parameters: + - name: src + in: query + description: Optional ONVIF device URL to enumerate profiles + required: false + schema: { type: string } + example: "onvif://user:pass@192.168.1.50:80" responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/roborock: get: - summary: Roborock vacuums discovery + summary: Roborock vacuums discovery (requires prior auth) description: "[Source: Roborock](https://github.com/AlexxIT/go2rtc#source-roborock)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + "404": + description: No auth + post: + summary: Roborock login and discovery + tags: [ Discovery ] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + username: { type: string } + password: { type: string } + required: [ username, password ] + responses: + "200": { $ref: "#/components/responses/discovery" } + /api/homekit: + get: + summary: Get HomeKit servers state + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Optional stream name (server ID) + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: "" } + "404": + description: Server not found + post: + summary: Pair HomeKit camera and create/update stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name to create/update + required: true + schema: { type: string } + - name: src + in: query + description: HomeKit URL (without pin) + required: true + schema: { type: string } + - name: pin + in: query + description: HomeKit PIN + required: true + schema: { type: string } + responses: + "200": + description: OK + delete: + summary: Unpair HomeKit camera and delete stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name / server ID + required: true + schema: { type: string } + responses: + "200": + description: OK + "404": + description: Stream not found + + /api/homekit/accessories: + get: + summary: Get HomeKit accessories JSON for a stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: { } } + "404": + description: Stream not found + + /pair-setup: + post: + summary: HomeKit Pair Setup (HAP) + description: HomeKit Accessory Protocol endpoint (TLV8). + tags: [ HomeKit ] + requestBody: + required: true + content: + application/pairing+tlv8: { example: "" } + responses: + "200": + description: OK + content: + application/pairing+tlv8: { example: "" } + + /pair-verify: + post: + summary: HomeKit Pair Verify (HAP) + description: HomeKit Accessory Protocol endpoint (TLV8). + tags: [ HomeKit ] + requestBody: + required: true + content: + application/pairing+tlv8: { example: "" } + responses: + "200": + description: OK + content: + application/pairing+tlv8: { example: "" } /onvif/: @@ -533,8 +1114,8 @@ paths: description: Simple realisation of the ONVIF protocol. Accepts any suburl requests tags: [ ONVIF ] responses: - default: - description: Default response + default: + description: "" @@ -544,8 +1125,33 @@ paths: description: Simple API for support [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration tags: [ RTSPtoWebRTC ] responses: - default: - description: Default response + default: + description: "" + + + /api/ws: + get: + summary: WebSocket endpoint + description: | + Upgrade to WebSocket and exchange JSON messages: + - Request: `{ "type": "...", "value": ... }` + - Response: `{ "type": "...", "value": ... }` + + Supported message types depend on enabled modules (see `api/README.md`). + tags: [ WebSocket ] + parameters: + - name: src + in: query + description: Stream name (consumer) + required: false + schema: { type: string } + - name: dst + in: query + description: Stream name (producer) + required: false + schema: { type: string } + responses: + "101": { description: Switching Protocols } @@ -564,15 +1170,13 @@ paths: - $ref: "#/components/parameters/stream_src_path" responses: 200: { $ref: "#/components/responses/webtorrent" } - delete: summary: Delete WebTorrent share tags: [ WebTorrent ] parameters: - $ref: "#/components/parameters/stream_src_path" responses: - default: - description: Default response + default: { description: "" } /api/webtorrent: get: From 4dae65a5351e415266ceb9589c2494e8dcc8da9f Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 31 Dec 2025 17:55:47 +0300 Subject: [PATCH 163/241] Fix audio sample rate for some xiaomi cameras #2006 --- pkg/xiaomi/miss/client.go | 18 ++++++++++++++++++ pkg/xiaomi/producer.go | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/xiaomi/miss/client.go b/pkg/xiaomi/miss/client.go index 470c0e0e..f7157ccf 100644 --- a/pkg/xiaomi/miss/client.go +++ b/pkg/xiaomi/miss/client.go @@ -271,6 +271,24 @@ type Packet struct { Payload []byte } +func (p *Packet) SampleRate() uint32 { + // flag: 1 0011 000 - sample rate 16000 + // flag: 100 00 01 0000 000 - sample rate 8000 + v := (p.Flags >> 3) & 0b1111 + if v != 0 { + return 16000 + } + return 8000 +} + +//func (p *Packet) AudioUnknown1() byte { +// return byte((p.Flags >> 7) & 0b11) +//} +// +//func (p *Packet) AudioUnknown2() byte { +// return byte((p.Flags >> 9) & 0b11) +//} + func GenerateKey() ([]byte, []byte, error) { public, private, err := box.GenerateKey(rand.Reader) if err != nil { diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go index 09ba7360..dcd419b8 100644 --- a/pkg/xiaomi/producer.go +++ b/pkg/xiaomi/producer.go @@ -110,7 +110,7 @@ func probe(client *miss.Client, channel, quality, audio uint8) ([]*core.Media, e } case miss.CodecPCMA: if acodec == nil { - acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate()} } case miss.CodecOPUS: if acodec == nil { From a42ab88dbdee415b51820e567beaa35e69351334 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 1 Jan 2026 05:24:45 +0100 Subject: [PATCH 164/241] add wyze support --- README.md | 5 + internal/rtsp/rtsp.go | 2 + internal/wyze/wyze.go | 225 +++++ main.go | 2 + pkg/aac/adts.go | 7 + pkg/wyze/README.md | 89 ++ pkg/wyze/backchannel.go | 55 ++ pkg/wyze/client.go | 537 ++++++++++++ pkg/wyze/cloud.go | 390 +++++++++ pkg/wyze/crypto/transcode.go | 143 ++++ pkg/wyze/crypto/xxtea.go | 147 ++++ pkg/wyze/producer.go | 232 +++++ pkg/wyze/tutk/README.md | 1065 +++++++++++++++++++++++ pkg/wyze/tutk/avframe.go | 126 +++ pkg/wyze/tutk/channel.go | 64 ++ pkg/wyze/tutk/cipher.go | 218 +++++ pkg/wyze/tutk/conn.go | 1555 ++++++++++++++++++++++++++++++++++ pkg/wyze/tutk/constants.go | 282 ++++++ pkg/wyze/tutk/types.go | 155 ++++ www/add.html | 58 ++ www/video-rtc.js | 15 +- 21 files changed, 5371 insertions(+), 1 deletion(-) create mode 100644 internal/wyze/wyze.go create mode 100644 pkg/wyze/README.md create mode 100644 pkg/wyze/backchannel.go create mode 100644 pkg/wyze/client.go create mode 100644 pkg/wyze/cloud.go create mode 100644 pkg/wyze/crypto/transcode.go create mode 100644 pkg/wyze/crypto/xxtea.go create mode 100644 pkg/wyze/producer.go create mode 100644 pkg/wyze/tutk/README.md create mode 100644 pkg/wyze/tutk/avframe.go create mode 100644 pkg/wyze/tutk/channel.go create mode 100644 pkg/wyze/tutk/cipher.go create mode 100644 pkg/wyze/tutk/conn.go create mode 100644 pkg/wyze/tutk/constants.go create mode 100644 pkg/wyze/tutk/types.go diff --git a/README.md b/README.md index b96c9a8e..4c45bdd0 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [Source: Kasa](#source-kasa) * [Source: Tuya](#source-tuya) * [Source: Xiaomi](#source-xiaomi) + * [Source: Wyze](#source-wyze) * [Source: GoPro](#source-gopro) * [Source: Ivideon](#source-ivideon) * [Source: Hass](#source-hass) @@ -605,6 +606,10 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/xiaomi/README.md). +#### Source: Wyze + +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/pkg/wyze/README.md). + #### Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* diff --git a/internal/rtsp/rtsp.go b/internal/rtsp/rtsp.go index 9b18982f..31c2c5db 100644 --- a/internal/rtsp/rtsp.go +++ b/internal/rtsp/rtsp.go @@ -198,6 +198,8 @@ func tcpHandler(conn *rtsp.Conn) { {Name: core.CodecPCM, ClockRate: 8000}, {Name: core.CodecPCMA, ClockRate: 8000}, {Name: core.CodecPCMU, ClockRate: 8000}, + {Name: core.CodecAAC, ClockRate: 8000}, + {Name: core.CodecAAC, ClockRate: 16000}, }, }) } diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go new file mode 100644 index 00000000..aad01d76 --- /dev/null +++ b/internal/wyze/wyze.go @@ -0,0 +1,225 @@ +package wyze + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/wyze" +) + +func Init() { + var v struct { + Cfg map[string]AccountConfig `yaml:"wyze"` + } + app.LoadConfig(&v) + + accounts = v.Cfg + + log := app.GetLogger("wyze") + + streams.HandleFunc("wyze", func(rawURL string) (core.Producer, error) { + log.Debug().Msgf("wyze: dial %s", rawURL) + return wyze.NewProducer(rawURL) + }) + + api.HandleFunc("api/wyze", apiWyze) +} + +type AccountConfig struct { + APIKey string `yaml:"api_key"` + APIID string `yaml:"api_id"` + Password string `yaml:"password"` +} + +var accounts map[string]AccountConfig + +func getCloud(email string) (*wyze.Cloud, error) { + cfg, ok := accounts[email] + if !ok { + return nil, fmt.Errorf("wyze: account not found: %s", email) + } + + var cloud *wyze.Cloud + if cfg.APIKey != "" && cfg.APIID != "" { + cloud = wyze.NewCloudWithAPIKey(cfg.APIKey, cfg.APIID) + } else { + cloud = wyze.NewCloud() + } + + if err := cloud.Login(email, cfg.Password); err != nil { + return nil, err + } + + return cloud, nil +} + +func apiWyze(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + apiDeviceList(w, r) + case "POST": + apiAuth(w, r) + } +} + +func apiDeviceList(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + email := query.Get("id") + if email == "" { + // Return list of configured accounts + accountList := make([]string, 0, len(accounts)) + for id := range accounts { + accountList = append(accountList, id) + } + api.ResponseJSON(w, accountList) + return + } + + err := func() error { + cloud, err := getCloud(email) + if err != nil { + return err + } + + cameras, err := cloud.GetCameraList() + if err != nil { + return err + } + + var items []*api.Source + for _, cam := range cameras { + streamURL := buildStreamURL(cam) + + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ModelName(), cam.MAC, cam.IP), + URL: streamURL, + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func buildStreamURL(cam *wyze.Camera) string { + // Use IP if available, otherwise use P2P_ID as host + host := cam.IP + if host == "" { + host = cam.P2PID + } + + query := url.Values{} + query.Set("uid", cam.P2PID) + query.Set("enr", cam.ENR) + query.Set("mac", cam.MAC) + + if cam.DTLS == 1 { + query.Set("dtls", "true") + } + + return fmt.Sprintf("wyze://%s?%s", host, query.Encode()) +} + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + email := r.Form.Get("email") + password := r.Form.Get("password") + apiKey := r.Form.Get("api_key") + apiID := r.Form.Get("api_id") + + if email == "" || password == "" { + http.Error(w, "email and password required", http.StatusBadRequest) + return + } + + // Try to login + var cloud *wyze.Cloud + if apiKey != "" && apiID != "" { + cloud = wyze.NewCloudWithAPIKey(apiKey, apiID) + } else { + cloud = wyze.NewCloud() + } + + if err := cloud.Login(email, password); err != nil { + // Check for MFA error + var authErr *wyze.AuthError + if ok := isAuthError(err, &authErr); ok { + w.Header().Set("Content-Type", api.MimeJSON) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(authErr) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Save credentials to config (not tokens!) + cfg := map[string]string{ + "password": password, + } + if apiKey != "" { + cfg["api_key"] = apiKey + } + if apiID != "" { + cfg["api_id"] = apiID + } + + if err := app.PatchConfig([]string{"wyze", email}, cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Update in-memory config + if accounts == nil { + accounts = make(map[string]AccountConfig) + } + accounts[email] = AccountConfig{ + APIKey: apiKey, + APIID: apiID, + Password: password, + } + + // Return camera list with direct URLs + cameras, err := cloud.GetCameraList() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + for _, cam := range cameras { + streamURL := buildStreamURL(cam) + + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ModelName(), cam.MAC, cam.IP), + URL: streamURL, + }) + } + + api.ResponseSources(w, items) +} + +func isAuthError(err error, target **wyze.AuthError) bool { + if e, ok := err.(*wyze.AuthError); ok { + *target = e + return true + } + return false +} diff --git a/main.go b/main.go index df5322eb..35984e40 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/internal/wyoming" + "github.com/AlexxIT/go2rtc/internal/wyze" "github.com/AlexxIT/go2rtc/internal/xiaomi" "github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/pkg/shell" @@ -100,6 +101,7 @@ func main() { {"roborock", roborock.Init}, {"tapo", tapo.Init}, {"tuya", tuya.Init}, + {"wyze", wyze.Init}, {"xiaomi", xiaomi.Init}, {"yandex", yandex.Init}, // Helper modules diff --git a/pkg/aac/adts.go b/pkg/aac/adts.go index 8bdc3a3d..140b1ba2 100644 --- a/pkg/aac/adts.go +++ b/pkg/aac/adts.go @@ -10,6 +10,13 @@ import ( const ADTSHeaderSize = 7 +func ADTSHeaderLen(b []byte) int { + if HasCRC(b) { + return 9 // 7 bytes header + 2 bytes CRC + } + return ADTSHeaderSize +} + func IsADTS(b []byte) bool { // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) // A 12 Syncword, all bits must be set to 1. diff --git a/pkg/wyze/README.md b/pkg/wyze/README.md new file mode 100644 index 00000000..03e26ce8 --- /dev/null +++ b/pkg/wyze/README.md @@ -0,0 +1,89 @@ +# Wyze + +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol without the Wyze app or SDK. + +**Important:** + +1. **Requires Wyze account**. You need to login once via the WebUI to load your cameras. +2. **Requires newer firmware with DTLS**. Only cameras with DTLS-enabled firmware are currently supported. +3. Internet access is only needed when loading cameras from your account. After that, all streaming is local P2P. +4. Connection to the camera is local only (direct P2P to camera IP). + +**Features:** + +- H.264 and H.265 video codec support +- AAC, G.711, PCM, and Opus audio codec support +- Two-way audio (intercom) support +- Resolution switching (HD/SD) + +## Setup + +1. Get your API Key from [Wyze Developer Portal](https://support.wyze.com/hc/en-us/articles/16129834216731) +2. Go to go2rtc WebUI > Add > Wyze +3. Enter your API ID, API Key, email, and password +4. Select cameras to add - stream URLs are generated automatically + +**Example Config** + +```yaml +wyze: + user@email.com: + api_id: "your-api-id" + api_key: "your-api-key" + password: "yourpassword" # or MD5 triple-hash with "md5:" prefix + +streams: + wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF +``` + +## Stream URL Format + +The stream URL is automatically generated when you add cameras via the WebUI: + +``` +wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&dtls=true +``` + +| Parameter | Description | +|-----------|-------------| +| `IP` | Camera's local IP address | +| `uid` | P2P identifier (20 chars) | +| `enr` | Encryption key for DTLS | +| `mac` | Device MAC address | +| `dtls` | Enable DTLS encryption (default: true) | + +## Configuration + +### Resolution + +You can change the camera's resolution using the `quality` parameter: + +```yaml +streams: + wyze_hd: wyze://...&quality=hd # 1080P/2K (default) + wyze_sd: wyze://...&quality=sd # 360P +``` + +### Two-Way Audio + +Two-way audio (intercom) is supported automatically. When a consumer sends audio to the stream, it will be transmitted to the camera's speaker. + +## Supported Cameras + +Cameras using the TUTK P2P protocol: + +| Model | Name | Tested | +|-------|------|--------| +| WYZE_CAKP2JFUS | Wyze Cam v3 | | +| HL_CAM3P | Wyze Cam v3 Pro | | +| HL_CAM4 | Wyze Cam v4 | Yes | +| WYZECP1_JEF | Wyze Cam Pan | | +| HL_PANP | Wyze Cam Pan v2 | | +| HL_PAN3 | Wyze Cam Pan v3 | | +| WVOD1 | Wyze Video Doorbell | | +| WVOD2 | Wyze Video Doorbell v2 | | +| AN_RSCW | Wyze Video Doorbell Pro | | +| GW_BE1 | Wyze Cam Floodlight | | +| HL_WCO2 | Wyze Cam Outdoor | | +| HL_CFL2 | Wyze Cam Floodlight v2 | | +| LD_CFP | Wyze Battery Cam Pro | | diff --git a/pkg/wyze/backchannel.go b/pkg/wyze/backchannel.go new file mode 100644 index 00000000..d0b15db3 --- /dev/null +++ b/pkg/wyze/backchannel.go @@ -0,0 +1,55 @@ +package wyze + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/pion/rtp" +) + +func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if err := p.client.StartIntercom(); err != nil { + return fmt.Errorf("wyze: failed to enable intercom: %w", err) + } + + // Get the camera's audio codec info (what it sent us = what it accepts) + tutkCodec, sampleRate, channels := p.client.GetBackchannelCodec() + if tutkCodec == 0 { + return fmt.Errorf("wyze: no audio codec detected from camera") + } + + if p.client.verbose { + fmt.Printf("[Wyze] Intercom enabled, using codec=0x%04x rate=%d ch=%d\n", tutkCodec, sampleRate, channels) + } + + sender := core.NewSender(media, track.Codec) + + // Track our own timestamp - camera expects timestamps starting from 0 + // and incrementing by frame duration in microseconds + var timestamp uint32 = 0 + samplesPerFrame := tutk.GetSamplesPerFrame(tutkCodec) + frameDurationUS := samplesPerFrame * 1000000 / sampleRate + + sender.Handler = func(pkt *rtp.Packet) { + if err := p.client.WriteAudio(tutkCodec, pkt.Payload, timestamp, sampleRate, channels); err == nil { + p.Send += len(pkt.Payload) + } + timestamp += frameDurationUS + } + + switch track.Codec.Name { + case core.CodecAAC: + if track.Codec.IsRTP() { + sender.Handler = aac.RTPToADTS(codec, sender.Handler) + } else { + sender.Handler = aac.EncodeToADTS(codec, sender.Handler) + } + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + + return nil +} diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go new file mode 100644 index 00000000..5dc17e41 --- /dev/null +++ b/pkg/wyze/client.go @@ -0,0 +1,537 @@ +package wyze + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "net" + "net/url" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/wyze/crypto" + "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" +) + +type Client struct { + conn *tutk.Conn + + host string + uid string + enr string + mac string + + authKey string + verbose bool + + closed bool + closeMu sync.Mutex + + hasAudio bool + hasIntercom bool + + audioCodecID uint16 + audioSampleRate uint32 + audioChannels uint8 +} + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("wyze: invalid URL: %w", err) + } + + query := u.Query() + + if query.Get("dtls") != "true" { + return nil, fmt.Errorf("wyze: only DTLS cameras are supported") + } + + c := &Client{ + host: u.Host, + uid: query.Get("uid"), + enr: query.Get("enr"), + mac: query.Get("mac"), + verbose: query.Get("verbose") == "true", + } + + c.authKey = string(crypto.CalculateAuthKey(c.enr, c.mac)) + + if c.verbose { + fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid) + } + + if err := c.connect(); err != nil { + c.Close() + return nil, err + } + + if err := c.doAVLogin(); err != nil { + c.Close() + return nil, err + } + + if err := c.doKAuth(); err != nil { + c.Close() + return nil, err + } + + if c.verbose { + fmt.Printf("[Wyze] Connection established\n") + } + + return c, nil +} + +func (c *Client) SupportsAudio() bool { + return c.hasAudio +} + +func (c *Client) SupportsIntercom() bool { + return c.hasIntercom +} + +func (c *Client) SetBackchannelCodec(codecID uint16, sampleRate uint32, channels uint8) { + c.audioCodecID = codecID + c.audioSampleRate = sampleRate + c.audioChannels = channels +} + +func (c *Client) GetBackchannelCodec() (codecID uint16, sampleRate uint32, channels uint8) { + return c.audioCodecID, c.audioSampleRate, c.audioChannels +} + +func (c *Client) SetResolution(sd bool) error { + var frameSize uint8 + var bitrate uint16 + + if sd { + frameSize = tutk.FrameSize360P + bitrate = tutk.BitrateSD + } else { + frameSize = tutk.FrameSize2K + bitrate = tutk.BitrateMax + } + + if c.verbose { + fmt.Printf("[Wyze] SetResolution: sd=%v frameSize=%d bitrate=%d\n", sd, frameSize, bitrate) + } + + k10056 := c.buildK10056(frameSize, bitrate) + if err := c.conn.SendIOCtrl(tutk.KCmdSetResolution, k10056); err != nil { + return fmt.Errorf("wyze: K10056 send failed: %w", err) + } + + // Wait for response (SDK-style: accept any IOCtrl) + cmdID, data, err := c.conn.RecvIOCtrl(1 * time.Second) + if err != nil { + return err + } + + if c.verbose { + fmt.Printf("[Wyze] SetResolution response: K%d (%d bytes)\n", cmdID, len(data)) + } + + if cmdID == tutk.KCmdSetResolutionResp && len(data) >= 17 { + result := data[16] + if c.verbose { + fmt.Printf("[Wyze] K10057 result: %d\n", result) + } + } + + return nil +} + +func (c *Client) StartVideo() error { + k10010 := c.buildK10010(tutk.MediaTypeVideo, true) + if c.verbose { + fmt.Printf("[Wyze] TX K10010 video (%d bytes): % x\n", len(k10010), k10010) + } + + if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil { + return fmt.Errorf("K10010 video send failed: %w", err) + } + + // Wait for K10011 response + cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second) + if err != nil { + return fmt.Errorf("K10011 video recv failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] K10011 video response: cmdID=%d, len=%d\n", cmdID, len(data)) + if len(data) >= 18 { + fmt.Printf("[Wyze] K10011 video: media=%d status=%d\n", data[16], data[17]) + } + } + + return nil +} + +func (c *Client) StartAudio() error { + k10010 := c.buildK10010(tutk.MediaTypeAudio, true) + if c.verbose { + fmt.Printf("[Wyze] TX K10010 audio (%d bytes): % x\n", len(k10010), k10010) + } + + if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil { + return fmt.Errorf("K10010 audio send failed: %w", err) + } + + // Wait for K10011 response + cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second) + if err != nil { + return fmt.Errorf("K10011 audio recv failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] K10011 audio response: cmdID=%d, len=%d\n", cmdID, len(data)) + if len(data) >= 18 { + fmt.Printf("[Wyze] K10011 audio: media=%d status=%d\n", data[16], data[17]) + } + } + + return nil +} + +func (c *Client) StartIntercom() error { + if c.conn.IsBackchannelReady() { + return nil // Already enabled + } + + if c.verbose { + fmt.Printf("[Wyze] Sending K10010 (enable return audio)\n") + } + + k10010 := c.buildK10010(tutk.MediaTypeReturnAudio, true) + if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil { + return fmt.Errorf("K10010 send failed: %w", err) + } + + // Wait for K10011 response + cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second) + if err != nil { + return fmt.Errorf("K10011 recv failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] K10011 response: cmdID=%d, len=%d\n", cmdID, len(data)) + } + + // Perform DTLS server handshake on backchannel (camera connects to us) + if c.verbose { + fmt.Printf("[Wyze] Starting speaker channel DTLS handshake\n") + } + + if err := c.conn.AVServStart(); err != nil { + return fmt.Errorf("speaker channel handshake failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] Backchannel ready\n") + } + + return nil +} + +func (c *Client) ReadPacket() (*tutk.Packet, error) { + return c.conn.AVRecvFrameData() +} + +func (c *Client) WriteAudio(codec uint16, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { + if !c.conn.IsBackchannelReady() { + return fmt.Errorf("speaker channel not connected") + } + + if c.verbose { + fmt.Printf("[Wyze] WriteAudio: codec=0x%04x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels) + } + + return c.conn.AVSendAudioData(codec, payload, timestamp, sampleRate, channels) +} + +func (c *Client) SetDeadline(t time.Time) error { + if c.conn != nil { + return c.conn.SetDeadline(t) + } + return nil +} + +func (c *Client) Protocol() string { + return "wyze/dtls" +} + +func (c *Client) RemoteAddr() net.Addr { + if c.conn != nil { + return c.conn.RemoteAddr() + } + return nil +} + +func (c *Client) Close() error { + c.closeMu.Lock() + if c.closed { + c.closeMu.Unlock() + return nil + } + c.closed = true + c.closeMu.Unlock() + + if c.verbose { + fmt.Printf("[Wyze] Closing connection\n") + } + + if c.conn != nil { + c.conn.Close() + } + + return nil +} + +func (c *Client) connect() error { + host := c.host + if idx := strings.Index(host, ":"); idx > 0 { + host = host[:idx] + } + + conn, err := tutk.Dial(host, c.uid, c.authKey, c.enr, c.verbose) + if err != nil { + return fmt.Errorf("wyze: connect failed: %w", err) + } + + c.conn = conn + if c.verbose { + fmt.Printf("[Wyze] Connected to %s (IOTC + DTLS)\n", conn.RemoteAddr()) + } + + return nil +} + +func (c *Client) doAVLogin() error { + if c.verbose { + fmt.Printf("[Wyze] Sending AV Login\n") + } + + if err := c.conn.AVClientStart(5 * time.Second); err != nil { + return fmt.Errorf("wyze: AV login failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] AV Login response received\n") + } + return nil +} + +func (c *Client) doKAuth() error { + if c.verbose { + fmt.Printf("[Wyze] Starting K-command authentication\n") + } + + // Step 1: Send K10000 + k10000 := c.buildK10000() + if err := c.conn.SendIOCtrl(tutk.KCmdAuth, k10000); err != nil { + return fmt.Errorf("wyze: K10000 send failed: %w", err) + } + + // Step 2: Wait for K10001 + cmdID, data, err := c.conn.RecvIOCtrl(10 * time.Second) + if err != nil { + return fmt.Errorf("wyze: K10001 recv failed: %w", err) + } + if cmdID != tutk.KCmdChallenge { + return fmt.Errorf("wyze: expected K10001, got K%d", cmdID) + } + + challenge, status, err := c.parseK10001(data) + if err != nil { + return fmt.Errorf("wyze: K10001 parse failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] K10001 received, status=%d\n", status) + } + + // Step 3: Send K10002 + k10002 := c.buildK10002(challenge, status) + if err := c.conn.SendIOCtrl(tutk.KCmdChallengeResp, k10002); err != nil { + return fmt.Errorf("wyze: K10002 send failed: %w", err) + } + + // Step 4: Wait for K10003 + cmdID, data, err = c.conn.RecvIOCtrl(10 * time.Second) + if err != nil { + return fmt.Errorf("wyze: K10003 recv failed: %w", err) + } + if cmdID != tutk.KCmdAuthResult { + return fmt.Errorf("wyze: expected K10003, got K%d", cmdID) + } + + authResp, err := c.parseK10003(data) + if err != nil { + return fmt.Errorf("wyze: K10003 parse failed: %w", err) + } + + // Parse capabilities + if authResp != nil && authResp.CameraInfo != nil { + if c.verbose { + fmt.Printf("[Wyze] CameraInfo authResp: ") + b, _ := json.Marshal(authResp) + fmt.Printf("%s\n", b) + } + + // Audio receiving support + if audio, ok := authResp.CameraInfo["audio"].(bool); ok { + c.hasAudio = audio + } else { + c.hasAudio = true // Default to true + } + } else { + c.hasAudio = true + } + + if avResp := c.conn.GetAVLoginResponse(); avResp != nil { + c.hasIntercom = avResp.TwoWayStreaming == 1 + if c.verbose { + fmt.Printf("[Wyze] two_way_streaming=%d (from AV Login Response)\n", avResp.TwoWayStreaming) + } + } + + if c.verbose { + fmt.Printf("[Wyze] K-auth complete\n") + } + + return nil +} + +func (c *Client) buildK10000() []byte { + buf := make([]byte, 16) + buf[0] = 'H' + buf[1] = 'L' + buf[2] = 5 + binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdAuth) + return buf +} + +func (c *Client) buildK10002(challenge []byte, status byte) []byte { + response := crypto.GenerateChallengeResponse(challenge, c.enr, status) + + buf := make([]byte, 38) + buf[0] = 'H' + buf[1] = 'L' + buf[2] = 5 + binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdChallengeResp) + buf[6] = 22 // Payload length + + if len(response) >= 16 { + copy(buf[16:32], response[:16]) + } + + if len(c.uid) >= 4 { + copy(buf[32:36], c.uid[:4]) + } + + buf[36] = 1 // Video flag (0 = disabled, 1 = enabled > will start video stream immediately) + buf[37] = 1 // Audio flag (0 = disabled, 1 = enabled > will start audio stream immediately) + + return buf +} + +func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { + // SDK format: 18 bytes total + // Header: 16 bytes, Payload: 2 bytes (media_type + enabled) + // TX K10010: 48 4c 05 00 1a 27 02 00 00 00 00 00 00 00 00 00 01 01 + buf := make([]byte, 18) + buf[0] = 'H' + buf[1] = 'L' + buf[2] = 5 // Version + binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdControlChannel) // 0x271a = 10010 + binary.LittleEndian.PutUint16(buf[6:8], 2) // Payload length = 2 + buf[16] = mediaType // 1=Video, 2=Audio, 3=ReturnAudio + if enabled { + buf[17] = 1 + } else { + buf[17] = 2 + } + return buf +} + +func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { + // SDK format: 21 bytes total + // Header: 16 bytes, Payload: 5 bytes + // TX K10056: 48 4c 05 00 48 27 05 00 00 00 00 00 00 00 00 00 04 f0 00 00 00 + buf := make([]byte, 21) + buf[0] = 'H' + buf[1] = 'L' + buf[2] = 5 // Version + binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdSetResolution) // 0x2748 = 10056 + binary.LittleEndian.PutUint16(buf[6:8], 5) // Payload length = 5 + buf[16] = frameSize + 1 // 4 = HD + binary.LittleEndian.PutUint16(buf[17:19], bitrate) // 0x00f0 = 240 + // buf[19], buf[20] = FPS (0 = auto) + return buf +} + +func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10001: received %d bytes\n", len(data)) + } + + if len(data) < 33 { + return nil, 0, fmt.Errorf("data too short: %d bytes", len(data)) + } + + if data[0] != 'H' || data[1] != 'L' { + return nil, 0, fmt.Errorf("invalid HL magic: %x %x", data[0], data[1]) + } + + cmdID := binary.LittleEndian.Uint16(data[4:6]) + if cmdID != tutk.KCmdChallenge { + return nil, 0, fmt.Errorf("expected cmdID 10001, got %d", cmdID) + } + + status = data[16] + challenge = make([]byte, 16) + copy(challenge, data[17:33]) + + return challenge, status, nil +} + +func (c *Client) parseK10003(data []byte) (*tutk.AuthResponse, error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10003: received %d bytes\n", len(data)) + } + + if len(data) < 16 { + return &tutk.AuthResponse{}, nil + } + + if data[0] != 'H' || data[1] != 'L' { + return &tutk.AuthResponse{}, nil + } + + cmdID := binary.LittleEndian.Uint16(data[4:6]) + textLen := binary.LittleEndian.Uint16(data[6:8]) + + if cmdID != tutk.KCmdAuthResult { + return &tutk.AuthResponse{}, nil + } + + if len(data) > 16 && textLen > 0 { + jsonData := data[16:] + for i := range jsonData { + if jsonData[i] == '{' { + var resp tutk.AuthResponse + if err := json.Unmarshal(jsonData[i:], &resp); err == nil { + if c.verbose { + fmt.Printf("[Wyze] parseK10003: parsed JSON\n") + } + return &resp, nil + } + break + } + } + } + + return &tutk.AuthResponse{}, nil +} diff --git a/pkg/wyze/cloud.go b/pkg/wyze/cloud.go new file mode 100644 index 00000000..f10268cf --- /dev/null +++ b/pkg/wyze/cloud.go @@ -0,0 +1,390 @@ +package wyze + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const ( + baseURLAuth = "https://auth-prod.api.wyze.com" + baseURLAPI = "https://api.wyzecam.com" + appName = "com.hualai.WyzeCam" + appVersion = "2.50.0" +) + +type Cloud struct { + client *http.Client + apiKey string + keyID string + accessToken string + refreshToken string + phoneID string + openUserID string + cameras []*Camera +} + +type Camera struct { + MAC string `json:"mac"` + P2PID string `json:"p2p_id"` + ENR string `json:"enr"` + IP string `json:"ip"` + Nickname string `json:"nickname"` + ProductModel string `json:"product_model"` + ProductType string `json:"product_type"` + DTLS int `json:"dtls"` + FirmwareVer string `json:"firmware_ver"` + IsOnline bool `json:"is_online"` +} + +func (c *Camera) ModelName() string { + models := map[string]string{ + "WYZEC1": "Wyze Cam v1", + "WYZEC1-JZ": "Wyze Cam v2", + "WYZE_CAKP2JFUS": "Wyze Cam v3", + "HL_CAM3P": "Wyze Cam v3 Pro", + "HL_CAM4": "Wyze Cam v4", + "WYZECP1_JEF": "Wyze Cam Pan", + "HL_PANP": "Wyze Cam Pan v2", + "HL_PAN3": "Wyze Cam Pan v3", + "WVOD1": "Wyze Video Doorbell", + "WVOD2": "Wyze Video Doorbell v2", + "AN_RSCW": "Wyze Video Doorbell Pro", + "GW_BE1": "Wyze Cam Floodlight", + "HL_WCO2": "Wyze Cam Outdoor", + "HL_CFL2": "Wyze Cam Floodlight v2", + "LD_CFP": "Wyze Battery Cam Pro", + } + if name, ok := models[c.ProductModel]; ok { + return name + } + return c.ProductModel +} + +func NewCloud() *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 30 * time.Second}, + phoneID: generatePhoneID(), + } +} + +func NewCloudWithAPIKey(apiKey, keyID string) *Cloud { + c := NewCloud() + c.apiKey = apiKey + c.keyID = keyID + return c +} + +func generatePhoneID() string { + return core.RandString(16, 16) // 16 hex chars +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + UserID string `json:"user_id"` + MFAOptions []string `json:"mfa_options"` + SMSSessionID string `json:"sms_session_id"` + EmailSessionID string `json:"email_session_id"` +} + +type apiError struct { + Code string `json:"code"` + ErrorCode int `json:"errorCode"` + Msg string `json:"msg"` + Description string `json:"description"` +} + +func (e *apiError) hasError() bool { + if e.Code == "1" || e.Code == "0" { + return false + } + if e.Code == "" && e.ErrorCode == 0 { + return false + } + return e.Code != "" || e.ErrorCode != 0 +} + +func (e *apiError) message() string { + if e.Msg != "" { + return e.Msg + } + return e.Description +} + +func (e *apiError) code() string { + if e.Code != "" { + return e.Code + } + return fmt.Sprintf("%d", e.ErrorCode) +} + +func (c *Cloud) Login(email, password string) error { + payload := map[string]string{ + "email": strings.TrimSpace(email), + "password": hashPassword(password), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAuth+"/api/user/login", strings.NewReader(string(jsonData))) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + if c.apiKey != "" && c.keyID != "" { + req.Header.Set("Apikey", c.apiKey) + req.Header.Set("Keyid", c.keyID) + req.Header.Set("User-Agent", "go2rtc") + } else { + req.Header.Set("X-API-Key", "WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ") + req.Header.Set("Phone-Id", c.phoneID) + req.Header.Set("User-Agent", "wyze_ios_"+appVersion) + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var errResp apiError + _ = json.Unmarshal(body, &errResp) + if errResp.hasError() { + return fmt.Errorf("wyze: login failed (code %s): %s", errResp.code(), errResp.message()) + } + + var result loginResponse + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("wyze: failed to parse login response: %w", err) + } + + if len(result.MFAOptions) > 0 { + return &AuthError{ + Message: "MFA required", + NeedsMFA: true, + MFAType: strings.Join(result.MFAOptions, ","), + } + } + + if result.AccessToken == "" { + return errors.New("wyze: no access token in response") + } + + c.accessToken = result.AccessToken + c.refreshToken = result.RefreshToken + c.openUserID = result.UserID + + return nil +} + +func (c *Cloud) LoginWithToken(accessToken, phoneID string) error { + c.accessToken = accessToken + if phoneID != "" { + c.phoneID = phoneID + } + _, err := c.GetCameraList() + return err +} + +func (c *Cloud) Credentials() (phoneID, openUserID string) { + return c.phoneID, c.openUserID +} + +func (c *Cloud) AccessToken() string { + return c.accessToken +} + +type deviceListResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data struct { + DeviceList []deviceInfo `json:"device_list"` + } `json:"data"` +} + +type deviceInfo struct { + MAC string `json:"mac"` + ENR string `json:"enr"` + Nickname string `json:"nickname"` + ProductModel string `json:"product_model"` + ProductType string `json:"product_type"` + FirmwareVer string `json:"firmware_ver"` + ConnState int `json:"conn_state"` + DeviceParams deviceParams `json:"device_params"` +} + +type deviceParams struct { + P2PID string `json:"p2p_id"` + P2PType int `json:"p2p_type"` + IP string `json:"ip"` + DTLS int `json:"dtls"` +} + +func (c *Cloud) GetCameraList() ([]*Camera, error) { + payload := map[string]any{ + "access_token": c.accessToken, + "phone_id": c.phoneID, + "app_name": appName, + "app_ver": appName + "___" + appVersion, + "app_version": appVersion, + "phone_system_type": 1, + "sc": "9f275790cab94a72bd206c8876429f3c", + "sv": "9d74946e652647e9b6c9d59326aef104", + "ts": time.Now().UnixMilli(), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/home_page/get_object_list", strings.NewReader(string(jsonData))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result deviceListResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("wyze: failed to parse device list: %w", err) + } + + if result.Code != "1" { + return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg) + } + + c.cameras = nil + for _, dev := range result.Data.DeviceList { + if dev.ProductType != "Camera" { + continue + } + + c.cameras = append(c.cameras, &Camera{ + MAC: dev.MAC, + P2PID: dev.DeviceParams.P2PID, + ENR: dev.ENR, + IP: dev.DeviceParams.IP, + Nickname: dev.Nickname, + ProductModel: dev.ProductModel, + ProductType: dev.ProductType, + DTLS: dev.DeviceParams.DTLS, + FirmwareVer: dev.FirmwareVer, + IsOnline: dev.ConnState == 1, + }) + } + + return c.cameras, nil +} + +func (c *Cloud) GetCamera(id string) (*Camera, error) { + if c.cameras == nil { + if _, err := c.GetCameraList(); err != nil { + return nil, err + } + } + + id = strings.ToUpper(id) + for _, cam := range c.cameras { + if strings.ToUpper(cam.MAC) == id || strings.EqualFold(cam.Nickname, id) { + return cam, nil + } + } + + return nil, fmt.Errorf("wyze: camera not found: %s", id) +} + +type p2pInfoResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` +} + +func (c *Cloud) GetP2PInfo(mac string) (map[string]any, error) { + payload := map[string]any{ + "access_token": c.accessToken, + "phone_id": c.phoneID, + "device_mac": mac, + "app_name": appName, + "app_ver": appName + "___" + appVersion, + "app_version": appVersion, + "phone_system_type": 1, + "sc": "9f275790cab94a72bd206c8876429f3c", + "sv": "9d74946e652647e9b6c9d59326aef104", + "ts": time.Now().UnixMilli(), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/device/get_iotc_info", strings.NewReader(string(jsonData))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result p2pInfoResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + if result.Code != "1" { + return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg) + } + + return result.Data, nil +} + +type AuthError struct { + Message string `json:"message"` + NeedsMFA bool `json:"needs_mfa,omitempty"` + MFAType string `json:"mfa_type,omitempty"` +} + +func (e *AuthError) Error() string { + return e.Message +} + +func hashPassword(password string) string { + encoded := strings.TrimSpace(password) + if strings.HasPrefix(strings.ToLower(encoded), "md5:") { + return encoded[4:] + } + for range 3 { + hash := md5.Sum([]byte(encoded)) + encoded = hex.EncodeToString(hash[:]) + } + return encoded +} diff --git a/pkg/wyze/crypto/transcode.go b/pkg/wyze/crypto/transcode.go new file mode 100644 index 00000000..61cf5f2c --- /dev/null +++ b/pkg/wyze/crypto/transcode.go @@ -0,0 +1,143 @@ +package crypto + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "math/bits" +) + +const charlie = "Charlie is the designer of P2P!!" + +func TransCodePartial(src []byte) []byte { + n := len(src) + tmp := make([]byte, n) + dst := bytes.Clone(src) + src16, tmp16, dst16 := src, tmp, dst + + for ; n >= 16; n -= 16 { + for i := 0; i < 16; i += 4 { + x := binary.LittleEndian.Uint32(src16[i:]) + binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, -i-1)) + } + for i := range 16 { + dst16[i] = tmp16[i] ^ charlie[i] + } + swap(dst16, tmp16, 16) + for i := 0; i < 16; i += 4 { + x := binary.LittleEndian.Uint32(tmp16[i:]) + binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, -i-3)) + } + tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:] + } + + for i := 0; i < n; i++ { + tmp16[i] = src16[i] ^ charlie[i] + } + swap(tmp16, dst16, n) + return dst +} + +func ReverseTransCodePartial(src []byte) []byte { + n := len(src) + tmp := make([]byte, n) + dst := bytes.Clone(src) + src16, tmp16, dst16 := src, tmp, dst + + for ; n >= 16; n -= 16 { + for i := 0; i < 16; i += 4 { + x := binary.LittleEndian.Uint32(src16[i:]) + binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3)) + } + swap(tmp16, dst16, 16) + for i := range 16 { + tmp16[i] = dst16[i] ^ charlie[i] + } + for i := 0; i < 16; i += 4 { + x := binary.LittleEndian.Uint32(tmp16[i:]) + binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, i+1)) + } + tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:] + } + + swap(src16, tmp16, n) + for i := 0; i < n; i++ { + dst16[i] = tmp16[i] ^ charlie[i] + } + return dst +} + +func TransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return TransCodePartial(src) + } + + dst := make([]byte, len(src)) + header := TransCodePartial(src[:16]) + copy(dst, header) + + if len(src) > 16 { + if src[3]&1 != 0 { // Partial encryption + remaining := len(src) - 16 + encryptLen := min(remaining, 48) + if encryptLen > 0 { + encrypted := TransCodePartial(src[16 : 16+encryptLen]) + copy(dst[16:], encrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full encryption + encrypted := TransCodePartial(src[16:]) + copy(dst[16:], encrypted) + } + } + return dst +} + +func ReverseTransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return ReverseTransCodePartial(src) + } + + dst := make([]byte, len(src)) + header := ReverseTransCodePartial(src[:16]) + copy(dst, header) + + if len(src) > 16 { + if dst[3]&1 != 0 { // Partial encryption (check decrypted header) + remaining := len(src) - 16 + decryptLen := min(remaining, 48) + if decryptLen > 0 { + decrypted := ReverseTransCodePartial(src[16 : 16+decryptLen]) + copy(dst[16:], decrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full decryption + decrypted := ReverseTransCodePartial(src[16:]) + copy(dst[16:], decrypted) + } + } + return dst +} + +func RandRead(b []byte) { + _, _ = rand.Read(b) +} + +func swap(src, dst []byte, n int) { + switch n { + case 8: + dst[0], dst[1], dst[2], dst[3] = src[7], src[4], src[3], src[2] + dst[4], dst[5], dst[6], dst[7] = src[1], src[6], src[5], src[0] + case 16: + dst[0], dst[1], dst[2], dst[3] = src[11], src[9], src[8], src[15] + dst[4], dst[5], dst[6], dst[7] = src[13], src[10], src[12], src[14] + dst[8], dst[9], dst[10], dst[11] = src[2], src[1], src[5], src[0] + dst[12], dst[13], dst[14], dst[15] = src[6], src[4], src[7], src[3] + default: + copy(dst, src[:n]) + } +} diff --git a/pkg/wyze/crypto/xxtea.go b/pkg/wyze/crypto/xxtea.go new file mode 100644 index 00000000..a28901cb --- /dev/null +++ b/pkg/wyze/crypto/xxtea.go @@ -0,0 +1,147 @@ +package crypto + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "strings" +) + +const delta = 0x9e3779b9 + +const ( + StatusDefault byte = 1 + StatusENR16 byte = 3 + StatusENR32 byte = 6 +) + +func XXTEADecrypt(data, key []byte) []byte { + if len(data) < 8 || len(key) < 16 { + return nil + } + + k := make([]uint32, 4) + for i := range 4 { + k[i] = binary.LittleEndian.Uint32(key[i*4:]) + } + + n := max(len(data)/4, 2) + v := make([]uint32, n) + for i := 0; i < len(data)/4; i++ { + v[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + + rounds := 6 + 52/n + sum := uint32(rounds) * delta + y := v[0] + + for rounds > 0 { + e := (sum >> 2) & 3 + for p := n - 1; p > 0; p-- { + z := v[p-1] + v[p] -= mx(sum, y, z, p, e, k) + y = v[p] + } + z := v[n-1] + v[0] -= mx(sum, y, z, 0, e, k) + y = v[0] + sum -= delta + rounds-- + } + + result := make([]byte, n*4) + for i := range n { + binary.LittleEndian.PutUint32(result[i*4:], v[i]) + } + + return result[:len(data)] +} + +func XXTEAEncrypt(data, key []byte) []byte { + if len(data) < 8 || len(key) < 16 { + return nil + } + + k := make([]uint32, 4) + for i := range 4 { + k[i] = binary.LittleEndian.Uint32(key[i*4:]) + } + + n := max(len(data)/4, 2) + v := make([]uint32, n) + for i := 0; i < len(data)/4; i++ { + v[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + + rounds := 6 + 52/n + var sum uint32 + z := v[n-1] + + for rounds > 0 { + sum += delta + e := (sum >> 2) & 3 + for p := 0; p < n-1; p++ { + y := v[p+1] + v[p] += mx(sum, y, z, p, e, k) + z = v[p] + } + y := v[0] + v[n-1] += mx(sum, y, z, n-1, e, k) + z = v[n-1] + rounds-- + } + + result := make([]byte, n*4) + for i := range n { + binary.LittleEndian.PutUint32(result[i*4:], v[i]) + } + + return result[:len(data)] +} + +func mx(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { + return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) +} + +func GenerateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { + var secretKey []byte + + switch status { + case StatusDefault: + secretKey = []byte("FFFFFFFFFFFFFFFF") + case StatusENR16: + if len(enr) >= 16 { + secretKey = []byte(enr[:16]) + } else { + secretKey = make([]byte, 16) + copy(secretKey, enr) + } + case StatusENR32: + if len(enr) >= 16 { + firstKey := []byte(enr[:16]) + challengeBytes = XXTEADecrypt(challengeBytes, firstKey) + } + if len(enr) >= 32 { + secretKey = []byte(enr[16:32]) + } else if len(enr) > 16 { + secretKey = make([]byte, 16) + copy(secretKey, []byte(enr[16:])) + } else { + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + default: + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + + return XXTEADecrypt(challengeBytes, secretKey) +} + +func CalculateAuthKey(enr, mac string) []byte { + data := enr + strings.ToUpper(mac) + hash := sha256.Sum256([]byte(data)) + b64 := base64.StdEncoding.EncodeToString(hash[:6]) + b64 = strings.ReplaceAll(b64, "+", "Z") + b64 = strings.ReplaceAll(b64, "/", "9") + b64 = strings.ReplaceAll(b64, "=", "A") + return []byte(b64) +} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go new file mode 100644 index 00000000..af6c25f1 --- /dev/null +++ b/pkg/wyze/producer.go @@ -0,0 +1,232 @@ +package wyze + +import ( + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + client *Client + model string +} + +func NewProducer(rawURL string) (*Producer, error) { + client, err := Dial(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + sd := query.Get("subtype") == "sd" + + medias, err := probe(client, sd) + if err != nil { + _ = client.Close() + return nil, err + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wyze", + Protocol: client.Protocol(), + RemoteAddr: client.RemoteAddr().String(), + Source: rawURL, + Medias: medias, + Transport: client, + }, + client: client, + model: query.Get("model"), + } + + return prod, nil +} + +func (p *Producer) Start() error { + defer p.client.Close() + + for { + _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) + pkt, err := p.client.ReadPacket() + if err != nil { + return err + } + if pkt == nil { + continue + } + + var name string + var pkt2 *core.Packet + + switch codecID := pkt.Codec; codecID { + case tutk.CodecH264: + name = core.CodecH264 + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + + case tutk.CodecH265: + name = core.CodecH265 + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + + case tutk.AudioCodecG711U: + name = core.CodecPCMU + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.AudioCodecG711A: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.AudioCodecAACADTS, tutk.AudioCodecAACWyze, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM: + name = core.CodecAAC + payload := pkt.Payload + if aac.IsADTS(payload) { + payload = payload[aac.ADTSHeaderLen(payload):] + } + pkt2 = &core.Packet{ + Header: rtp.Header{Version: aac.RTPPacketVersionAAC, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: payload, + } + + case tutk.AudioCodecOpus: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + default: + continue + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +func probe(client *Client, sd bool) ([]*core.Media, error) { + _ = client.SetResolution(sd) + _ = client.SetDeadline(time.Now().Add(core.ProbeTimeout)) + + var vcodec, acodec *core.Codec + var tutkAudioCodec uint16 + + for { + pkt, err := client.ReadPacket() + if err != nil { + return nil, fmt.Errorf("wyze: probe: %w", err) + } + if pkt == nil || len(pkt.Payload) < 5 { + continue + } + + switch pkt.Codec { + case tutk.CodecH264: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if len(buf) >= 5 && h264.NALUType(buf) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(buf) + } + } + case tutk.CodecH265: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if len(buf) >= 5 && h265.NALUType(buf) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(buf) + } + } + case tutk.AudioCodecG711U: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.AudioCodecG711A: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.AudioCodecAACWyze, tutk.AudioCodecAACADTS, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM: + if acodec == nil { + config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false) + acodec = aac.ConfigToCodec(config) + tutkAudioCodec = pkt.Codec + } + case tutk.AudioCodecOpus: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + tutkAudioCodec = pkt.Codec + } + case tutk.AudioCodecPCM: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCM, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + } + + if vcodec != nil && (acodec != nil || !client.SupportsAudio()) { + break + } + } + + _ = client.SetDeadline(time.Time{}) + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + } + + if acodec != nil { + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }) + + if client.SupportsIntercom() { + client.SetBackchannelCodec(tutkAudioCodec, acodec.ClockRate, uint8(acodec.Channels)) + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{acodec.Clone()}, + }) + } + } + + if client.verbose { + fmt.Printf("[Wyze] Probed codecs: video=%s audio=%s\n", vcodec.Name, acodec.Name) + if client.SupportsIntercom() { + fmt.Printf("[Wyze] Intercom supported, audio send codec=%s\n", acodec.Name) + } + } + + return medias, nil +} diff --git a/pkg/wyze/tutk/README.md b/pkg/wyze/tutk/README.md new file mode 100644 index 00000000..8020dba8 --- /dev/null +++ b/pkg/wyze/tutk/README.md @@ -0,0 +1,1065 @@ +# TUTK/IOTC Protocol Reference for Wyze Cameras + +This document provides a complete reverse-engineering reference for the ThroughTek TUTK/IOTC protocol as used by Wyze cameras. It covers the entire protocol stack from UDP transport through encrypted P2P streaming, enabling implementation of native Wyze camera streaming without the proprietary TUTK SDK. + +## Table of Contents + +1. [Protocol Stack Overview](#1-protocol-stack-overview) +2. [Encryption Layers](#2-encryption-layers) +3. [Connection Flow](#3-connection-flow) +4. [IOTC Packet Structures](#4-iotc-packet-structures) +5. [DTLS Transport](#5-dtls-transport) +6. [AV Login](#6-av-login) +7. [K-Command Authentication](#7-k-command-authentication) +8. [K-Command Control](#8-k-command-control) +9. [AV Frame Structure](#9-av-frame-structure) +10. [FRAMEINFO Structure](#10-frameinfo-structure) +11. [Codec Reference](#11-codec-reference) +12. [Two-Way Audio (Backchannel)](#12-two-way-audio-backchannel) +13. [Frame Reassembly](#13-frame-reassembly) +14. [Wyze Cloud API](#14-wyze-cloud-api) +15. [Cryptography Details](#15-cryptography-details) +16. [Constants Reference](#16-constants-reference) + +--- + +## 1. Protocol Stack Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ Video (H.264/H.265) + Audio (AAC/G.711/Opus) │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ AV Frame Layer │ +│ Frame Types, Channels, FRAMEINFO, Packet Reassembly │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ K-Command Authentication │ +│ K10000-K10003 (XXTEA Challenge-Response) │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ AV Login Layer │ +│ Credentials + Capabilities Exchange │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ DTLS 1.2 Encryption │ +│ PSK = SHA256(ENR), ChaCha20-Poly1305 AEAD │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ IOTC Session │ +│ Discovery (0x0601) + Session Setup (0x0402) │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ TransCode Cipher ("Charlie") │ +│ XOR + Bit Rotation Obfuscation │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ UDP Transport │ +│ Port 32761 (default) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Required Credentials + +| Parameter | Description | Source | +|-----------|-------------|--------| +| UID | Device P2P identifier (20 chars) | Wyze Cloud API | +| ENR | Encryption key (16+ bytes) | Wyze Cloud API | +| MAC | Device MAC address | Wyze Cloud API | +| AuthKey | SHA256(ENR + MAC)[:6] in Base64 | Calculated | + +### Credential Derivation + +``` +AuthKey = Base64(SHA256(ENR + uppercase(MAC))[0:6]) + with substitutions: '+' → 'Z', '/' → '9', '=' → 'A' + +PSK = SHA256(ENR) // 32 bytes for DTLS +``` + +--- + +## 2. Encryption Layers + +The protocol uses three distinct encryption layers: + +### Layer 1: TransCode ("Charlie" Cipher) + +Applied to all IOTC Discovery and Session packets before UDP transmission. + +**Algorithm:** +- XOR with magic string: `"Charlie is the designer of P2P!!"` +- 32-bit left rotation on each block +- Byte permutation/swapping + +**When Applied:** +- Disco Request/Response (0x0601/0x0602) +- Session Request/Response (0x0402/0x0404) +- Data TX/RX wrappers (0x0407/0x0408) + +### Layer 2: DTLS 1.2 + +Encrypts all data after session establishment. + +| Parameter | Value | +|-----------|-------| +| Version | DTLS 1.2 | +| Cipher Suite | TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256 (0xCCAC) | +| PSK Identity | `AUTHPWD_admin` | +| PSK | SHA256(ENR) - 32 bytes | +| Curve | X25519 | + +### Layer 3: XXTEA + +Used for K-Command challenge-response authentication. + +| Status | Key Derivation | +|--------|----------------| +| 1 (Default) | Key = `"FFFFFFFFFFFFFFFF"` (16 x 0xFF) | +| 3 (ENR16) | Key = ENR[0:16] | +| 6 (ENR32) | Double: decrypt with ENR[0:16], then with ENR[16:32] | + +--- + +## 3. Connection Flow + +``` +Client Camera + │ │ + │ ═══════════ Phase 1: IOTC Discovery ═══════════════ │ + │ │ + │ Disco Stage 1 (0x0601, broadcast) ───────────────► │ + │ ◄─────────────────────── Disco Response (0x0602) │ + │ Disco Stage 2 (0x0601, direct) ──────────────────► │ + │ │ + │ ═══════════ Phase 2: IOTC Session ═════════════════ │ + │ │ + │ Session Request (0x0402) ────────────────────────► │ + │ ◄───────────────────── Session Response (0x0404) │ + │ │ + │ ═══════════ Phase 3: DTLS Handshake ═══════════════ │ + │ │ + │ ClientHello (in DATA_TX 0x0407) ─────────────────► │ + │ ◄───────────────────── ServerHello + KeyExchange │ + │ ClientKeyExchange + Finished ────────────────────► │ + │ ◄───────────────────────────────── DTLS Finished │ + │ │ + │ ═══════════ Phase 4: AV Login ═════════════════════ │ + │ │ + │ AV Login #1 (magic=0x0000) ──────────────────────► │ + │ AV Login #2 (magic=0x2000) ──────────────────────► │ + │ ◄───────────────────── AV Login Response (0x2100) │ + │ ACK (0x0009) ────────────────────────────────────► │ + │ │ + │ ═══════════ Phase 5: K-Authentication ═════════════ │ + │ │ + │ K10000 (Auth Request) ───────────────────────────► │ + │ ◄───────────────────────── K10001 (Challenge 16B) │ + │ ACK (0x0009) ────────────────────────────────────► │ + │ K10002 (Response 38B) ───────────────────────────► │ + │ ◄───────────────────────── K10003 (Result, JSON) │ + │ ACK (0x0009) ────────────────────────────────────► │ + │ │ + │ ═══════════ Phase 6: Streaming ════════════════════ │ + │ │ + │ ◄─────────────────────────────── Video/Audio Data │ + │ ◄─────────────────────────────── Video/Audio Data │ + │ ... │ +``` + +--- + +## 4. IOTC Packet Structures + +### 4.1 IOTC Frame Header (16 bytes) + +All IOTC packets share this outer wrapper: + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0] 1 Marker1 Always 0x04 +[1] 1 Marker2 Always 0x02 +[2] 1 Marker3 Always 0x1A +[3] 1 Mode 0x02 (Disco), 0x0A (Session), 0x0B (Data) +[4-5] 2 BodySize Body length in bytes (LE) +[6-7] 2 Sequence Packet sequence number (LE) +[8-9] 2 Command Command ID (LE) +[10-11] 2 Flags Command-specific flags (LE) +[12-15] 4 RandomID Random identifier or metadata +``` + +### 4.2 Disco Request (0x0601) - 80 bytes total + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 Header IOTC Frame Header (cmd=0x0601) +[16-35] 20 UID Device UID (null-padded ASCII) +[36-51] 16 Reserved Zero-filled +[52-59] 8 RandomID 8 random bytes for session +[60] 1 Stage 1=broadcast, 2=direct +[61-71] 11 Reserved Zero-filled +[72-79] 8 AuthKey Calculated auth key +``` + +### 4.3 Session Request (0x0402) - 52 bytes total + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 Header IOTC Frame Header (cmd=0x0402) +[16-35] 20 UID Device UID (null-padded ASCII) +[36-43] 8 RandomID Same as Disco +[44-47] 4 Reserved Zero-filled +[48-51] 4 Timestamp Unix timestamp (LE) +``` + +### 4.4 Data TX (0x0407) - Variable + +Wraps DTLS records for transmission: + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 Header IOTC Frame Header (cmd=0x0407) +[16-17] 2 RandomID[0:2] +[18] 1 Channel 0=Main (DTLS client), 1=Back (DTLS server) +[19] 1 Marker Always 0x01 +[20-23] 4 Const Always 0x0000000C +[24-31] 8 RandomID Full 8-byte random ID +[32+] var Payload DTLS record data +``` + +--- + +## 5. DTLS Transport + +DTLS records are wrapped in IOTC DATA_TX (0x0407) packets for transmission and extracted from DATA_RX (0x0408) packets on reception. + +### PSK Callback + +``` +Identity: "AUTHPWD_admin" +PSK: SHA256(ENR_string) → 32 bytes +``` + +### Nonce Construction + +``` +nonce[12] = IV[12] XOR (epoch[2] || sequenceNumber[6] || padding[4]) +``` + +### AEAD Additional Data + +``` +additional_data = epoch[2] || sequenceNumber[6] || contentType[1] || version[2] || payloadLength[2] +``` + +--- + +## 6. AV Login + +After DTLS handshake, two login packets establish the AV session. + +### AV Login Packet #1 (570 bytes) + +``` +Offset Size Field Value/Description +────────────────────────────────────────────────────────────── +[0-1] 2 Magic 0x0000 (LE) +[2-3] 2 Version 0x000C (12) +[4-15] 12 Reserved Zero-filled +[16-17] 2 PayloadSize 0x0222 (546) +[18-19] 2 Flags 0x0001 +[20-23] 4 RandomID 4 random bytes +[24-279] 256 Username "admin" (null-padded) +[280-535] 256 Password ENR string (null-padded) +[536-539] 4 Resend 0=disabled, 1=enabled (see 9.6) +[540-543] 4 SecurityMode 0x00000002 (AV_SECURITY_AUTO) +[544-547] 4 AuthType 0x00000000 (PASSWORD) +[548-551] 4 SyncRecvData 0x00000000 +[552-555] 4 Capabilities 0x001F07FB +[556-569] 14 Reserved Zero-filled +``` + +### AV Login Packet #2 (572 bytes) + +Same structure as #1 with: +- Magic = 0x2000 +- PayloadSize = 0x0224 (548) +- Flags = 0x0000 +- RandomID[0] incremented by 1 + +### AV Login Response (0x2100) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-1] 2 Magic 0x2100 +[2-3] 2 Version 0x000C +[4] 1 ResponseType 0x10 = success +[5-15] 11 Reserved +[16-19] 4 PayloadSize 0x00000024 (36) +[20-23] 4 Checksum Echo from request +[24-27] 4 Reserved +[28] 1 Flag1 +[29] 1 EnableFlag 0x01 if enabled +[30] 1 Flag2 +[31] 1 TwoWayAudio 0x01 if intercom supported +[32-35] 4 Reserved +[36-39] 4 BufferConfig 0x00000004 +[40-43] 4 Capabilities 0x001F07FB (see below) +[44-57] 14 Reserved +``` + +### Capabilities Bitmask (0x001F07FB) + +``` +Bit Hex Name Description +────────────────────────────────────────────────────────────── +0 0x00000001 CYCLIC_FRAME_NUMBERING Frame numbers wrap around +1 0x00000002 CLEAN_BUF_ON_RESET Clear buffer on stream reset +3 0x00000008 TIMESTAMP_IN_FRAMEINFO Timestamps in FRAMEINFO struct +4 0x00000010 MULTI_CHANNEL Multiple AV channels supported +5 0x00000020 EXTENDED_FRAMEINFO 40-byte FRAMEINFO (vs 16-byte SDK) +6 0x00000040 RESEND_TIMEOUT Packet resend with timeout +7 0x00000080 DTLS_SUPPORT DTLS encryption supported +8 0x00000100 SPEAKER_CHANNEL Two-way audio / intercom +9 0x00000200 PTZ_CHANNEL PTZ control channel +10 0x00000400 PLAYBACK_CHANNEL SD card playback channel +16 0x00010000 AV_SECURITY_ENABLED Encrypted AV stream +17 0x00020000 RESEND_ENABLED Packet resend mechanism +18 0x00040000 DTLS_PSK DTLS with Pre-Shared Key +19 0x00080000 DTLS_ECDHE DTLS with ECDHE key exchange +20 0x00100000 CHACHA20_POLY1305 ChaCha20-Poly1305 cipher support +``` + +**0x001F07FB breakdown:** +``` +0x001F07FB = 0b0000_0000_0001_1111_0000_0111_1111_1011 + = Bits: 0,1,3,4,5,6,7,8,9,10,16,17,18,19,20 +``` + +--- + +## 7. K-Command Authentication + +K-Commands use the "HL" header format and are sent inside IOCTRL frames. + +### IOCTRL Frame Wrapper (40+ bytes) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-1] 2 Magic 0x000C +[2-3] 2 Version 0x000C +[4-7] 4 AVSeq AV sequence number (LE) +[8-15] 8 Reserved Zero-filled +[16-17] 2 IOCTRLMagic 0x7000 +[18-19] 2 SubChannel Command sequence (increments) +[20-23] 4 IOCTRLSeq Always 0x00000001 +[24-27] 4 PayloadSize HL payload size + 4 +[28-31] 4 Flag Matches SubChannel +[32-35] 4 Reserved +[36-37] 2 IOType 0x0100 +[38-39] 2 Reserved +[40+] var HLPayload K-Command data +``` + +### HL Header (16 bytes) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-1] 2 Magic "HL" (0x48 0x4C) +[2] 1 Version 5 +[3] 1 Reserved 0x00 +[4-5] 2 CommandID 10000, 10001, 10002, etc. (LE) +[6-7] 2 PayloadLen Payload length after header (LE) +[8-15] 8 Reserved Zero-filled +[16+] var Payload Command-specific data +``` + +### K10000 - Auth Request (16 bytes) + +Header only, no payload. Initiates authentication. + +### K10001 - Challenge (33+ bytes) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 HLHeader CommandID = 10001 +[16] 1 Status Key selection: 1, 3, or 6 +[17-32] 16 Challenge XXTEA-encrypted challenge bytes +``` + +**Status Interpretation:** +| Status | Key Source | +|--------|------------| +| 1 | Default key: 16 x 0xFF | +| 3 | ENR[0:16] | +| 6 | Double decrypt: first ENR[0:16], then ENR[16:32] | + +### K10002 - Challenge Response (38 bytes) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 HLHeader CommandID = 10002, PayloadLen = 22 +[16-31] 16 Response XXTEA-decrypted challenge +[32-35] 4 UIDPrefix First 4 bytes of UID +[36] 1 VideoFlag 1 = enable video stream +[37] 1 AudioFlag 1 = enable audio stream +``` + +### K10003 - Auth Result + +Variable length, contains JSON payload: + +```json +{ + "connectionRes": "1", + "cameraInfo": { + "basicInfo": { + "firmware": "4.52.9.4188", + "mac": "AABBCCDDEEFF", + "model": "HL_CAM4" + }, + "channelResquestResult": { + "audio": "1", + "video": "1" + } + } +} +``` + +After K10003, video/audio streaming begins automatically. + +--- + +## 8. K-Command Control + +### K10010 - Control Channel (18 bytes) + +Start or stop media streams: + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 HLHeader CommandID = 10010, PayloadLen = 2 +[16] 1 MediaType 1=Video, 2=Audio, 3=ReturnAudio +[17] 1 Enable 1=Enable, 2=Disable +``` + +**Media Types:** +| Value | Type | Description | +|-------|------|-------------| +| 1 | Video | Main video stream | +| 2 | Audio | Audio from camera | +| 3 | ReturnAudio | Intercom (audio to camera) | +| 4 | RDT | Raw data transfer | + +### K10056 - Set Resolution (21 bytes) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 HLHeader CommandID = 10056, PayloadLen = 5 +[16] 1 FrameSize Resolution + 1 (see table) +[17-18] 2 Bitrate KB/s value (LE) +[19-20] 2 FPS Frames per second, 0 = auto +``` + +**Frame Sizes:** +| Value | Resolution | +|-------|------------| +| 1 | 1080P (1920x1080) | +| 2 | 360P (640x360) | +| 3 | 720P (1280x720) | +| 4 | 2K (2560x1440) | + +**Bitrate Values:** +| Value | Rate | +|-------|------| +| 0xF0 (240) | Maximum | +| 0x3C (60) | SD quality | + +--- + +## 9. AV Frame Structure + +### 9.1 Channels + +| Value | Name | Description | +|-------|------|-------------| +| 0x03 | Audio | Audio frames (always single-packet) | +| 0x05 | I-Video | Keyframes (can be multi-packet) | +| 0x07 | P-Video | Predictive frames (can be multi-packet) | + +### 9.2 Frame Types + +| Type | Name | Header Size | Has FRAMEINFO | +|------|------|-------------|---------------| +| 0x00 | Cont | 28 bytes | No | +| 0x01 | EndSingle | 28 bytes | Yes (40B) | +| 0x04 | ContAlt | 28 bytes | No | +| 0x05 | EndMulti | 28 bytes | Yes (40B) | +| 0x08 | Start | 36 bytes | No | +| 0x09 | StartAlt | 36 bytes | Yes if pkt_total=1 | +| 0x0D | EndExt | 36 bytes | Yes (40B) | + +### 9.3 28-Byte Header Layout + +Used by: Cont (0x00), EndSingle (0x01), ContAlt (0x04), EndMulti (0x05) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0] 1 Channel 0x03/0x05/0x07 +[1] 1 FrameType 0x00/0x01/0x04/0x05 +[2-3] 2 Version 0x000B (11) +[4-5] 2 TxSequence Global incrementing sequence (LE) +[6-7] 2 Magic 0x507E ("P~") +[8] 1 Channel Duplicate of [0] +[9] 1 StreamIndex 0x00 normal, 0x01 for End packets +[10-11] 2 PacketCounter Running counter (does NOT reset per frame) +[12-13] 2 pkt_total Total packets in this frame (LE) +[14-15] 2 pkt_idx/Marker Packet index OR 0x0028 = FRAMEINFO present +[16-17] 2 PayloadSize Payload bytes (LE) +[18-19] 2 Reserved 0x0000 +[20-23] 4 PrevFrameNo Previous frame number (LE) +[24-27] 4 FrameNo Current frame number (LE) → USE FOR REASSEMBLY +``` + +### 9.4 36-Byte Header Layout + +Used by: Start (0x08), StartAlt (0x09), EndExt (0x0D) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0] 1 Channel 0x03/0x05/0x07 +[1] 1 FrameType 0x08/0x09/0x0D +[2-3] 2 Version 0x000B (11) +[4-5] 2 TxSequence Global incrementing sequence (LE) +[6-7] 2 Magic 0x507E ("P~") +[8-11] 4 TimestampOrID Variable (not reliable) +[12-15] 4 Flags Variable +[16] 1 Channel Duplicate of [0] +[17] 1 StreamIndex 0x00 normal, 0x01 for End/Audio +[18-19] 2 ChannelFrameIdx Per-channel index (NOT for reassembly) +[20-21] 2 pkt_total Total packets in this frame (LE) +[22-23] 2 pkt_idx/Marker Packet index OR 0x0028 = FRAMEINFO present +[24-25] 2 PayloadSize Payload bytes (LE) +[26-27] 2 Reserved 0x0000 +[28-31] 4 PrevFrameNo Previous frame number (LE) +[32-35] 4 FrameNo Current frame number (LE) → USE FOR REASSEMBLY +``` + +### 9.5 FRAMEINFO Marker (0x0028) + +The value at offset [14-15] (28-byte) or [22-23] (36-byte) has dual meaning: + +| Condition | Interpretation | +|-----------|----------------| +| End packet AND value == 0x0028 | FRAMEINFO present (40 bytes at payload end) | +| Otherwise | Actual packet index within frame | + +**Note:** 0x0028 hex = 40 decimal. For non-End packets, this could be pkt_idx=40. + +### 9.6 Resend Mode + +The `resend` field in the AV Login packet (offset [536-539]) controls the packet format used for streaming. Setting this value determines whether retransmission support is enabled: + +#### resend=0: Direct Format (Simpler) + +``` +[channel][frameType][version 2B][seq 2B]...[payload] +``` + +Example: +``` +0000: 05 00 0b 00 6d 00 81 4e 05 00 63 00 86 00 00 00 + ^^ ^^ + | frameType=0x00 (continuation) + channel=0x05 (I-Video) +``` + +**Characteristics:** +- First byte is channel: 0x03=Audio, 0x05=I-Video, 0x07=P-Video +- No 0x0c wrapper overhead +- No Frame Index packets (1080 bytes) +- Simpler parsing, less bandwidth + +#### resend=1: Wrapped Format (With Resend Support) + +``` +[0x0c][variant][version 2B][seq 2B]...[channel at offset 16/24] +``` + +Example: +``` +0000: 0c 05 0b 00 e4 00 64 00 0a 00 00 14 01 00 00 00 + ^^ ^^ + | variant=0x05 + 0x0c wrapper (resend marker) +0010: 07 01 c8 00 01 00 28 00 ... + ^^ + channel=0x07 (P-Video) at offset 16 +``` + +**Characteristics:** +- First byte is always 0x0c (resend wrapper) +- Channel byte at offset 16 (variant < 0x08) or 24 (variant >= 0x08) +- Additional 1080-byte Frame Index packets sent periodically +- Enables packet retransmission for reliable delivery + +#### Header Size Rule + +| Variant | Header Size | Channel Offset | +|---------|-------------|----------------| +| < 0x08 | 36 bytes | 16 | +| >= 0x08 | 44 bytes | 24 | + +### 9.7 Frame Index Packets (Inner Byte 0x0c) + +When using `resend=1`, the camera sends periodic **Frame Index** packets (also called Resend Buffer Status). + +#### Packet Structure (1080 bytes total) + +``` +OUTER HEADER (16 bytes): +0000: 0c 00 0b 00 [seq 2B] [sub 2B] [counter 2B] 14 14 01 00 00 00 + ^^^^ ^^^^^ + cmd=0x0c magic + +INNER HEADER (20 bytes): +0010: 0c 00 00 00 00 00 00 00 14 04 00 00 00 00 00 00 00 00 00 00 + ^^^^ ^^^^^ + inner cmd payload_size = 0x0414 = 1044 bytes + +PAYLOAD DATA (starting at offset 0x20): +0020: 00 00 00 00 // 4 zero bytes +0024: [ch] [ft] // channel + frame type +0026: [data 2B] [data 2B] // varies by packet type +... +0030: [prev_frame 4B LE] // previous frame number +0034: [curr_frame 4B LE] // current frame number +``` + +#### Key Offsets + +| Offset | Size | Field | +|--------|------|-------| +| 0x24 (36) | 1 | Channel (0x05=I-Video, 0x07=P-Video) | +| 0x25 (37) | 1 | Frame type | +| 0x30 (48) | 4 | Previous frame number (LE) | +| 0x34 (52) | 4 | Current frame number (LE) | + +#### Packet Types + +| Channel | Description | +|---------|-------------| +| 0x05 | I-Video Frame Index - consecutive frame numbers for GOP sync | +| 0x07 | P-Video - buffer window status (oldest/newest buffered frame) | + +--- + +## 10. FRAMEINFO Structure + +### 10.1 RX FRAMEINFO (40 bytes) - From Camera + +Appended to the end of End packets (0x01, 0x05, 0x0D, or 0x09 when pkt_total=1): + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-1] 2 codec_id Video: 0x4E (H.264), 0x50 (H.265) + Audio: 0x90 (AAC), 0x89 (G.711μ), etc. +[2] 1 flags Video: 0x00=P-frame, 0x01=I-frame (keyframe) + Audio: (sr_idx << 2) | (bits16 << 1) | stereo +[3] 1 cam_index Camera index (usually 0) +[4] 1 online_num Number of viewers +[5] 1 framerate FPS (e.g., 20, 30) +[6] 1 frame_size 0=1080P, 1=SD, 2=360P, 4=2K +[7] 1 bitrate Bitrate value +[8-11] 4 timestamp_us Microseconds within second (0-999999) +[12-15] 4 timestamp Unix timestamp in seconds (LE) +[16-19] 4 payload_size Total payload size for validation (LE) +[20-23] 4 frame_no Absolute frame counter (LE) +[24-39] 16 device_id MAC address as ASCII + padding +``` + +### 10.2 TX FRAMEINFO (16 bytes) - To Camera + +Used for audio backchannel (intercom): + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-1] 2 codec_id 0x0090 (AAC Wyze), 0x0089 (G.711μ), etc. +[2] 1 flags (sr_idx << 2) | (bits16 << 1) | stereo +[3] 1 cam_index 0 +[4] 1 online_num 1 (for TX) +[5] 1 tags 0 +[6-11] 6 reserved Zero-filled +[12-15] 4 timestamp_ms Cumulative: (frame_no - 1) * frame_duration_ms +``` + +### 10.3 Audio Flags Encoding + +``` +flags = (sample_rate_index << 2) | (bits16 << 1) | stereo + +Example: 16kHz, 16-bit, Mono + sr_idx=3, bits16=1, stereo=0 + flags = (3 << 2) | (1 << 1) | 0 = 0x0E +``` + +--- + +## 11. Codec Reference + +### 11.1 Video Codecs + +| ID (Hex) | ID (Dec) | Name | +|----------|----------|------| +| 0x4C | 76 | MPEG-4 | +| 0x4D | 77 | H.263 | +| 0x4E | 78 | H.264/AVC | +| 0x4F | 79 | MJPEG | +| 0x50 | 80 | H.265/HEVC | + +### 11.2 Audio Codecs + +| ID (Hex) | ID (Dec) | Name | +|----------|----------|------| +| 0x86 | 134 | AAC Raw | +| 0x87 | 135 | AAC ADTS | +| 0x88 | 136 | AAC LATM | +| 0x89 | 137 | G.711 μ-law (PCMU) | +| 0x8A | 138 | G.711 A-law (PCMA) | +| 0x8B | 139 | ADPCM | +| 0x8C | 140 | PCM 16-bit LE | +| 0x8D | 141 | Speex | +| 0x8E | 142 | MP3 | +| 0x8F | 143 | G.726 | +| 0x90 | 144 | AAC Wyze | +| 0x92 | 146 | Opus | + +### 11.3 Sample Rate Index + +| Index | Frequency | +|-------|-----------| +| 0x00 | 8000 Hz | +| 0x01 | 11025 Hz | +| 0x02 | 12000 Hz | +| 0x03 | 16000 Hz | +| 0x04 | 22050 Hz | +| 0x05 | 24000 Hz | +| 0x06 | 32000 Hz | +| 0x07 | 44100 Hz | +| 0x08 | 48000 Hz | + +--- + +## 12. Two-Way Audio (Backchannel) + +### 12.1 Activation Flow + +1. Send K10010 with MediaType=3 (ReturnAudio), Enable=1 +2. Wait for K10011 response confirming activation +3. Camera initiates DTLS connection back (we become DTLS **server**) +4. Use Channel 1 (IOTCChannelBack) for audio transmission + +### 12.2 Audio TX Frame Format + +All audio TX uses 0x09 single-packet frames with 36-byte header: + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0] 1 Channel 0x03 (Audio) +[1] 1 FrameType 0x09 (StartAlt/Single) +[2-3] 2 Version 0x000C (12) +[4-7] 4 TxSeq Audio TX sequence number (LE) +[8-11] 4 TimestampUS Timestamp in microseconds (LE) +[12-15] 4 Flags 0x00000001 (first), 0x00100001 (subsequent) +[16] 1 Channel 0x03 +[17] 1 FrameType 0x01 (EndSingle) +[18-19] 2 PrevFrameNo prev_frame_no (16-bit, LE) +[20-21] 2 pkt_total 0x0001 (always single packet) +[22-23] 2 Flags 0x0010 +[24-27] 4 PayloadSize audio_len + 16 (includes FRAMEINFO) +[28-31] 4 PrevFrameNo prev_frame_no (32-bit, LE) +[32-35] 4 FrameNo Current frame number (LE) +[36...] AudioPayload AAC/G.711/Opus data +[end-16] 16 FRAMEINFO TX FRAMEINFO (16 bytes) +``` + +--- + +## 13. Frame Reassembly + +### Algorithm + +``` +1. Parse packet header to extract: + - channel, frameType, pkt_idx, pkt_total, frame_no + +2. Detect frame transition: + - If frame_no changed from previous packet: + - Emit previous frame if complete + - Log incomplete frames + +3. Store packet data: + - Key: pkt_idx (0 to pkt_total-1) + - Value: payload bytes (COPY - buffer is reused!) + +4. Store FRAMEINFO if present: + - Only in End packets (0x01, 0x05, 0x0D) + - Or 0x09 when pkt_total == 1 + +5. Check completion: + - All pkt_total packets received? + - FRAMEINFO present? + +6. Assemble frame: + - Concatenate: packets[0] + packets[1] + ... + packets[pkt_total-1] + - Validate size against FRAMEINFO.payload_size + - Emit to consumer +``` + +### Example: Multi-Packet I-Frame (14 packets) + +``` +Packet 1: ch=0x05 type=0x08 pkt=0/14 frame=1 ← Start (36B header) +Packet 2: ch=0x05 type=0x00 pkt=1/14 frame=1 ← Cont (28B header) +Packet 3: ch=0x05 type=0x00 pkt=2/14 frame=1 ← Cont +... +Packet 13: ch=0x05 type=0x00 pkt=12/14 frame=1 ← Cont +Packet 14: ch=0x05 type=0x05 pkt=13/14 frame=1 ← EndMulti + FRAMEINFO +``` + +### Example: Single-Packet P-Frame + +``` +Packet 1: ch=0x07 type=0x01 pkt=0/1 frame=42 ← EndSingle + FRAMEINFO +``` + +--- + +## 14. Wyze Cloud API + +### 14.1 Authentication + +**Endpoint:** `POST https://auth-prod.api.wyze.com/api/user/login` + +**Password Hashing:** Triple MD5 +``` +hash = password +for i in range(3): + hash = MD5(hash).hex() +``` + +**Request Headers:** +``` +Content-Type: application/json +X-API-Key: WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ +Phone-Id: +User-Agent: wyze_ios_2.50.0 +``` + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "" +} +``` + +**Response:** +```json +{ + "access_token": "...", + "refresh_token": "...", + "user_id": "..." +} +``` + +### 14.2 Device List + +**Endpoint:** `POST https://api.wyzecam.com/app/v2/home_page/get_object_list` + +**Request Body:** +```json +{ + "access_token": "", + "phone_id": "", + "app_name": "com.hualai.WyzeCam", + "app_ver": "com.hualai.WyzeCam___2.50.0", + "app_version": "2.50.0", + "phone_system_type": 1, + "sc": "9f275790cab94a72bd206c8876429f3c", + "sv": "9d74946e652647e9b6c9d59326aef104", + "ts": +} +``` + +**Response (filtered for cameras):** +```json +{ + "device_list": [ + { + "mac": "AABBCCDDEEFF", + "p2p_id": "HSBJYB5HSETGCDWD111A", + "enr": "roTRg3tiuL3TjXhm...", + "ip": "192.168.1.100", + "nickname": "Front Door", + "product_model": "HL_CAM4", + "dtls": 1, + "firmware_ver": "4.52.9.4188" + } + ] +} +``` + +--- + +## 15. Cryptography Details + +### 15.1 XXTEA Algorithm + +Block cipher used for K-Auth challenge-response: + +``` +Constants: + DELTA = 0x9E3779B9 + +Function mx(sum, y, z, p, e, k): + return (((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ + ((sum ^ y) + (k[(p & 3) ^ e] ^ z)) + +Decrypt(data, key): + v = data as uint32[] (little-endian) + k = key as uint32[] + n = len(v) + rounds = 6 + 52/n + sum = rounds * DELTA + + for round in range(rounds): + e = (sum >> 2) & 3 + for p in range(n-1, 0, -1): + z = v[p-1] + v[p] -= mx(sum, y=v[(p+1) mod n], z, p, e, k) + y = v[p] + z = v[n-1] + v[0] -= mx(sum, y=v[1], z, 0, e, k) + y = v[0] + sum -= DELTA + + return v as bytes +``` + +### 15.2 TransCode ("Charlie" Cipher) + +Obfuscation cipher for IOTC packets: + +``` +Magic string: "Charlie is the designer of P2P!!" + +Process in 16-byte blocks: + 1. XOR each byte with corresponding position in magic string + 2. Treat as 4 x uint32, rotate left by varying amounts + 3. Apply byte permutation pattern + +Permutation for 16-byte block: + [11, 9, 8, 15, 13, 10, 12, 14, 2, 1, 5, 0, 6, 4, 7, 3] +``` + +### 15.3 AuthKey Calculation + +``` +input = ENR + uppercase(MAC) +hash = SHA256(input) +raw = hash[0:6] +b64 = Base64Encode(raw) +authkey = b64.replace('+', 'Z').replace('/', '9').replace('=', 'A') +``` + +--- + +## 16. Constants Reference + +### 16.1 IOTC Commands + +| Command | Value | Description | +|---------|-------|-------------| +| CmdDiscoReq | 0x0601 | Discovery request | +| CmdDiscoRes | 0x0602 | Discovery response | +| CmdSessionReq | 0x0402 | Session request | +| CmdSessionRes | 0x0404 | Session response | +| CmdDataTX | 0x0407 | Data transmission | +| CmdDataRX | 0x0408 | Data reception | +| CmdKeepaliveReq | 0x0427 | Keepalive request | +| CmdKeepaliveRes | 0x0428 | Keepalive response | + +### 16.2 Magic Values + +| Magic | Value | Description | +|-------|-------|-------------| +| MagicAVLogin1 | 0x0000 | AV Login packet 1 | +| MagicAVLogin2 | 0x2000 | AV Login packet 2 | +| MagicAVLoginResp | 0x2100 | AV Login response | +| MagicIOCtrl | 0x7000 | IOCTRL frame | +| MagicChannelMsg | 0x1000 | Channel message | +| MagicACK | 0x0009 | ACK frame | + +### 16.3 K-Commands + +| Command | ID | Description | +|---------|-----|-------------| +| KCmdAuth | 10000 | Auth request | +| KCmdChallenge | 10001 | Challenge from camera | +| KCmdChallengeResp | 10002 | Challenge response | +| KCmdAuthResult | 10003 | Auth result (JSON) | +| KCmdControlChannel | 10010 | Start/stop media | +| KCmdControlChannelResp | 10011 | Control response | +| KCmdSetResolution | 10056 | Set resolution/bitrate | +| KCmdSetResolutionResp | 10057 | Resolution response | + +### 16.4 IOTYPE Values + +| Type | Value | Description | +|------|-------|-------------| +| IOTypeVideoStart | 0x01FF | Start video | +| IOTypeVideoStop | 0x02FF | Stop video | +| IOTypeAudioStart | 0x0300 | Start audio | +| IOTypeAudioStop | 0x0301 | Stop audio | +| IOTypeSpeakerStart | 0x0350 | Start intercom | +| IOTypeSpeakerStop | 0x0351 | Stop intercom | +| IOTypeDevInfoReq | 0x0340 | Device info request | +| IOTypeDevInfoRes | 0x0341 | Device info response | +| IOTypePTZCommand | 0x1001 | PTZ control | +| IOTypeReceiveFirstFrame | 0x1002 | Request keyframe | + +### 16.5 Protocol Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| DefaultPort | 32761 | TUTK discovery port | +| ProtocolVersion | 0x000C | Version 12 | +| DefaultCapabilities | 0x001F07FB | Standard caps | +| MaxPacketSize | 2048 | Max UDP packet | +| IOTCChannelMain | 0 | Main channel (DTLS client) | +| IOTCChannelBack | 1 | Backchannel (DTLS server) | diff --git a/pkg/wyze/tutk/avframe.go b/pkg/wyze/tutk/avframe.go new file mode 100644 index 00000000..3c125bf7 --- /dev/null +++ b/pkg/wyze/tutk/avframe.go @@ -0,0 +1,126 @@ +package tutk + +import ( + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/aac" +) + +const FrameInfoSize = 40 + +// Wire format (little-endian) - Wyze extended FRAMEINFO: +// +// [0-1] codec_id uint16 (0x004e=H.264, 0x0050=H.265, 0x0088=AAC) +// [2] flags uint8 (Video: 0=P/1=I, Audio: sr_idx<<2|bits16<<1|ch) +// [3] cam_index uint8 +// [4] online_num uint8 +// [5] framerate uint8 (FPS, e.g. 20) +// [6] frame_size uint8 (Resolution: 1=1080P, 2=360P, 4=2K) +// [7] bitrate uint8 (e.g. 0xF0=240) +// [8-11] timestamp_us uint32 (microseconds component) +// [12-15] timestamp uint32 (Unix timestamp in seconds) +// [16-19] payload_sz uint32 (frame payload size) +// [20-23] frame_no uint32 (frame number) +// [24-39] device_id 16 bytes (MAC address + padding) +type FrameInfo struct { + CodecID uint16 + Flags uint8 + CamIndex uint8 + OnlineNum uint8 + Framerate uint8 // FPS (e.g. 20) + FrameSize uint8 // Resolution: 1=1080P, 2=360P, 4=2K + Bitrate uint8 // Bitrate value (e.g. 240) + TimestampUS uint32 + Timestamp uint32 + PayloadSize uint32 + FrameNo uint32 +} + +// Resolution constants (as received in FrameSize field) +// Note: Some cameras only support 2K + 360P, others support 1080P + 360P +// The actual resolution depends on camera model! +const ( + ResolutionUnknown = 0 + ResolutionSD = 1 // 360P (640x360) on 2K cameras, or 1080P on older cams + Resolution360P = 2 // 360P (640x360) + Resolution2K = 4 // 2K (2560x1440) +) + +func (fi *FrameInfo) IsKeyframe() bool { + return fi.Flags == 0x01 +} + +// Resolution returns a human-readable resolution string +func (fi *FrameInfo) Resolution() string { + switch fi.FrameSize { + case ResolutionSD: + return "SD" // Could be 360P or 1080P depending on camera + case Resolution360P: + return "360P" + case Resolution2K: + return "2K" + default: + return "unknown" + } +} + +func (fi *FrameInfo) SampleRate() uint32 { + srIdx := (fi.Flags >> 2) & 0x0F + return uint32(SampleRateValue(srIdx)) +} + +func (fi *FrameInfo) Channels() uint8 { + if fi.Flags&0x01 == 1 { + return 2 + } + return 1 +} + +func (fi *FrameInfo) IsVideo() bool { + return IsVideoCodec(fi.CodecID) +} + +func (fi *FrameInfo) IsAudio() bool { + return IsAudioCodec(fi.CodecID) +} + +func ParseFrameInfo(data []byte) *FrameInfo { + if len(data) < FrameInfoSize { + return nil + } + + offset := len(data) - FrameInfoSize + fi := data[offset:] + + return &FrameInfo{ + CodecID: binary.LittleEndian.Uint16(fi[0:2]), + Flags: fi[2], + CamIndex: fi[3], + OnlineNum: fi[4], + Framerate: fi[5], + FrameSize: fi[6], + Bitrate: fi[7], + TimestampUS: binary.LittleEndian.Uint32(fi[8:12]), + Timestamp: binary.LittleEndian.Uint32(fi[12:16]), + PayloadSize: binary.LittleEndian.Uint32(fi[16:20]), + FrameNo: binary.LittleEndian.Uint32(fi[20:24]), + } +} + +func ParseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { + // Try ADTS header first (more reliable than FRAMEINFO flags) + if aac.IsADTS(payload) { + codec := aac.ADTSToCodec(payload) + if codec != nil { + return codec.ClockRate, codec.Channels + } + } + + // Fallback to FRAMEINFO flags + if fi != nil { + return fi.SampleRate(), fi.Channels() + } + + // Default values + return 16000, 1 +} diff --git a/pkg/wyze/tutk/channel.go b/pkg/wyze/tutk/channel.go new file mode 100644 index 00000000..4fc25e33 --- /dev/null +++ b/pkg/wyze/tutk/channel.go @@ -0,0 +1,64 @@ +package tutk + +import ( + "fmt" + "net" + "time" +) + +type ChannelAdapter struct { + conn *Conn + channel uint8 +} + +func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + var buf chan []byte + if a.channel == IOTCChannelMain { + buf = a.conn.mainBuf + } else { + buf = a.conn.speakerBuf + } + + select { + case data := <-buf: + n = copy(p, data) + if a.conn.verbose && len(data) >= 1 { + fmt.Printf("[ChannelAdapter] ch=%d ReadFrom: len=%d contentType=%d\n", + a.channel, len(data), data[0]) + } + return n, a.conn.addr, nil + case <-a.conn.done: + return 0, nil, net.ErrClosed + } +} + +func (a *ChannelAdapter) WriteTo(p []byte, addr net.Addr) (n int, err error) { + if a.conn.verbose { + fmt.Printf("[IOTC TX] channel=%d size=%d\n", a.channel, len(p)) + } + _, err = a.conn.sendIOTC(p, a.channel) + if err != nil { + return 0, err + } + return len(p), nil +} + +func (a *ChannelAdapter) Close() error { + return nil +} + +func (a *ChannelAdapter) LocalAddr() net.Addr { + return &net.UDPAddr{IP: net.IPv4(0, 0, 0, 0), Port: 0} +} + +func (a *ChannelAdapter) SetDeadline(time.Time) error { + return nil +} + +func (a *ChannelAdapter) SetReadDeadline(time.Time) error { + return nil +} + +func (a *ChannelAdapter) SetWriteDeadline(time.Time) error { + return nil +} diff --git a/pkg/wyze/tutk/cipher.go b/pkg/wyze/tutk/cipher.go new file mode 100644 index 00000000..85831abe --- /dev/null +++ b/pkg/wyze/tutk/cipher.go @@ -0,0 +1,218 @@ +package tutk + +import ( + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "hash" + "sync/atomic" + + "github.com/pion/dtls/v3" + "github.com/pion/dtls/v3/pkg/crypto/clientcertificate" + "github.com/pion/dtls/v3/pkg/crypto/prf" + "github.com/pion/dtls/v3/pkg/protocol" + "github.com/pion/dtls/v3/pkg/protocol/recordlayer" + "golang.org/x/crypto/chacha20poly1305" +) + +const CipherSuiteID_CCAC dtls.CipherSuiteID = 0xCCAC + +const ( + chachaTagLength = 16 + chachaNonceLength = 12 +) + +var ( + errDecryptPacket = &protocol.TemporaryError{Err: errors.New("failed to decrypt packet")} + errCipherSuiteNotInit = &protocol.TemporaryError{Err: errors.New("CipherSuite not initialized")} +) + +type ChaCha20Poly1305Cipher struct { + localCipher, remoteCipher cipher.AEAD + localWriteIV, remoteWriteIV []byte +} + +func NewChaCha20Poly1305Cipher(localKey, localWriteIV, remoteKey, remoteWriteIV []byte) (*ChaCha20Poly1305Cipher, error) { + localCipher, err := chacha20poly1305.New(localKey) + if err != nil { + return nil, err + } + + remoteCipher, err := chacha20poly1305.New(remoteKey) + if err != nil { + return nil, err + } + + return &ChaCha20Poly1305Cipher{ + localCipher: localCipher, + localWriteIV: localWriteIV, + remoteCipher: remoteCipher, + remoteWriteIV: remoteWriteIV, + }, nil +} + +func generateAEADAdditionalData(h *recordlayer.Header, payloadLen int) []byte { + var additionalData [13]byte + + binary.BigEndian.PutUint64(additionalData[:], h.SequenceNumber) + binary.BigEndian.PutUint16(additionalData[:], h.Epoch) + additionalData[8] = byte(h.ContentType) + additionalData[9] = h.Version.Major + additionalData[10] = h.Version.Minor + binary.BigEndian.PutUint16(additionalData[11:], uint16(payloadLen)) + + return additionalData[:] +} + +func computeNonce(iv []byte, epoch uint16, sequenceNumber uint64) []byte { + nonce := make([]byte, chachaNonceLength) + + binary.BigEndian.PutUint64(nonce[4:], sequenceNumber) + binary.BigEndian.PutUint16(nonce[4:], epoch) + + for i := 0; i < chachaNonceLength; i++ { + nonce[i] ^= iv[i] + } + + return nonce +} + +func (c *ChaCha20Poly1305Cipher) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) { + payload := raw[pkt.Header.Size():] + raw = raw[:pkt.Header.Size()] + + nonce := computeNonce(c.localWriteIV, pkt.Header.Epoch, pkt.Header.SequenceNumber) + additionalData := generateAEADAdditionalData(&pkt.Header, len(payload)) + encryptedPayload := c.localCipher.Seal(nil, nonce, payload, additionalData) + + r := make([]byte, len(raw)+len(encryptedPayload)) + copy(r, raw) + copy(r[len(raw):], encryptedPayload) + + binary.BigEndian.PutUint16(r[pkt.Header.Size()-2:], uint16(len(r)-pkt.Header.Size())) + + return r, nil +} + +func (c *ChaCha20Poly1305Cipher) Decrypt(header recordlayer.Header, in []byte) ([]byte, error) { + err := header.Unmarshal(in) + switch { + case err != nil: + return nil, err + case header.ContentType == protocol.ContentTypeChangeCipherSpec: + return in, nil + case len(in) <= header.Size()+chachaTagLength: + return nil, fmt.Errorf("ciphertext too short: %d <= %d", len(in), header.Size()+chachaTagLength) + } + + nonce := computeNonce(c.remoteWriteIV, header.Epoch, header.SequenceNumber) + out := in[header.Size():] + additionalData := generateAEADAdditionalData(&header, len(out)-chachaTagLength) + + out, err = c.remoteCipher.Open(out[:0], nonce, out, additionalData) + if err != nil { + return nil, fmt.Errorf("%w: %v", errDecryptPacket, err) + } + + return append(in[:header.Size()], out...), nil +} + +type TLSEcdhePskWithChacha20Poly1305Sha256 struct { + aead atomic.Value +} + +func NewTLSEcdhePskWithChacha20Poly1305Sha256() *TLSEcdhePskWithChacha20Poly1305Sha256 { + return &TLSEcdhePskWithChacha20Poly1305Sha256{} +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) CertificateType() clientcertificate.Type { + return clientcertificate.Type(0) +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) KeyExchangeAlgorithm() dtls.CipherSuiteKeyExchangeAlgorithm { + return dtls.CipherSuiteKeyExchangeAlgorithmPsk | dtls.CipherSuiteKeyExchangeAlgorithmEcdhe +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ECC() bool { + return true +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ID() dtls.CipherSuiteID { + return CipherSuiteID_CCAC +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) String() string { + return "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256" +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) HashFunc() func() hash.Hash { + return sha256.New +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) AuthenticationType() dtls.CipherSuiteAuthenticationType { + return dtls.CipherSuiteAuthenticationTypePreSharedKey +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) IsInitialized() bool { + return c.aead.Load() != nil +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Init(masterSecret, clientRandom, serverRandom []byte, isClient bool) error { + const ( + prfMacLen = 0 + prfKeyLen = 32 + prfIvLen = 12 + ) + + keys, err := prf.GenerateEncryptionKeys( + masterSecret, clientRandom, serverRandom, + prfMacLen, prfKeyLen, prfIvLen, + c.HashFunc(), + ) + if err != nil { + return err + } + + var aead *ChaCha20Poly1305Cipher + if isClient { + aead, err = NewChaCha20Poly1305Cipher( + keys.ClientWriteKey, keys.ClientWriteIV, + keys.ServerWriteKey, keys.ServerWriteIV, + ) + } else { + aead, err = NewChaCha20Poly1305Cipher( + keys.ServerWriteKey, keys.ServerWriteIV, + keys.ClientWriteKey, keys.ClientWriteIV, + ) + } + if err != nil { + return err + } + + c.aead.Store(aead) + return nil +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) { + aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher) + if !ok { + return nil, fmt.Errorf("%w: unable to encrypt", errCipherSuiteNotInit) + } + return aead.Encrypt(pkt, raw) +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Decrypt(h recordlayer.Header, raw []byte) ([]byte, error) { + aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher) + if !ok { + return nil, fmt.Errorf("%w: unable to decrypt", errCipherSuiteNotInit) + } + return aead.Decrypt(h, raw) +} + +func CustomCipherSuites() []dtls.CipherSuite { + return []dtls.CipherSuite{ + NewTLSEcdhePskWithChacha20Poly1305Sha256(), + } +} diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go new file mode 100644 index 00000000..53659b84 --- /dev/null +++ b/pkg/wyze/tutk/conn.go @@ -0,0 +1,1555 @@ +package tutk + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "os" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/wyze/crypto" + "github.com/pion/dtls/v3" +) + +const ( + PSKIdentity = "AUTHPWD_admin" + DefaultUser = "admin" + DefaultPort = 32761 // TUTK discovery port + MaxPacketSize = 2048 // Max single packet size + ReadBufferSize = 2 * 1024 * 1024 // 2MB for video streams +) + +type FrameAssembler struct { + frameNo uint32 + pktTotal uint16 + packets map[uint16][]byte // pkt_idx -> payload + frameInfo *FrameInfo +} + +type Conn struct { + udpConn *net.UDPConn + addr *net.UDPAddr + broadcastAddr *net.UDPAddr + randomID []byte + uid string + authKey string + enr string + psk []byte + iotcTxSeq uint16 + avLoginResp *AVLoginResponse + + // DTLS - Main Channel (we = Client) + mainConn *dtls.Conn + mainBuf chan []byte + + // DTLS - Speaker Channel (we = Server) + speakerConn *dtls.Conn + speakerBuf chan []byte + + ioctrl chan []byte + ackReceived chan struct{} + errors chan error + + frameAssemblers map[byte]*FrameAssembler // channel -> assembler + packetQueue chan *Packet + + avTxSeq uint32 + ioctrlSeq uint16 + + // Audio TX state (for intercom) + audioTxSeq uint32 + audioTxFrameNo uint32 + + lastAckCounter uint16 + ackFlags uint16 + + baseTS uint64 + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex + done chan struct{} + verbose bool +} + +func Dial(host, uid, authKey, enr string, verbose bool) (*Conn, error) { + conn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + _ = conn.SetReadBuffer(ReadBufferSize) + + ctx, cancel := context.WithCancel(context.Background()) + + hash := sha256.Sum256([]byte(enr)) + psk := hash[:] + + c := &Conn{ + udpConn: conn, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, + broadcastAddr: &net.UDPAddr{IP: net.IPv4(255, 255, 255, 255), Port: DefaultPort}, + randomID: genRandomID(), + uid: uid, + authKey: authKey, + enr: enr, + psk: psk, + verbose: verbose, + ctx: ctx, + cancel: cancel, + // DTLS channel buffers + mainBuf: make(chan []byte, 64), + speakerBuf: make(chan []byte, 64), + // Packet delivery (SDK-style FIFO) + packetQueue: make(chan *Packet, 128), + done: make(chan struct{}), + ioctrl: make(chan []byte, 16), + ackReceived: make(chan struct{}, 1), + errors: make(chan error, 1), + } + + if err = c.discovery(); err != nil { + _ = c.Close() + return nil, err + } + + // Start IOTC reader goroutine for DTLS routing + c.wg.Add(1) + go c.iotcReader() + + // Perform DTLS client handshake on Main channel + if err = c.connect(); err != nil { + _ = c.Close() + return nil, err + } + + // Start AV data worker + c.wg.Add(1) + go c.worker() + + return c, nil +} + +func (c *Conn) AVClientStart(timeout time.Duration) error { + randomID := genRandomID() + pkt1 := c.buildAVLoginPacket(MagicAVLogin1, 570, 0x0001, randomID) + pkt2 := c.buildAVLoginPacket(MagicAVLogin2, 572, 0x0000, randomID) + pkt2[20]++ // pkt2 has randomID incremented by 1 + + if _, err := c.mainConn.Write(pkt1); err != nil { + return fmt.Errorf("AV login 1 failed: %w", err) + } + + time.Sleep(50 * time.Millisecond) + + if _, err := c.mainConn.Write(pkt2); err != nil { + return fmt.Errorf("AV login 2 failed: %w", err) + } + + // Wait for response + deadline := time.Now().Add(timeout) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return context.DeadlineExceeded + } + + select { + case data, ok := <-c.ioctrl: + if !ok { + return io.EOF + } + if len(data) >= 32 && binary.LittleEndian.Uint16(data[0:2]) == MagicAVLoginResp { + // Parse response inline + c.avLoginResp = &AVLoginResponse{ + ServerType: binary.LittleEndian.Uint32(data[4:8]), + Resend: int32(data[29]), + TwoWayStreaming: int32(data[31]), + } + + if c.verbose { + fmt.Printf("[TUTK] AV Login Response: two_way_streaming=%d\n", c.avLoginResp.TwoWayStreaming) + } + + _ = c.sendACK() + return nil + } + case <-c.ctx.Done(): + return c.ctx.Err() + } + } +} + +func (c *Conn) AVServStart() error { + if c.verbose { + fmt.Printf("[DTLS] Waiting for client handshake on channel %d\n", IOTCChannelBack) + fmt.Printf("[DTLS] PSK Identity: %s\n", PSKIdentity) + fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) + } + + config := c.buildDTLSConfig(true) + + // Create adapter for speaker channel + adapter := &ChannelAdapter{ + conn: c, + channel: IOTCChannelBack, + } + + conn, err := dtls.Server(adapter, c.addr, config) + if err != nil { + return fmt.Errorf("dtls: server handshake failed: %w", err) + } + + c.mu.Lock() + c.speakerConn = conn + c.mu.Unlock() + + if c.verbose { + fmt.Printf("[DTLS] Server handshake complete on channel %d\n", IOTCChannelBack) + } + + // Wait for and respond to AV Login request from camera + if err := c.handleSpeakerAVLogin(); err != nil { + return fmt.Errorf("speaker AV login failed: %w", err) + } + + return nil +} + +func (c *Conn) AVServStop() error { + c.mu.Lock() + defer c.mu.Unlock() + + // Reset audio TX state + c.audioTxSeq = 0 + c.audioTxFrameNo = 0 + + if c.speakerConn != nil { + err := c.speakerConn.Close() + c.speakerConn = nil + return err + } + return nil +} + +func (c *Conn) AVRecvFrameData() (*Packet, error) { + select { + case pkt, ok := <-c.packetQueue: + if !ok { + return nil, io.EOF + } + return pkt, nil + case err := <-c.errors: + return nil, err + case <-c.done: + return nil, io.EOF + case <-c.ctx.Done(): + return nil, io.EOF + } +} + +func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { + c.mu.Lock() + conn := c.speakerConn + if conn == nil { + c.mu.Unlock() + return fmt.Errorf("speaker channel not connected") + } + + // Build frame with 36-byte header + audio + 16-byte FrameInfo (FrameInfo inside payload!) + frame := c.buildAudioFrame(payload, timestampUS, codec, sampleRate, channels) + + if c.verbose { + c.logAudioTX(frame, codec, len(payload), timestampUS, sampleRate, channels) + } + c.mu.Unlock() + + n, err := conn.Write(frame) + if c.verbose { + if err != nil { + fmt.Printf("[AUDIO TX] DTLS Write ERROR: %v\n", err) + } else { + fmt.Printf("[AUDIO TX] DTLS Write OK: %d bytes\n", n) + } + } + return err +} + +func (c *Conn) SendIOCtrl(cmdID uint16, payload []byte) error { + frame := c.buildIOCtrlFrame(payload) + if _, err := c.mainConn.Write(frame); err != nil { + return err + } + + // Block until ACK received (like SDK) + select { + case <-c.ackReceived: + if c.verbose { + fmt.Printf("[Conn] SendIOCtrl K%d: ACK received\n", cmdID) + } + return nil + case <-time.After(5 * time.Second): + return fmt.Errorf("ACK timeout for K%d", cmdID) + case <-c.ctx.Done(): + return c.ctx.Err() + } +} + +func (c *Conn) RecvIOCtrl(timeout time.Duration) (cmdID uint16, data []byte, err error) { + select { + case data, ok := <-c.ioctrl: + if !ok { + return 0, nil, io.EOF + } + // Parse cmdID from HL header at offset 4-5 + if len(data) >= 6 { + cmdID = binary.LittleEndian.Uint16(data[4:6]) + } + // Send ACK after receiving + _ = c.sendACK() + if c.verbose { + fmt.Printf("[Conn] RecvIOCtrl: received K%d (%d bytes)\n", cmdID, len(data)) + } + return cmdID, data, nil + case <-time.After(timeout): + return 0, nil, context.DeadlineExceeded + case <-c.ctx.Done(): + return 0, nil, c.ctx.Err() + } +} + +func (c *Conn) GetAVLoginResponse() *AVLoginResponse { + return c.avLoginResp +} + +func (c *Conn) IsBackchannelReady() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.speakerConn != nil +} + +func (c *Conn) RemoteAddr() *net.UDPAddr { + return c.addr +} + +func (c *Conn) LocalAddr() *net.UDPAddr { + return c.udpConn.LocalAddr().(*net.UDPAddr) +} + +func (c *Conn) SetDeadline(t time.Time) error { + return c.udpConn.SetDeadline(t) +} + +func (c *Conn) Close() error { + // Signal done to stop goroutines + select { + case <-c.done: + default: + close(c.done) + } + + // Close DTLS connections + c.mu.Lock() + if c.mainConn != nil { + c.mainConn.Close() + c.mainConn = nil + } + if c.speakerConn != nil { + c.speakerConn.Close() + c.speakerConn = nil + } + c.mu.Unlock() + + c.cancel() + + // Wait for goroutines + c.wg.Wait() + + close(c.ioctrl) + close(c.errors) + + return c.udpConn.Close() +} + +func (c *Conn) discovery() error { + _ = c.udpConn.SetDeadline(time.Now().Add(10 * time.Second)) + + if err := c.discoStage1(); err != nil { + return fmt.Errorf("disco stage 1: %w", err) + } + + c.discoStage2() + + if err := c.sessionSetup(); err != nil { + return fmt.Errorf("session setup: %w", err) + } + + _ = c.udpConn.SetDeadline(time.Time{}) + return nil +} + +func (c *Conn) discoStage1() error { + pkt := c.buildDisco(1) + encrypted := crypto.TransCodeBlob(pkt) + + if c.verbose { + fmt.Printf("[IOTC] Disco Stage 1: broadcast + direct to %s\n", c.addr) + } + + for range 10 { + _, _ = c.udpConn.WriteToUDP(encrypted, c.broadcastAddr) + + if _, err := c.udpConn.WriteToUDP(encrypted, c.addr); err != nil { + return err + } + + buf := make([]byte, MaxPacketSize) + c.udpConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, addr, err := c.udpConn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return err + } + + data := crypto.ReverseTransCodeBlob(buf[:n]) + if len(data) < 16 { + continue + } + + cmd := binary.LittleEndian.Uint16(data[8:10]) + if c.verbose { + fmt.Printf("[IOTC] Disco Stage 1: received cmd=0x%04x from %s\n", cmd, addr) + } + + if cmd == CmdDiscoRes { + c.addr = addr + if c.verbose { + fmt.Printf("[IOTC] Disco Stage 1: success! Camera at %s\n", addr) + } + return nil + } + } + + return fmt.Errorf("timeout") +} + +func (c *Conn) discoStage2() { + pkt := c.buildDisco(2) + encrypted := crypto.TransCodeBlob(pkt) + _, _ = c.udpConn.WriteToUDP(encrypted, c.addr) + time.Sleep(100 * time.Millisecond) +} + +func (c *Conn) sessionSetup() error { + pkt := c.buildSession() + + if c.verbose { + fmt.Printf("[IOTC] Session setup: sending to %s\n", c.addr) + } + + if _, err := c.sendEncrypted(pkt); err != nil { + return err + } + + for retry := range 10 { + buf := make([]byte, MaxPacketSize) + c.udpConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, addr, err := c.udpConn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if retry%3 == 2 { + if c.verbose { + fmt.Printf("[IOTC] Session setup: resending (retry %d)\n", retry) + } + _, _ = c.sendEncrypted(pkt) + } + continue + } + return err + } + + data := crypto.ReverseTransCodeBlob(buf[:n]) + if len(data) < 16 { + continue + } + + cmd := binary.LittleEndian.Uint16(data[8:10]) + if c.verbose { + fmt.Printf("[IOTC] Session setup: received cmd=0x%04x from %s\n", cmd, addr) + } + + if cmd == CmdSessionRes { + c.addr = addr + if c.verbose { + fmt.Printf("[IOTC] Session setup: success!\n") + } + return nil + } + } + + return fmt.Errorf("timeout") +} + +func (c *Conn) connect() error { + if c.verbose { + fmt.Printf("[DTLS] Starting client handshake on channel %d\n", IOTCChannelMain) + fmt.Printf("[DTLS] PSK Identity: %s\n", PSKIdentity) + fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) + } + + config := c.buildDTLSConfig(false) + + // Create adapter for main channel + adapter := &ChannelAdapter{ + conn: c, + channel: IOTCChannelMain, + } + + conn, err := dtls.Client(adapter, c.addr, config) + if err != nil { + return fmt.Errorf("dtls: client handshake failed: %w", err) + } + + c.mu.Lock() + c.mainConn = conn + c.mu.Unlock() + + if c.verbose { + fmt.Printf("[DTLS] Client handshake complete on channel %d\n", IOTCChannelMain) + } + + return nil +} + +func (c *Conn) iotcReader() { + defer c.wg.Done() + + buf := make([]byte, MaxPacketSize) + + for { + select { + case <-c.done: + return + default: + } + + // Inline receive with timeout + c.udpConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.udpConn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return + } + + data := crypto.ReverseTransCodeBlob(buf[:n]) + if addr.Port != c.addr.Port || !addr.IP.Equal(c.addr.IP) { + c.addr = addr + } + + if len(data) < 16 { + continue + } + + cmd := binary.LittleEndian.Uint16(data[8:10]) + + if cmd == CmdKeepaliveRes && len(data) > 16 { + payload := data[16:] + if len(payload) >= 8 { + keepaliveResp := c.buildKeepaliveResponse(payload) + _, _ = c.sendEncrypted(keepaliveResp) + if c.verbose { + fmt.Printf("[DTLS] Keepalive response sent\n") + } + } + continue + } + + if cmd == CmdDataRX && len(data) > 28 { + // Debug: Dump IOTC header to verify structure + if c.verbose && len(data) >= 32 { + fmt.Printf("[IOTC] RX Header dump (32 bytes):\n") + fmt.Printf(" [0-7]: %02x %02x %02x %02x %02x %02x %02x %02x\n", + data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]) + fmt.Printf(" [8-15]: %02x %02x %02x %02x %02x %02x %02x %02x (cmd@8-9, ch@14)\n", + data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15]) + fmt.Printf(" [16-23]: %02x %02x %02x %02x %02x %02x %02x %02x\n", + data[16], data[17], data[18], data[19], data[20], data[21], data[22], data[23]) + fmt.Printf(" [24-31]: %02x %02x %02x %02x %02x %02x %02x %02x (dtls starts @28)\n", + data[24], data[25], data[26], data[27], data[28], data[29], data[30], data[31]) + } + + dtlsPayload := data[28:] + + // Channel byte is at position 14 in IOTC header + channel := data[14] + + if c.verbose { + fmt.Printf("[IOTC] RX cmd=0x%04x len=%d ch=%d dtlsLen=%d\n", cmd, len(data), channel, len(dtlsPayload)) + if len(dtlsPayload) >= 13 { + contentType := dtlsPayload[0] + fmt.Printf("[DTLS] ch=%d contentType=%d first8=[%02x %02x %02x %02x %02x %02x %02x %02x]\n", + channel, contentType, dtlsPayload[0], dtlsPayload[1], dtlsPayload[2], dtlsPayload[3], + dtlsPayload[4], dtlsPayload[5], dtlsPayload[6], dtlsPayload[7]) + } + } + + // Copy data since buffer is reused + dataCopy := make([]byte, len(dtlsPayload)) + copy(dataCopy, dtlsPayload) + + // Route based on channel + var buf chan []byte + switch channel { + case IOTCChannelMain: + buf = c.mainBuf + case IOTCChannelBack: + buf = c.speakerBuf + } + + if buf != nil { + select { + case buf <- dataCopy: + default: + // Drop oldest if full + select { + case <-buf: + default: + } + buf <- dataCopy + } + } + } + } +} + +func (c *Conn) worker() { + defer c.wg.Done() + + buf := make([]byte, 2048) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + n, err := c.mainConn.Read(buf) + if err != nil { + select { + case c.errors <- err: + default: + } + return + } + + if n < 2 { + continue + } + + // Debug: dump first bytes to see what we actually receive + if c.verbose && n >= 36 { + fmt.Printf("[Conn] worker raw: n=%d\n", n) + fmt.Printf("[Conn] first16: %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x\n", + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15]) + fmt.Printf("[Conn] off16-31: %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x\n", + buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23], + buf[24], buf[25], buf[26], buf[27], buf[28], buf[29], buf[30], buf[31]) + } else if c.verbose && n >= 8 { + fmt.Printf("[Conn] worker raw: n=%d first8=[%02x %02x %02x %02x %02x %02x %02x %02x]\n", + n, buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7]) + } + + c.route(buf[:n]) + } +} + +func (c *Conn) route(data []byte) { + // [channel][frameType][version_lo][version_hi][seq_lo][seq_hi]... + // channel: 0x03=Audio, 0x05=I-Video, 0x07=P-Video + // frameType: 0x00=cont, 0x05=end, 0x08=I-start, 0x0d=end-44 + + if len(data) < 2 { + return + } + + // Check for control frame magic values first (uint16 LE) + magic := binary.LittleEndian.Uint16(data[0:2]) + + switch magic { + case MagicAVLoginResp: + // AV Login Response - send full data for parsing + c.queueIOCtrlData(data) + return + + case MagicIOCtrl: + // IOCTRL Response Frame (K10001, K10003) + if len(data) >= 32 { + for i := 32; i+2 < len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + c.queueIOCtrlData(data[i:]) + return + } + } + } + return + + case MagicChannelMsg: + // Channel message + if len(data) >= 36 { + opCode := data[16] + if opCode == 0x00 { + for i := 36; i+2 < len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + c.queueIOCtrlData(data[i:]) + return + } + } + } + } + return + + case MagicACK: + // ACK from camera + select { + case c.ackReceived <- struct{}{}: + default: + } + return + } + + // Check for AV Data packet (channel byte at offset 0) + channel := data[0] + if channel == ChannelAudio || channel == ChannelIVideo || channel == ChannelPVideo { + c.handleAVData(data) + return + } + + // Unknown packet type + if c.verbose { + fmt.Printf("[Conn] Unknown frame: type=0x%02x len=%d\n", data[0], len(data)) + } +} + +func (c *Conn) handleSpeakerAVLogin() error { + // Read AV Login request from camera (SDK receives 570 bytes) + buf := make([]byte, 1024) + c.speakerConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, err := c.speakerConn.Read(buf) + if err != nil { + return fmt.Errorf("read AV login: %w", err) + } + + if c.verbose { + fmt.Printf("[SPEAK] Received AV Login request: %d bytes\n", n) + } + + // Need at least 24 bytes to read the checksum + if n < 24 { + return fmt.Errorf("AV login too short: %d bytes", n) + } + + // Extract checksum from incoming request (bytes 20-23) - MUST echo this back! + checksum := binary.LittleEndian.Uint32(buf[20:24]) + + // Build AV Login response (60 bytes like SDK) + resp := c.buildAVLoginResponse(checksum) + + if c.verbose { + fmt.Printf("[SPEAK] Sending AV Login response: %d bytes\n", len(resp)) + } + + _, err = c.speakerConn.Write(resp) + if err != nil { + return fmt.Errorf("write AV login response: %w", err) + } + + // Camera will resend AV-Login, respond again with AV-LoginResp + c.speakerConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, _ = c.speakerConn.Read(buf) + if n > 0 { + if c.verbose { + fmt.Printf("[SPEAK] Received AV Login resend: %d bytes\n", n) + } + // Send second AV-LoginResp + if c.verbose { + fmt.Printf("[SPEAK] Sending second AV Login response: %d bytes\n", len(resp)) + } + c.speakerConn.Write(resp) + } + + // Clear deadline + c.speakerConn.SetReadDeadline(time.Time{}) + + if c.verbose { + fmt.Printf("[SPEAK] AV Login complete, ready for audio\n") + } + + return nil +} + +func (c *Conn) handleAVData(data []byte) { + // Parse packet header to get pkt_idx, pkt_total, frame_no + hdr := ParsePacketHeader(data) + if hdr == nil { + fmt.Printf("[Conn] Invalid AV packet header, len=%d\n", len(data)) + return + } + + // Debug: Log raw Wire-Header bytes + if c.verbose { + fmt.Printf("[WIRE] ch=0x%02x type=0x%02x len=%d pkt=%d/%d frame=%d\n", + hdr.Channel, hdr.FrameType, len(data), hdr.PktIdx, hdr.PktTotal, hdr.FrameNo) + fmt.Printf(" RAW[0..35]: ") + for i := 0; i < 36 && i < len(data); i++ { + fmt.Printf("%02x ", data[i]) + } + fmt.Printf("\n") + } + + // Extract payload and try to detect FRAMEINFO + payload, fi := c.extractPayload(data, hdr.Channel) + if payload == nil { + return + } + + if c.verbose { + c.logAVPacket(hdr.Channel, hdr.FrameType, payload, fi) + } + + // Route to handler + switch hdr.Channel { + case ChannelAudio: + c.handleAudio(payload, fi) + case ChannelIVideo, ChannelPVideo: + c.handleVideo(hdr.Channel, hdr, payload, fi) + } +} + +func (c *Conn) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { + if len(data) < 2 { + return nil, nil + } + + frameType := data[1] + + // Determine header size and FrameInfo size based on frameType + headerSize := 28 + frameInfoSize := 0 // 0 means no FrameInfo + + switch frameType { + case FrameTypeStart: + // Extended start packet - 36-byte header, no FrameInfo + headerSize = 36 + case FrameTypeStartAlt: + // StartAlt - 36-byte header + // Has FrameInfo only if pkt_total == 1 (single-packet frame) + headerSize = 36 + if len(data) >= 22 { + pktTotal := uint16(data[20]) | uint16(data[21])<<8 + if pktTotal == 1 { + frameInfoSize = FrameInfoSize + } + } + case FrameTypeCont, FrameTypeContAlt: + // Continuation packet - standard 28-byte header, no FrameInfo + headerSize = 28 + case FrameTypeEndSingle, FrameTypeEndMulti: + // End packet - standard 28-byte header, 40-byte FrameInfo + headerSize = 28 + frameInfoSize = FrameInfoSize + case FrameTypeEndExt: + // Extended end packet - 36-byte header, 40-byte FrameInfo + headerSize = 36 + frameInfoSize = FrameInfoSize + default: + // Unknown frame type - use 28-byte header as fallback (most common) + headerSize = 28 + } + + if len(data) < headerSize { + return nil, nil + } + + // If this packet type doesn't have FrameInfo, return payload without it + if frameInfoSize == 0 { + return data[headerSize:], nil + } + + // End packets have FrameInfo - validate size + if len(data) < headerSize+frameInfoSize { + return data[headerSize:], nil + } + + fi := ParseFrameInfo(data) + + // Validate codec matches channel type + validCodec := false + switch channel { + case ChannelIVideo, ChannelPVideo: + validCodec = IsVideoCodec(fi.CodecID) + case ChannelAudio: + validCodec = IsAudioCodec(fi.CodecID) + } + + if validCodec { + if c.verbose { + fiRaw := data[len(data)-frameInfoSize:] + fmt.Printf("[FRAMEINFO RAW %d bytes]:\n", frameInfoSize) + fmt.Printf(" [0-15]: ") + for i := 0; i < 16 && i < len(fiRaw); i++ { + fmt.Printf("%02x ", fiRaw[i]) + } + fmt.Printf("\n [16-31]: ") + for i := 16; i < 32 && i < len(fiRaw); i++ { + fmt.Printf("%02x ", fiRaw[i]) + } + fmt.Printf("\n [32-%d]: ", frameInfoSize-1) + for i := 32; i < frameInfoSize && i < len(fiRaw); i++ { + fmt.Printf("%02x ", fiRaw[i]) + } + fmt.Printf("\n") + } + + payload := data[headerSize : len(data)-frameInfoSize] + return payload, fi + } + + return data[headerSize:], nil +} + +func (c *Conn) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) { + if c.frameAssemblers == nil { + c.frameAssemblers = make(map[byte]*FrameAssembler) + } + + asm := c.frameAssemblers[channel] + + // Frame transition detection: new frame number = previous frame complete + if asm != nil && hdr.FrameNo != asm.frameNo { + gotAll := uint16(len(asm.packets)) == asm.pktTotal + + if gotAll && asm.frameInfo != nil { + // Perfect: all packets + FrameInfo present + c.assembleAndQueueVideo(channel, asm) + } else if c.verbose { + // Debugging: what exactly is missing? + if gotAll && asm.frameInfo == nil { + fmt.Printf("[VIDEO] Frame #%d: all %d packets received but End packet lost (no FrameInfo)\n", + asm.frameNo, asm.pktTotal) + } else { + fmt.Printf("[VIDEO] Frame #%d: incomplete %d/%d packets\n", + asm.frameNo, len(asm.packets), asm.pktTotal) + } + } + asm = nil + } + + // Create new assembler if needed + if asm == nil { + asm = &FrameAssembler{ + frameNo: hdr.FrameNo, + pktTotal: hdr.PktTotal, + packets: make(map[uint16][]byte, hdr.PktTotal), + } + c.frameAssemblers[channel] = asm + } + + // Store packet (with pkt_idx as key!) + // IMPORTANT: Always register the packet, even if payload is empty! + // End packets may have 0 bytes payload (all data in previous packets) + // but still need to be counted for completeness check. + // CRITICAL: Must copy payload! The underlying buffer is reused by the worker. + payloadCopy := make([]byte, len(payload)) + copy(payloadCopy, payload) + asm.packets[hdr.PktIdx] = payloadCopy + + // Store FrameInfo if present + if fi != nil { + asm.frameInfo = fi + } + + // Check if frame is complete + if uint16(len(asm.packets)) == asm.pktTotal && asm.frameInfo != nil { + c.assembleAndQueueVideo(channel, asm) + delete(c.frameAssemblers, channel) + } +} + +func (c *Conn) assembleAndQueueVideo(channel byte, asm *FrameAssembler) { + fi := asm.frameInfo + + // Assemble packets in correct order + var payload []byte + for i := uint16(0); i < asm.pktTotal; i++ { + if pkt, ok := asm.packets[i]; ok { + payload = append(payload, pkt...) + } + } + + // Size validation + if fi.PayloadSize > 0 && len(payload) != int(fi.PayloadSize) { + if c.verbose { + fmt.Printf("[VIDEO] Frame #%d size mismatch: got=%d expected=%d, discarding\n", + asm.frameNo, len(payload), fi.PayloadSize) + } + return + } + + if len(payload) == 0 { + return + } + + // Calculate RTP timestamp (90kHz for video) using relative timestamps + // to avoid uint64 overflow (absoluteTS * clockRate exceeds uint64 max) + absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) + if c.baseTS == 0 { + c.baseTS = absoluteTS + } + relativeUS := absoluteTS - c.baseTS + const clockRate uint64 = 90000 + rtpTS := uint32(relativeUS * clockRate / 1000000) + + pkt := &Packet{ + Channel: channel, + Payload: payload, + Codec: fi.CodecID, + Timestamp: rtpTS, + IsKeyframe: fi.IsKeyframe(), + FrameNo: fi.FrameNo, + } + + if c.verbose { + frameType := "P" + if fi.IsKeyframe() { + frameType = "I" + } + fmt.Printf("[VIDEO] #%d %s %s size=%d rtp=%d\n", + fi.FrameNo, CodecName(fi.CodecID), frameType, len(payload), rtpTS) + } + + c.queuePacket(pkt) +} + +func (c *Conn) handleAudio(payload []byte, fi *FrameInfo) { + if len(payload) == 0 || fi == nil { + return + } + + var sampleRate uint32 + var channels uint8 + + // Parse ADTS for AAC codecs, use FRAMEINFO for others + switch fi.CodecID { + case AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACLATM, AudioCodecAACWyze: + sampleRate, channels = ParseAudioParams(payload, fi) + default: + sampleRate = fi.SampleRate() + channels = fi.Channels() + } + + // Calculate RTP timestamp using relative timestamps to avoid uint64 overflow + // Uses shared baseTS with video for proper A/V sync + absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) + if c.baseTS == 0 { + c.baseTS = absoluteTS + } + relativeUS := absoluteTS - c.baseTS + clockRate := uint64(sampleRate) + rtpTS := uint32(relativeUS * clockRate / 1000000) + + pkt := &Packet{ + Channel: ChannelAudio, + Payload: payload, + Codec: fi.CodecID, + Timestamp: rtpTS, + SampleRate: sampleRate, + Channels: channels, + FrameNo: fi.FrameNo, + } + + if c.verbose { + fmt.Printf("[AUDIO] #%d %s size=%d rate=%d ch=%d rtp=%d\n", + fi.FrameNo, AudioCodecName(fi.CodecID), len(payload), sampleRate, channels, rtpTS) + } + + c.queuePacket(pkt) +} + +func (c *Conn) queuePacket(pkt *Packet) { + select { + case c.packetQueue <- pkt: + default: + // Queue full - drop oldest + select { + case <-c.packetQueue: + default: + } + c.packetQueue <- pkt + } +} + +func (c *Conn) queueIOCtrlData(data []byte) { + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + + select { + case c.ioctrl <- dataCopy: + default: + select { + case <-c.ioctrl: + default: + } + c.ioctrl <- dataCopy + } +} + +func (c *Conn) sendACK() error { + ack := c.buildACK() + + if c.verbose { + fmt.Printf("[Conn] SendACK: txSeq=%d flags=0x%04x\n", c.avTxSeq-1, c.ackFlags) + } + + _, err := c.mainConn.Write(ack) + return err +} + +func (c *Conn) sendIOTC(payload []byte, channel byte) (int, error) { + frame := c.buildDataTXChannel(payload, channel) + return c.sendEncrypted(frame) +} + +func (c *Conn) sendEncrypted(data []byte) (int, error) { + encrypted := crypto.TransCodeBlob(data) + return c.udpConn.WriteToUDP(encrypted, c.addr) +} + +func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { + const frameInfoSize = 16 + const headerSize = 36 + + c.audioTxSeq++ + c.audioTxFrameNo++ + + totalPayload := len(payload) + frameInfoSize + frame := make([]byte, headerSize+totalPayload) + + // Calculate prev_frame_no (0 for first frame, otherwise frame_no - 1) + prevFrameNo := uint32(0) + if c.audioTxFrameNo > 1 { + prevFrameNo = c.audioTxFrameNo - 1 + } + + // Type 0x09 "Single" - 36-byte header with full timestamp + frame[0] = ChannelAudio // 0x03 + frame[1] = FrameTypeStartAlt // 0x09 + binary.LittleEndian.PutUint16(frame[2:4], ProtocolVersion) // 0x000c + + binary.LittleEndian.PutUint32(frame[4:8], c.audioTxSeq) + binary.LittleEndian.PutUint32(frame[8:12], timestampUS) // Timestamp in header + + // Flags at [12-15]: first frame uses 0x00000001, subsequent use 0x00100001 + if c.audioTxFrameNo == 1 { + binary.LittleEndian.PutUint32(frame[12:16], 0x00000001) + } else { + binary.LittleEndian.PutUint32(frame[12:16], 0x00100001) + } + + // Inner header + frame[16] = ChannelAudio // 0x03 + frame[17] = FrameTypeEndSingle // 0x01 + binary.LittleEndian.PutUint16(frame[18:20], uint16(prevFrameNo)) // prev_frame_no (16-bit) + + binary.LittleEndian.PutUint16(frame[20:22], 0x0001) // pkt_total = 1 + binary.LittleEndian.PutUint16(frame[22:24], 0x0010) // flags + + binary.LittleEndian.PutUint32(frame[24:28], uint32(totalPayload)) // payload size + binary.LittleEndian.PutUint32(frame[28:32], prevFrameNo) // prev_frame_no again (32-bit) + binary.LittleEndian.PutUint32(frame[32:36], c.audioTxFrameNo) // frame_no + + // Audio payload + copy(frame[headerSize:], payload) + + // FrameInfo (16 bytes) at end of payload + samplesPerFrame := GetSamplesPerFrame(codec) + frameDurationMs := samplesPerFrame * 1000 / sampleRate + + fi := frame[headerSize+len(payload):] + binary.LittleEndian.PutUint16(fi[0:2], codec) // codec_id + fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) // flags + fi[3] = 0 // cam_index + fi[4] = 1 // onlineNum = 1 + fi[5] = 0 // tags + // fi[6:12] = reserved (already 0) + binary.LittleEndian.PutUint32(fi[12:16], (c.audioTxFrameNo-1)*frameDurationMs) + + if c.verbose { + fmt.Printf("[AUDIO TX] FrameInfo: codec=0x%04x flags=0x%02x online=%d ts=%d\n", + codec, fi[2], fi[4], binary.LittleEndian.Uint32(fi[12:16])) + } + + return frame +} + +func (c *Conn) buildDisco(stage byte) []byte { + const bodySize = 72 + const frameSize = 16 + bodySize + + frame := make([]byte, frameSize) + frame[0] = 0x04 + frame[1] = 0x02 + frame[2] = 0x1a + frame[3] = 0x02 + + binary.LittleEndian.PutUint16(frame[4:6], bodySize) + binary.LittleEndian.PutUint16(frame[8:10], CmdDiscoReq) + binary.LittleEndian.PutUint16(frame[10:12], 0x0021) + + body := frame[16:] + copy(body[0:], c.uid) + + body[36] = 0x01 + body[37] = 0x01 + body[38] = 0x02 + body[39] = 0x04 + + copy(body[40:48], c.randomID) + body[48] = stage + + if stage == 1 && len(c.authKey) > 0 { + copy(body[58:], c.authKey) + } + + return frame +} + +func (c *Conn) buildSession() []byte { + const bodySize = 36 + const frameSize = 16 + bodySize + + frame := make([]byte, frameSize) + frame[0] = 0x04 + frame[1] = 0x02 + frame[2] = 0x1a + frame[3] = 0x02 + + binary.LittleEndian.PutUint16(frame[4:6], bodySize) + binary.LittleEndian.PutUint16(frame[8:10], CmdSessionReq) + binary.LittleEndian.PutUint16(frame[10:12], 0x0033) + + body := frame[16:] + copy(body[0:], c.uid) + copy(body[20:28], c.randomID) + + ts := uint32(time.Now().Unix()) + binary.LittleEndian.PutUint32(body[32:36], ts) + + return frame +} + +func (c *Conn) buildDTLSConfig(isServer bool) *dtls.Config { + var keyLogWriter io.Writer + + if c.verbose { + keyLogPath := os.Getenv("SSLKEYLOGFILE") + if keyLogPath != "" { + f, err := os.OpenFile(keyLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err == nil { + keyLogWriter = f + if c.verbose { + fmt.Printf("[DTLS] Key Log: %s\n", keyLogPath) + } + } + } + } + + config := &dtls.Config{ + PSK: func(hint []byte) ([]byte, error) { + if c.verbose { + fmt.Printf("[DTLS] PSK callback, hint: %s\n", string(hint)) + } + return c.psk, nil + }, + PSKIdentityHint: []byte(PSKIdentity), + InsecureSkipVerify: true, + InsecureSkipVerifyHello: true, + MTU: 1200, + FlightInterval: 300 * time.Millisecond, + ExtendedMasterSecret: dtls.DisableExtendedMasterSecret, + KeyLogWriter: keyLogWriter, + } + + // Use custom cipher suites for client, standard for server + if isServer { + config.CipherSuites = []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_CBC_SHA256} + } else { + config.CustomCipherSuites = CustomCipherSuites + } + + return config +} + +func (c *Conn) buildDataTXChannel(payload []byte, channel byte) []byte { + const subHeaderSize = 12 + bodySize := subHeaderSize + len(payload) + frameSize := 16 + bodySize + frame := make([]byte, frameSize) + + frame[0] = 0x04 + frame[1] = 0x02 + frame[2] = 0x1a + frame[3] = 0x0b + + binary.LittleEndian.PutUint16(frame[4:6], uint16(bodySize)) + binary.LittleEndian.PutUint16(frame[6:8], c.iotcTxSeq) + c.iotcTxSeq++ + binary.LittleEndian.PutUint16(frame[8:10], CmdDataTX) + binary.LittleEndian.PutUint16(frame[10:12], 0x0021) + + copy(frame[12:14], c.randomID[:2]) + frame[14] = channel // Channel byte: 0 = Main, 1 = Backchannel + frame[15] = 0x01 + + binary.LittleEndian.PutUint32(frame[16:20], 0x0000000c) + copy(frame[20:28], c.randomID[:8]) + + copy(frame[28:], payload) + + return frame +} + +func (c *Conn) buildACK() []byte { + // c.ackFlags++ + + if c.ackFlags == 0 { + c.ackFlags = 0x0001 + } else if c.ackFlags < 0x0007 { + c.ackFlags++ + } + + ack := make([]byte, 24) + binary.LittleEndian.PutUint16(ack[0:2], MagicACK) // Magic + binary.LittleEndian.PutUint16(ack[2:4], ProtocolVersion) // Version + binary.LittleEndian.PutUint32(ack[4:8], c.avTxSeq) // TxSeq + c.avTxSeq++ + binary.LittleEndian.PutUint32(ack[8:12], 0xffffffff) // RxSeq + binary.LittleEndian.PutUint16(ack[12:14], c.ackFlags) // Flags + binary.LittleEndian.PutUint32(ack[16:20], uint32(c.ackFlags)<<16) // SDK uses ackFlags<<16, not avTxSeq + + return ack +} + +func (c *Conn) buildKeepaliveResponse(incomingPayload []byte) []byte { + frame := make([]byte, 24) + + frame[0] = 0x04 + frame[1] = 0x02 + frame[2] = 0x1a + frame[3] = 0x0a + + binary.LittleEndian.PutUint16(frame[4:6], 8) + binary.LittleEndian.PutUint16(frame[8:10], CmdKeepaliveReq) + binary.LittleEndian.PutUint16(frame[10:12], 0x0021) + + if len(incomingPayload) >= 8 { + copy(frame[16:24], incomingPayload[:8]) + } + + return frame +} + +func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID []byte) []byte { + pkt := make([]byte, size) + + // Header + binary.LittleEndian.PutUint16(pkt[0:2], magic) + binary.LittleEndian.PutUint16(pkt[2:4], ProtocolVersion) + // bytes 4-15: reserved (zeros) + + // Payload info at offset 16 + payloadSize := uint16(size - 24) // total - header(16) - random(4) - padding(4) + binary.LittleEndian.PutUint16(pkt[16:18], payloadSize) + binary.LittleEndian.PutUint16(pkt[18:20], flags) + copy(pkt[20:24], randomID[:4]) + + // Credentials (each field is 256 bytes) + copy(pkt[24:], DefaultUser) // username at offset 24 (payload byte 0) + copy(pkt[280:], c.enr) // password (ENR) at offset 280 (payload byte 256) + + // Config section (AVClientStartInConfig) starts at offset 536 (= 24 + 256 + 256) + // Layout: resend(4) + security_mode(4) + auth_type(4) + sync_recv_data(4) + ... + binary.LittleEndian.PutUint32(pkt[536:540], 0) // resend=0 + binary.LittleEndian.PutUint32(pkt[540:544], 2) // security_mode=2 (AV_SECURITY_AUTO) + binary.LittleEndian.PutUint32(pkt[544:548], 0) // auth_type=0 (AV_AUTH_PASSWORD) + binary.LittleEndian.PutUint32(pkt[548:552], 0) // sync_recv_data=0 + binary.LittleEndian.PutUint32(pkt[552:556], DefaultCapabilities) // capabilities + binary.LittleEndian.PutUint16(pkt[556:558], 0) // request_video_on_connect=0 + binary.LittleEndian.PutUint16(pkt[558:560], 0) // request_audio_on_connect=0 + + return pkt +} + +func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { + // SDK sends 60-byte AV Login response + // Captured from SDK: 00 21 0c 00 10 00 00 00 00 00 00 00 00 00 00 00 + // 24 00 00 00 cd ac ca 40 00 00 00 00 00 01 00 01 + // 00 00 00 00 04 00 00 00 fb 07 1f 00 00 00 00 00 + // 00 00 00 00 00 00 03 00 02 00 00 00 + // + // Structure: + // [0-1] Magic: 0x2100 (Login Response) + // [2-3] Protocol Version: 0x000c + // [4] Response Type: 0x10 (success) + // [5-15] Reserved: zeros + // [16-19] Payload Size: 0x24 = 36 + // [20-23] Checksum: MUST echo from request! + // [24-27] Reserved: zeros + // [28] Flag1: 0x00 + // [29] EnableFlag: 0x01 + // [30] Flag2: 0x00 + // [31] TwoWayStreaming: 0x01 + // [32-35] Reserved: zeros + // [36-39] BufferConfig: 0x04 + // [40-43] Capabilities: 0x001f07fb + // [44-51] Reserved: zeros + // [52-53] Reserved: zeros + // [54-55] ChannelInfo1: 0x0003 + // [56-57] ChannelInfo2: 0x0002 + // [58-59] Reserved: zeros + + resp := make([]byte, 60) + + // Header + binary.LittleEndian.PutUint16(resp[0:2], 0x2100) // Magic + binary.LittleEndian.PutUint16(resp[2:4], 0x000c) // Version + resp[4] = 0x10 // Response type (success) + + // Payload info + binary.LittleEndian.PutUint32(resp[16:20], 0x24) // Payload size = 36 + binary.LittleEndian.PutUint32(resp[20:24], checksum) // Echo checksum from request! + + // Payload (36 bytes starting at offset 24) + resp[29] = 0x01 // EnableFlag + resp[31] = 0x01 // TwoWayStreaming + + binary.LittleEndian.PutUint32(resp[36:40], 0x04) // BufferConfig + binary.LittleEndian.PutUint32(resp[40:44], 0x001f07fb) // Capabilities + + binary.LittleEndian.PutUint16(resp[54:56], 0x0003) // ChannelInfo1 + binary.LittleEndian.PutUint16(resp[56:58], 0x0002) // ChannelInfo2 + + return resp +} + +func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { + const headerSize = 40 + frame := make([]byte, headerSize+len(payload)) + + // Magic (same as protocol version for IOCtrl frames) + binary.LittleEndian.PutUint16(frame[0:2], ProtocolVersion) + + // Version + binary.LittleEndian.PutUint16(frame[2:4], ProtocolVersion) + + // AVSeq (4-7) + seq := c.avTxSeq + c.avTxSeq++ + binary.LittleEndian.PutUint32(frame[4:8], seq) + + // Bytes 8-15: reserved + + // Channel: MagicIOCtrl (0x7000) for IOCtrl frames + binary.LittleEndian.PutUint16(frame[16:18], MagicIOCtrl) + + // SubChannel (18-19): increments with each IOCtrl command sent + binary.LittleEndian.PutUint16(frame[18:20], c.ioctrlSeq) + + // IOCTLSeq (20-23): always 1 + binary.LittleEndian.PutUint32(frame[20:24], 1) + + // PayloadSize (24-27): payload + 4 bytes padding + binary.LittleEndian.PutUint32(frame[24:28], uint32(len(payload)+4)) + + // Flag (28-31): matches subChannel in SDK + binary.LittleEndian.PutUint32(frame[28:32], uint32(c.ioctrlSeq)) + + // Bytes 32-36: reserved + // Byte 37: 0x01 + frame[37] = 0x01 + + // Bytes 38-39: reserved + + // Payload at offset 40 + copy(frame[headerSize:], payload) + + c.ioctrlSeq++ + + return frame +} + +func (c *Conn) logAVPacket(channel, frameType byte, payload []byte, fi *FrameInfo) { + fmt.Printf("[Conn] AV: ch=0x%02x type=0x%02x len=%d", channel, frameType, len(payload)) + if fi != nil { + fmt.Printf(" fi={codec=0x%04x flags=0x%02x ts=%d}", fi.CodecID, fi.Flags, fi.Timestamp) + } + fmt.Printf("\n") +} + +func (c *Conn) logAudioTX(frame []byte, codec uint16, payloadLen int, timestampUS uint32, sampleRate uint32, channels uint8) { + chStr := "mono" + if channels == 2 { + chStr = "stereo" + } + + // Determine header size based on frame type + headerSize := 28 + frameType := "P-Start" + if len(frame) >= 2 && frame[1] == FrameTypeStartAlt { + headerSize = 36 + frameType = "Single" + } + + fmt.Printf("[AUDIO TX] %s codec=0x%04x (%s) payload=%d ts=%d rate=%d %s total=%d\n", + frameType, codec, AudioCodecName(codec), payloadLen, timestampUS, sampleRate, chStr, len(frame)) + + // Dump frame header for comparison with SDK + if len(frame) >= headerSize { + fmt.Printf(" HEADER[0..%d]: ", headerSize-1) + for i := 0; i < headerSize; i++ { + fmt.Printf("%02x ", frame[i]) + } + fmt.Printf("\n") + } + + // First few payload bytes (for comparison with SDK) + if payloadLen > 0 && len(frame) > headerSize { + maxShow := min(16, payloadLen) + fmt.Printf(" PAYLOAD[%d..%d]: ", headerSize, headerSize+maxShow-1) + for i := 0; i < maxShow; i++ { + fmt.Printf("%02x ", frame[headerSize+i]) + } + if payloadLen > maxShow { + fmt.Printf("...") + } + fmt.Printf("\n") + } +} + +func genRandomID() []byte { + b := make([]byte, 8) + _, _ = rand.Read(b) + return b +} diff --git a/pkg/wyze/tutk/constants.go b/pkg/wyze/tutk/constants.go new file mode 100644 index 00000000..74bc93b6 --- /dev/null +++ b/pkg/wyze/tutk/constants.go @@ -0,0 +1,282 @@ +package tutk + +const ( + CodecUnknown uint16 = 0x00 // Unknown codec + CodecMPEG4 uint16 = 0x4C // 76 - MPEG4 + CodecH263 uint16 = 0x4D // 77 - H.263 + CodecH264 uint16 = 0x4E // 78 - H.264/AVC (common for Wyze) + CodecMJPEG uint16 = 0x4F // 79 - MJPEG + CodecH265 uint16 = 0x50 // 80 - H.265/HEVC (common for Wyze) +) + +const ( + AudioCodecAACRaw uint16 = 0x86 // 134 - AAC raw format + AudioCodecAACADTS uint16 = 0x87 // 135 - AAC with ADTS header + AudioCodecAACLATM uint16 = 0x88 // 136 - AAC with LATM format + AudioCodecG711U uint16 = 0x89 // 137 - G.711 μ-law (PCMU) + AudioCodecG711A uint16 = 0x8A // 138 - G.711 A-law (PCMA) + AudioCodecADPCM uint16 = 0x8B // 139 - ADPCM + AudioCodecPCM uint16 = 0x8C // 140 - PCM 16-bit signed LE + AudioCodecSPEEX uint16 = 0x8D // 141 - Speex + AudioCodecMP3 uint16 = 0x8E // 142 - MP3 + AudioCodecG726 uint16 = 0x8F // 143 - G.726 + // Wyze extensions (not in official SDK) + AudioCodecAACWyze uint16 = 0x90 // 144 - Wyze AAC + AudioCodecOpus uint16 = 0x92 // 146 - Opus codec +) + +const ( + SampleRate8K uint8 = 0x00 // 8000 Hz + SampleRate11K uint8 = 0x01 // 11025 Hz + SampleRate12K uint8 = 0x02 // 12000 Hz + SampleRate16K uint8 = 0x03 // 16000 Hz + SampleRate22K uint8 = 0x04 // 22050 Hz + SampleRate24K uint8 = 0x05 // 24000 Hz + SampleRate32K uint8 = 0x06 // 32000 Hz + SampleRate44K uint8 = 0x07 // 44100 Hz + SampleRate48K uint8 = 0x08 // 48000 Hz +) + +var SampleRates = map[uint8]int{ + SampleRate8K: 8000, + SampleRate11K: 11025, + SampleRate12K: 12000, + SampleRate16K: 16000, + SampleRate22K: 22050, + SampleRate24K: 24000, + SampleRate32K: 32000, + SampleRate44K: 44100, + SampleRate48K: 48000, +} + +var SamplesPerFrame = map[uint16]uint32{ + AudioCodecAACRaw: 1024, // AAC frame = 1024 samples + AudioCodecAACADTS: 1024, + AudioCodecAACLATM: 1024, + AudioCodecAACWyze: 1024, + AudioCodecG711U: 160, // G.711 typically 20ms = 160 samples at 8kHz + AudioCodecG711A: 160, + AudioCodecPCM: 160, + AudioCodecADPCM: 160, + AudioCodecSPEEX: 160, + AudioCodecMP3: 1152, // MP3 frame = 1152 samples + AudioCodecG726: 160, + AudioCodecOpus: 960, // Opus typically 20ms = 960 samples at 48kHz +} + +const ( + IOTypeVideoStart = 0x01FF + IOTypeVideoStop = 0x02FF + IOTypeAudioStart = 0x0300 + IOTypeAudioStop = 0x0301 + IOTypeSpeakerStart = 0x0350 + IOTypeSpeakerStop = 0x0351 + IOTypeGetAudioOutFormatReq = 0x032A + IOTypeGetAudioOutFormatRes = 0x032B + IOTypeSetStreamCtrlReq = 0x0320 + IOTypeSetStreamCtrlRes = 0x0321 + IOTypeGetStreamCtrlReq = 0x0322 + IOTypeGetStreamCtrlRes = 0x0323 + IOTypeDevInfoReq = 0x0340 + IOTypeDevInfoRes = 0x0341 + IOTypeGetSupportStreamReq = 0x0344 + IOTypeGetSupportStreamRes = 0x0345 + IOTypeSetRecordReq = 0x0310 + IOTypeSetRecordRes = 0x0311 + IOTypeGetRecordReq = 0x0312 + IOTypeGetRecordRes = 0x0313 + IOTypePTZCommand = 0x1001 + IOTypeReceiveFirstFrame = 0x1002 + IOTypeGetEnvironmentReq = 0x030A + IOTypeGetEnvironmentRes = 0x030B + IOTypeSetVideoModeReq = 0x030C + IOTypeSetVideoModeRes = 0x030D + IOTypeGetVideoModeReq = 0x030E + IOTypeGetVideoModeRes = 0x030F + IOTypeSetTimeReq = 0x0316 + IOTypeSetTimeRes = 0x0317 + IOTypeGetTimeReq = 0x0318 + IOTypeGetTimeRes = 0x0319 + IOTypeSetWifiReq = 0x0102 + IOTypeSetWifiRes = 0x0103 + IOTypeGetWifiReq = 0x0104 + IOTypeGetWifiRes = 0x0105 + IOTypeListWifiAPReq = 0x0106 + IOTypeListWifiAPRes = 0x0107 + IOTypeSetMotionDetectReq = 0x0306 + IOTypeSetMotionDetectRes = 0x0307 + IOTypeGetMotionDetectReq = 0x0308 + IOTypeGetMotionDetectRes = 0x0309 +) + +const ( + CmdDiscoReq uint16 = 0x0601 + CmdDiscoRes uint16 = 0x0602 + CmdSessionReq uint16 = 0x0402 + CmdSessionRes uint16 = 0x0404 + CmdDataTX uint16 = 0x0407 + CmdDataRX uint16 = 0x0408 + CmdKeepaliveReq uint16 = 0x0427 + CmdKeepaliveRes uint16 = 0x0428 +) + +const ( + MagicAVLoginResp uint16 = 0x2100 + MagicIOCtrl uint16 = 0x7000 + MagicChannelMsg uint16 = 0x1000 + MagicACK uint16 = 0x0009 + MagicAVLogin1 uint16 = 0x0000 + MagicAVLogin2 uint16 = 0x2000 +) + +const ( + ProtocolVersion uint16 = 0x000c // Version 12 +) + +const ( + DefaultCapabilities uint32 = 0x001f07fb +) + +const ( + KCmdAuth = 10000 + KCmdChallenge = 10001 + KCmdChallengeResp = 10002 + KCmdAuthResult = 10003 + KCmdControlChannel = 10010 + KCmdControlChannelResp = 10011 + KCmdSetResolution = 10056 + KCmdSetResolutionResp = 10057 +) + +const ( + MediaTypeVideo = 1 + MediaTypeAudio = 2 + MediaTypeReturnAudio = 3 + MediaTypeRDT = 4 +) + +const ( + IOTCChannelMain = 0 // Main AV channel (we = DTLS Client, camera = Server) + IOTCChannelBack = 1 // Backchannel for Return Audio (we = DTLS Server, camera = Client) +) + +const ( + BitrateMax uint16 = 0xF0 // 240 KB/s + BitrateSD uint16 = 0x3C // 60 KB/s +) + +const ( + FrameSize1080P = 0 + FrameSize360P = 1 + FrameSize720P = 2 + FrameSize2K = 3 +) + +const ( + QualityUnknown = 0 + QualityMax = 1 + QualityHigh = 2 + QualityMiddle = 3 + QualityLow = 4 + QualityMin = 5 +) + +func CodecName(id uint16) string { + switch id { + case CodecH264: + return "H264" + case CodecH265: + return "H265" + case CodecMPEG4: + return "MPEG4" + case CodecH263: + return "H263" + case CodecMJPEG: + return "MJPEG" + default: + return "Unknown" + } +} + +func AudioCodecName(id uint16) string { + switch id { + case AudioCodecG711U: + return "PCMU" + case AudioCodecG711A: + return "PCMA" + case AudioCodecPCM: + return "PCM" + case AudioCodecAACLATM, AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACWyze: + return "AAC" + case AudioCodecOpus: + return "Opus" + case AudioCodecSPEEX: + return "Speex" + case AudioCodecMP3: + return "MP3" + case AudioCodecG726: + return "G726" + case AudioCodecADPCM: + return "ADPCM" + default: + return "Unknown" + } +} + +func SampleRateValue(enum uint8) int { + if rate, ok := SampleRates[enum]; ok { + return rate + } + return 16000 // Default +} + +func SampleRateIndex(hz uint32) uint8 { + switch hz { + case 8000: + return SampleRate8K + case 11025: + return SampleRate11K + case 12000: + return SampleRate12K + case 16000: + return SampleRate16K + case 22050: + return SampleRate22K + case 24000: + return SampleRate24K + case 32000: + return SampleRate32K + case 44100: + return SampleRate44K + case 48000: + return SampleRate48K + default: + return SampleRate16K // Default + } +} + +func BuildAudioFlags(sampleRate uint32, bits16 bool, stereo bool) uint8 { + flags := SampleRateIndex(sampleRate) << 2 + if bits16 { + flags |= 0x02 + } + if stereo { + flags |= 0x01 + } + return flags +} + +func IsVideoCodec(id uint16) bool { + return id >= CodecMPEG4 && id <= CodecH265 +} + +func IsAudioCodec(id uint16) bool { + return id >= AudioCodecAACRaw && id <= AudioCodecOpus +} + +func GetSamplesPerFrame(codecID uint16) uint32 { + if samples, ok := SamplesPerFrame[codecID]; ok { + return samples + } + return 1024 // Default to AAC +} diff --git a/pkg/wyze/tutk/types.go b/pkg/wyze/tutk/types.go new file mode 100644 index 00000000..3596a47e --- /dev/null +++ b/pkg/wyze/tutk/types.go @@ -0,0 +1,155 @@ +package tutk + +const ( + // Start packets - first fragment of a frame + // 0x08: Extended start (36-byte header, no FrameInfo) + // 0x09: StartAlt (36-byte header, FrameInfo only if pkt_total==1) + FrameTypeStart uint8 = 0x08 + FrameTypeStartAlt uint8 = 0x09 + + // Continuation packets - middle fragment (28-byte header, no FrameInfo) + FrameTypeCont uint8 = 0x00 + FrameTypeContAlt uint8 = 0x04 + + // End packets - last fragment (with 40-byte FrameInfo) + // 0x01: Single-packet frame (28-byte header) + // 0x05: Multi-packet end (28-byte header) + // 0x0d: Extended end (36-byte header) + FrameTypeEndSingle uint8 = 0x01 + FrameTypeEndMulti uint8 = 0x05 + FrameTypeEndExt uint8 = 0x0d +) + +const ( + ChannelIVideo uint8 = 0x05 + ChannelAudio uint8 = 0x03 + ChannelPVideo uint8 = 0x07 +) + +type Packet struct { + Channel uint8 + Codec uint16 + Timestamp uint32 + Payload []byte + IsKeyframe bool + FrameNo uint32 + SampleRate uint32 + Channels uint8 +} + +func (p *Packet) IsVideo() bool { + return p.Channel == ChannelIVideo || p.Channel == ChannelPVideo +} + +func (p *Packet) IsAudio() bool { + return p.Channel == ChannelAudio +} + +type AuthResponse struct { + ConnectionRes string `json:"connectionRes"` + CameraInfo map[string]any `json:"cameraInfo"` +} + +type AVLoginResponse struct { + ServerType uint32 + Resend int32 + TwoWayStreaming int32 + SyncRecvData int32 + SecurityMode uint32 + VideoOnConnect int32 + AudioOnConnect int32 +} + +func IsStartFrame(frameType uint8) bool { + return frameType == FrameTypeStart || frameType == FrameTypeStartAlt +} + +func IsEndFrame(frameType uint8) bool { + return frameType == FrameTypeEndSingle || + frameType == FrameTypeEndMulti || + frameType == FrameTypeEndExt +} + +func IsContinuationFrame(frameType uint8) bool { + return frameType == FrameTypeCont || frameType == FrameTypeContAlt +} + +type PacketHeader struct { + Channel byte + FrameType byte + HeaderSize int // 28 or 36 + FrameNo uint32 // Frame number (from [24-27] for 28-byte, [32-35] for 36-byte) + PktIdx uint16 // Packet index within frame (0-based) + PktTotal uint16 // Total packets in this frame + PayloadSize uint16 + HasFrameInfo bool // true if [14-15] or [22-23] == 0x0028 +} + +func ParsePacketHeader(data []byte) *PacketHeader { + if len(data) < 28 { + return nil + } + + frameType := data[1] + hdr := &PacketHeader{ + Channel: data[0], + FrameType: frameType, + } + + // Header size based on FrameType (NOT magic bytes!) + switch frameType { + case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt: // 0x08, 0x09, 0x0d + hdr.HeaderSize = 36 + default: // 0x00, 0x01, 0x04, 0x05 + hdr.HeaderSize = 28 + } + + if len(data) < hdr.HeaderSize { + return nil + } + + if hdr.HeaderSize == 28 { + // 28-Byte Header Layout: + // [12-13] pkt_total + // [14-15] pkt_idx OR 0x0028 (FrameInfo marker) - ONLY 0x0028 in End packets! + // [16-17] payload_size + // [24-27] frame_no (uint32) + hdr.PktTotal = uint16(data[12]) | uint16(data[13])<<8 + pktIdxOrMarker := uint16(data[14]) | uint16(data[15])<<8 + hdr.PayloadSize = uint16(data[16]) | uint16(data[17])<<8 + hdr.FrameNo = uint32(data[24]) | uint32(data[25])<<8 | uint32(data[26])<<16 | uint32(data[27])<<24 + + // 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40 + if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 // Last packet + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } else { + // 36-Byte Header Layout: + // [20-21] pkt_total + // [22-23] pkt_idx OR 0x0028 (FrameInfo marker) - ONLY 0x0028 in End packets! + // [24-25] payload_size + // [32-35] frame_no (uint32) - GLOBAL frame counter, matches 28-byte [24-27] + // NOTE: [18-19] is channel-specific frame index, NOT used for reassembly! + hdr.PktTotal = uint16(data[20]) | uint16(data[21])<<8 + pktIdxOrMarker := uint16(data[22]) | uint16(data[23])<<8 + hdr.PayloadSize = uint16(data[24]) | uint16(data[25])<<8 + hdr.FrameNo = uint32(data[32]) | uint32(data[33])<<8 | uint32(data[34])<<16 | uint32(data[35])<<24 + + // 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40 + if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } + + return hdr +} diff --git a/www/add.html b/www/add.html index 38c4e155..a2e0d85f 100644 --- a/www/add.html +++ b/www/add.html @@ -413,6 +413,64 @@ + +
    +

    + API Key required: Get your API Key +

    +
    + + + + + +
    +
    + + +
    +
    +
    + + +
    diff --git a/www/video-rtc.js b/www/video-rtc.js index cab5bf04..b235b974 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -249,7 +249,20 @@ export class VideoRTC extends HTMLElement { this.appendChild(this.video); this.video.addEventListener('error', ev => { - console.warn(ev); + const err = this.video.error; + console.error('[VideoRTC] Video error:', { + code: err ? err.code : 'unknown', + message: err ? err.message : 'unknown', + MEDIA_ERR_ABORTED: 1, + MEDIA_ERR_NETWORK: 2, + MEDIA_ERR_DECODE: 3, + MEDIA_ERR_SRC_NOT_SUPPORTED: 4, + codecs: this.mseCodecs || 'not set', + readyState: this.video.readyState, + networkState: this.video.networkState, + currentTime: this.video.currentTime, + event: ev + }); if (this.ws) this.ws.close(); // run reconnect for broken MSE stream }); From 44da81774cc37e8bffd0d55f2e32d18cab37742c Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 1 Jan 2026 08:32:39 +0300 Subject: [PATCH 165/241] Add recv/send counters to xiaomi source --- pkg/xiaomi/backchannel.go | 4 ++++ pkg/xiaomi/producer.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/pkg/xiaomi/backchannel.go b/pkg/xiaomi/backchannel.go index 0224a594..a6f57b81 100644 --- a/pkg/xiaomi/backchannel.go +++ b/pkg/xiaomi/backchannel.go @@ -31,6 +31,7 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv buf = append(buf, transcode(pkt.Payload)...) const size = 2 * 8000 * 0.040 // 16bit 40ms for len(buf) >= size { + p.Send += size _ = p.client.WriteAudio(miss.CodecPCM, buf[:size]) buf = buf[size:] } @@ -40,6 +41,7 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv buf = append(buf, pkt.Payload...) const size = 8000 * 0.040 // 8bit 40 ms for len(buf) >= size { + p.Send += size _ = p.client.WriteAudio(miss.CodecPCMA, buf[:size]) buf = buf[size:] } @@ -54,12 +56,14 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv } else { // convert two 20ms to one 40ms buf = opus.JoinFrames(buf, pkt.Payload) + p.Send += len(buf) _ = p.client.WriteAudio(miss.CodecOPUS, buf) buf = nil } } } else { sender.Handler = func(pkt *rtp.Packet) { + p.Send += len(pkt.Payload) _ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload) } } diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go index 09ba7360..9abf66b0 100644 --- a/pkg/xiaomi/producer.go +++ b/pkg/xiaomi/producer.go @@ -164,6 +164,8 @@ func (p *Producer) Start() error { return err } + p.Recv += len(pkt.Payload) + // TODO: rewrite this var name string var pkt2 *core.Packet From e77210f916f61d5ae0d947c3111e039546d6b494 Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 1 Jan 2026 07:49:21 +0300 Subject: [PATCH 166/241] Improve support tutk vendor for xiaomi source --- pkg/xiaomi/miss/client.go | 2 +- pkg/xiaomi/producer.go | 4 +- pkg/xiaomi/tutk/README.md | 63 +++++++ pkg/xiaomi/tutk/conn.go | 326 +++++++---------------------------- pkg/xiaomi/tutk/crypto.go | 23 +-- pkg/xiaomi/tutk/proto.go | 196 +++++++++++++++++++++ pkg/xiaomi/tutk/proto_new.go | 191 ++++++++++++++++++++ pkg/xiaomi/tutk/proto_old.go | 153 ++++++++++++++++ 8 files changed, 682 insertions(+), 276 deletions(-) create mode 100644 pkg/xiaomi/tutk/README.md create mode 100644 pkg/xiaomi/tutk/proto.go create mode 100644 pkg/xiaomi/tutk/proto_new.go create mode 100644 pkg/xiaomi/tutk/proto_old.go diff --git a/pkg/xiaomi/miss/client.go b/pkg/xiaomi/miss/client.go index 470c0e0e..560f9f49 100644 --- a/pkg/xiaomi/miss/client.go +++ b/pkg/xiaomi/miss/client.go @@ -35,7 +35,7 @@ func Dial(rawURL string) (*Client, error) { case "cs2": c.conn, err = cs2.Dial(u.Host, query.Get("transport")) case "tutk": - c.conn, err = tutk.Dial(u.Host, query.Get("uid")) + c.conn, err = tutk.Dial(u.Host, query.Get("uid"), query.Get("model")) default: return nil, fmt.Errorf("miss: unsupported vendor %s", s) } diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go index 9abf66b0..f2ce4eda 100644 --- a/pkg/xiaomi/producer.go +++ b/pkg/xiaomi/producer.go @@ -75,7 +75,7 @@ func Dial(rawURL string) (core.Producer, error) { } func probe(client *miss.Client, channel, quality, audio uint8) ([]*core.Media, error) { - _ = client.SetDeadline(time.Now().Add(core.ProbeTimeout)) + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) if err := client.VideoStart(channel, quality, audio&1); err != nil { return nil, err @@ -158,7 +158,7 @@ func (p *Producer) Start() error { var audioTS uint32 for { - _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) + _ = p.client.SetDeadline(time.Now().Add(10 * time.Second)) pkt, err := p.client.ReadPacket() if err != nil { return err diff --git a/pkg/xiaomi/tutk/README.md b/pkg/xiaomi/tutk/README.md new file mode 100644 index 00000000..95773594 --- /dev/null +++ b/pkg/xiaomi/tutk/README.md @@ -0,0 +1,63 @@ +# TUTK + +The most terrible protocol I have ever had to work with. + +## Messages + +Ping from camera (24b). The shortest message. + +``` +off sample +0 0402 tutk magic +2 190a tutk version (120a, 190a...) +4 0800 msg size = len(b)-16 = 24-16 +6 0000 channel seq (always 0 for ping) +8 2804 msg type (2804 - ping from camera, 0804 - usual msg from camera) +10 1200 direction (12 - from camera, 21 - from client) +12 00000000 fixed +16 7ecc93c4 random +20 56c2561f random +``` + +Usual msg from camera (52b + msg data). + +``` +off sample +12 e6e8 same bytes b[20:22] +14 0000 channel (0, 1, 5) +16 0c00 fixed +18 0000 fixed +20 e6e839da random session id +24 66b0dc14 random session id +28 0070 command +30 0b00 version +32 0100 command seq +34 0000 ??? +36 00000000 ??? +40 00000000 ??? +44 e300 msg data size +46 0000 ??? +48 8f15a02f random msg id +52 ... msg data +``` + +Message with media from camera. + +``` +off sample +28 0c00 command +30 0b00 version +32 7700 command seq +34 0000 ??? data only for last message per pack (14/14) +36 0200 pack seq, don't know how packs used +38 0914 09/14 - message seq/messages per packs +40 01000000 fixed +42 0500 command 2 +44 3200 command 2 seq +46 4f00 chunks count per this frame +48 1b00 chunk seq, starts from 0 (wrong for last chunk) +50 0004 frame data size +52 c8f6 random msg id +54 01000000 previous frame seq, starts from 0 +58 02000000 current frame seq, starts from 1 +``` diff --git a/pkg/xiaomi/tutk/conn.go b/pkg/xiaomi/tutk/conn.go index f3005224..37489ffa 100644 --- a/pkg/xiaomi/tutk/conn.go +++ b/pkg/xiaomi/tutk/conn.go @@ -12,23 +12,33 @@ import ( "time" ) -func Dial(host, uid string) (*Conn, error) { +func Dial(host, uid, model string) (*Conn, error) { conn, err := net.ListenUDP("udp", nil) if err != nil { return nil, err } - c := &Conn{ - conn: conn, - addr: &net.UDPAddr{IP: net.ParseIP(host), Port: 32761}, - sid: genSID(), + addr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + addr = &net.UDPAddr{IP: net.ParseIP(host), Port: 32761} } + c := &Conn{conn: conn, addr: addr, sid: genSID()} + if err = c.handshake([]byte(uid)); err != nil { _ = c.Close() return nil, err } + switch model { + case "isa.camera.df3": + c.msgCtrl = c.oldMsgCtrl + c.handleCh0 = c.oldHandlerCh0() + default: + c.msgCtrl = c.newMsgCtrl + c.handleCh0 = c.newHandlerCh0() + } + c.rawCmd = make(chan []byte, 10) c.rawPkt = make(chan []byte, 100) @@ -43,20 +53,32 @@ type Conn struct { sid []byte err error - seqCh0 uint16 - seqCmd uint16 rawCmd chan []byte rawPkt chan []byte cmdMu sync.Mutex cmdAck func() + + seqSendCh0 uint16 + seqSendCh1 uint16 + + seqSendCmd1 uint16 + seqSendCmd2 uint16 + seqSendCnt uint16 + + seqRecvPkt0 uint16 + seqRecvPkt1 uint16 + seqRecvCmd2 uint16 + + msgCtrl func(ctrlType uint16, ctrlData []byte) []byte + handleCh0 func(cmd []byte) int8 } func (c *Conn) handshake(uid []byte) (err error) { _ = c.SetDeadline(time.Now().Add(5 * time.Second)) if _, err = c.WriteAndWait( - c.msgLanSearch(uid, 1), // 01062100 + c.msgConnectByUID(uid, 1), func(_, res []byte) bool { return bytes.Index(res, uid) == 16 // 02061200 }, @@ -64,15 +86,14 @@ func (c *Conn) handshake(uid []byte) (err error) { return err } - if err = c.Write(c.msgLanSearch(uid, 2)); err != nil { + if err = c.Write(c.msgConnectByUID(uid, 2)); err != nil { return err } if _, err = c.WriteAndWait( - c.msgAvClientStartReq(), // 07042100 + 00000b00 + c.msgAvClientStart(), func(req, res []byte) bool { - mid := req[48:52] - return bytes.Index(res, mid) == 48 // 08041200 + 00140800 + return bytes.Index(res, req[48:52]) == 48 }, ); err != nil { return err @@ -90,135 +111,45 @@ func (c *Conn) worker() { }() buf := make([]byte, 1200) - var waitSeq uint16 - var waitSize uint32 - var waitData []byte for { - n, addr, err := c.conn.ReadFromUDP(buf) + n, _, err := c.ReadFromUDP(buf) if err != nil { c.err = fmt.Errorf("%s: %w", "tutk", err) return } + if c.handleMsg(buf[:n]) <= 0 { + if c.err != nil { + return + } + fmt.Printf("tutk: unknown msg: %x\n", buf[:n]) + } + } +} + +func (c *Conn) Write(buf []byte) error { + //log.Printf("-> %x", buf) + _, err := c.conn.WriteToUDP(TransCodePartial(nil, buf), c.addr) + return err +} + +func (c *Conn) ReadFromUDP(buf []byte) (n int, addr *net.UDPAddr, err error) { + for { + if n, addr, err = c.conn.ReadFromUDP(buf); err != nil { + return 0, nil, err + } + if string(addr.IP) != string(c.addr.IP) || n < 16 { continue // skip messages from another IP } - b := ReverseTransCodePartial(buf[:n]) - //log.Printf("<- %x", b) - - if b[0] != 0x04 || b[1] != 0x02 { - continue - } - - if len(b) == 24 { - _ = c.Write(msgAckPing(b)) - continue - } - - switch b[14] { - case 0: - switch string(b[28:30]) { - case "\x00\x12": - _ = c.Write(c.msgAckCh0Req0012(b)) - continue - - case "\x00\x70": - _ = c.Write(c.msgAckCh0Req0070(b)) - select { - case c.rawCmd <- b[52:]: - default: - } - continue - - case "\x00\x71": - if c.cmdAck != nil { - c.cmdAck() - } - continue - - case "\x01\x03": - seq := binary.LittleEndian.Uint16(b[40:]) - if seq != waitSeq { - waitSeq = 0 // data loss - continue - } - if seq == 0 { - waitSize = binary.LittleEndian.Uint32(b[36:]) + 32 - } - - waitData = append(waitData, b[52:]...) - if n := uint32(len(waitData)); n < waitSize { - waitSeq++ - continue - } else if n > waitSize { - waitSeq = 0 // data loss - continue - } - - // create a buffer for the header and collected data - packetData := make([]byte, waitSize) - // there's a header at the end - let's move it to the beginning - copy(packetData, waitData[waitSize-32:]) - copy(packetData[32:], waitData) - - select { - case c.rawPkt <- packetData: - default: - c.err = fmt.Errorf("%s: media queue is full", "tutk") - return - } - - waitSeq = 0 - waitData = waitData[:0] - continue - - case "\x01\x04": - waitSize2 := binary.LittleEndian.Uint32(b[36:]) - waitData2 := b[52:] - - if uint32(len(waitData2)) != waitSize2 { - continue // shouldn't happened for audio - } - - packetData := make([]byte, waitSize2) - copy(packetData, waitData2) - - select { - case c.rawPkt <- packetData: - default: - c.err = fmt.Errorf("%s: media queue is full", "tutk") - return - } - continue - } - case 1: - switch string(b[28:30]) { - case "\x00\x00": - _ = c.Write(msgAckCh1Req0000(b)) - continue - case "\x00\x07": - _ = c.Write(msgAckCh1Req0007(b)) - continue - } - case 5: - if len(b) == 48 { - _ = c.Write(msgAckCh5(b)) - continue - } - } - - fmt.Printf("%s: unknown msg: %x\n", "tutk", buf[:n]) + ReverseTransCodePartial(buf, buf[:n]) + //log.Printf("<- %x", buf[:n]) + return n, addr, nil } } -func (c *Conn) Write(req []byte) error { - //log.Printf("-> %x", req) - _, err := c.conn.WriteToUDP(TransCodePartial(req), c.addr) - return err -} - func (c *Conn) WriteAndWait(req []byte, ok func(req, res []byte) bool) ([]byte, error) { var t *time.Timer t = time.AfterFunc(1, func() { @@ -231,20 +162,14 @@ func (c *Conn) WriteAndWait(req []byte, ok func(req, res []byte) bool) ([]byte, buf := make([]byte, 1200) for { - n, addr, err := c.conn.ReadFromUDP(buf) + n, addr, err := c.ReadFromUDP(buf) if err != nil { return nil, err } - if string(addr.IP) != string(c.addr.IP) || n < 16 { - continue // skip messages from another IP - } - - res := ReverseTransCodePartial(buf[:n]) - //log.Printf("<- %x", b) - if ok(req, res) { + if ok(req, buf[:n]) { c.addr.Port = addr.Port - return res, nil + return buf[:n], nil } } } @@ -298,10 +223,10 @@ func (c *Conn) WriteCommand(cmd uint16, data []byte) error { timeout.Reset(1) } - req := c.msgAvSendIOCtrl(cmd, data) + msg := c.msgCtrl(cmd, data) for { - if err := c.Write(req); err != nil { + if err := c.WriteCh0(msg); err != nil { return err } <-timeout.C @@ -334,128 +259,3 @@ func genSID() []byte { b[4] = 0x0c return b } - -func (c *Conn) msgLanSearch(uid []byte, i byte) []byte { - const size = 68 // or 52 or 68 or 88 - b := make([]byte, size) - copy(b, "\x04\x02\x0f\x02") - b[4] = size - 16 - copy(b[8:], "\x01\x06\x21\x00") - copy(b[16:], uid) - copy(b[52:], "\x00\x03\x01\x02") // or 07000303 or 01010204 - copy(b[56:], c.sid[8:]) - b[64] = i - return b -} - -func (c *Conn) msg(size uint16) []byte { - b := make([]byte, size) - copy(b, "\x04\x02\x19\x0a") - binary.LittleEndian.PutUint16(b[4:], size-16) - binary.LittleEndian.PutUint16(b[6:], c.seqCh0) - c.seqCh0++ // start from 0 - copy(b[8:], "\x07\x04\x21\x00") - return b -} - -func (c *Conn) msgAvClientStartReq() []byte { - const size = 586 // or 586 or 598 - b := c.msg(size) - copy(b[12:], c.sid) - copy(b[28:], "\x00\x00\x08\x00") // or 00000400 or 00000b00 - binary.LittleEndian.PutUint16(b[44:], size-52) - binary.LittleEndian.PutUint32(b[48:], uint32(time.Now().UnixMilli())) - copy(b[size-16:], "\x04\x00\x00\x00\xfb\x07\x1f\x00") - return b -} - -func (c *Conn) msgAvSendIOCtrl(cmd uint16, msg []byte) []byte { - size := 52 + 4 + uint16(len(msg)) - b := c.msg(size) - copy(b[12:], c.sid) - copy(b[28:], "\x00\x70\x08\x00") // or 00700400 or 00700b00 - c.seqCmd++ // start from 1 - binary.LittleEndian.PutUint16(b[32:], c.seqCmd) - binary.LittleEndian.PutUint16(b[44:], size-52) - //_, _ = rand.Read(b[48:52]) // mid - binary.LittleEndian.PutUint32(b[48:], uint32(time.Now().UnixMilli())) - binary.LittleEndian.PutUint16(b[52:], cmd) - copy(b[56:], msg) - return b -} - -const version = 0x19 - -func msgAckPing(req []byte) []byte { - // <- [24] 0402120a 08000000 28041200 000000005b0d4202070aa8c0 - // -> [24] 04021a0a 08000000 27042100 000000005b0d4202070aa8c0 - req[2] = version - req[8] = 0x27 - req[10] = 0x21 - return req -} - -func msgAck(req []byte, size byte) []byte { - // xxxx??xx ??00xxxx 07xx21xx ... - req[2] = version - req[4] = size - 16 - req[5] = 0x00 - req[8] = 0x07 - req[10] = 0x21 - return req[:size] -} - -func (c *Conn) msgAckCh0Req0012(req []byte) []byte { - // <- [64] 0402120a 30000000 08041200 e6e8 0000 0c000000e6e839da66b0dc14 00120800000000000000000000000000 0c00 000000000000 020000000100000001000000 - // -> [72] 0402190a 38000300 07042100 e6e8 0000 0c000000e6e839da66b0dc14 00130b00000000000000000000000000 1400 000000000000 0200000001000000010000000000000000000000 - const size = 72 - req = append(req, 0, 0, 0, 0, 0, 0, 0, 0) - binary.LittleEndian.PutUint16(req[6:], c.seqCh0) // channel sequence - c.seqCh0++ - req[28] = 0x00 // command - req[29] = 0x13 - req[44] = size - 52 // data size - req[45] = 0x00 - return msgAck(req, size) -} - -func (c *Conn) msgAckCh0Req0070(req []byte) []byte { - // <- [104] 0402120a 58000300 08041200 e6e8 0000 0c000000e6e839da66b0dc14 00700800010000000000000000000000 3400 00007625a02f ... - // -> [ 52] 0402190a 24000400 07042100 e6e8 0000 0c000000e6e839da66b0dc14 00710800010000000000000000000000 0000 00007625a02f - binary.LittleEndian.PutUint16(req[6:], c.seqCh0) // channel sequence - c.seqCh0++ - req[28] = 0x00 // command - req[29] = 0x71 - req[44] = 0x00 // data size - req[45] = 0x00 - return msgAck(req, 52) -} - -func msgAckCh1Req0000(req []byte) []byte { - // <- [590] 0402120a 3e020100 08041200 e6e8 0100 0c000000e6e839da66b0dc14 00000800000000000000000000000000 1a02 0000d9c0001b ... - // -> [ 84] 0402190a 44000000 07042100 e6e8 0100 0c000000e6e839da66b0dc14 00140b00000000000000000000000000 2000 0000d9c0001b ... - const size = 84 - req[28] = 0x00 // command - req[29] = 0x14 - req[44] = size - 52 // data size - req[45] = 0x00 - copy(req[52:], req[len(req)-32:]) // size - return msgAck(req, size) -} - -func msgAckCh1Req0007(req []byte) []byte { - // <- [64] 0402120a 30000300 08041200 e6e8 0100 0c000000e6e839da66b0dc14 00070800000000000000000000000000 0c00 000001000000 000000006f1ea02f00000000 - // -> [56] 0402190a 28000200 07042100 e6e8 0100 0c000000e6e839da66b0dc14 010a0b00000000000000000000000000 0000 000001000000 00000000 - req[28] = 0x01 // command - req[29] = 0x0a - req[44] = 0x00 // data size - req[45] = 0x00 - return msgAck(req, 56) -} - -func msgAckCh5(req []byte) []byte { - // <- [48] 0402120a 20000200 08041200 e6e8 0500 0c000000e6e839da66b0dc14 5a97c2f1010500000000000000000000 00a0 0000 - // -> [48] 0402190a 20000200 07042100 e6e8 0500 0c000000e6e839da66b0dc14 5a97c2f1410500000000000000000000 00a0 0000 - req[32] = 0x41 - return msgAck(req, 48) -} diff --git a/pkg/xiaomi/tutk/crypto.go b/pkg/xiaomi/tutk/crypto.go index c98fc092..feeb31f7 100644 --- a/pkg/xiaomi/tutk/crypto.go +++ b/pkg/xiaomi/tutk/crypto.go @@ -1,7 +1,6 @@ package tutk import ( - "bytes" "encoding/binary" "math/bits" ) @@ -9,10 +8,12 @@ import ( // I'd like to say hello to Charlie. Your name is forever etched into the history of streaming software. const charlie = "Charlie is the designer of P2P!!" -func ReverseTransCodePartial(src []byte) []byte { +func ReverseTransCodePartial(dst, src []byte) []byte { n := len(src) tmp := make([]byte, n) - dst := bytes.Clone(src) + if len(dst) < n { + dst = make([]byte, n) + } src16 := src tmp16 := tmp @@ -24,7 +25,7 @@ func ReverseTransCodePartial(src []byte) []byte { binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3)) } - swap(tmp16, dst16, 16) + swap(dst16, tmp16, 16) for i := 0; i != 16; i++ { tmp16[i] = dst16[i] ^ charlie[i] @@ -40,7 +41,7 @@ func ReverseTransCodePartial(src []byte) []byte { src16 = src16[16:] } - swap(src16, tmp16, n) + swap(tmp16, src16, n) for i := 0; i < n; i++ { dst16[i] = tmp16[i] ^ charlie[i] @@ -49,10 +50,12 @@ func ReverseTransCodePartial(src []byte) []byte { return dst } -func TransCodePartial(src []byte) []byte { +func TransCodePartial(dst, src []byte) []byte { n := len(src) tmp := make([]byte, n) - dst := bytes.Clone(src) + if len(dst) < n { + dst = make([]byte, n) + } src16 := src tmp16 := tmp @@ -68,7 +71,7 @@ func TransCodePartial(src []byte) []byte { dst16[i] = tmp16[i] ^ charlie[i] } - swap(dst16, tmp16, 16) + swap(tmp16, dst16, 16) for i := 0; i != 16; i += 4 { x := binary.LittleEndian.Uint32(tmp16[i:]) @@ -84,12 +87,12 @@ func TransCodePartial(src []byte) []byte { tmp16[i] = src16[i] ^ charlie[i] } - swap(tmp16, dst16, n) + swap(dst16, tmp16, n) return dst } -func swap(src, dst []byte, n int) { +func swap(dst, src []byte, n int) { switch n { case 2: _, _ = src[1], dst[1] diff --git a/pkg/xiaomi/tutk/proto.go b/pkg/xiaomi/tutk/proto.go new file mode 100644 index 00000000..86cf95af --- /dev/null +++ b/pkg/xiaomi/tutk/proto.go @@ -0,0 +1,196 @@ +package tutk + +import ( + "encoding/binary" + "time" +) + +func (c *Conn) WriteCh0(msg []byte) error { + binary.LittleEndian.PutUint16(msg[6:], c.seqSendCh0) + c.seqSendCh0++ + return c.Write(msg) +} + +func (c *Conn) WriteCh1(msg []byte) error { + binary.LittleEndian.PutUint16(msg[6:], c.seqSendCh1) + c.seqSendCh1++ + msg[14] = 1 // channel + return c.Write(msg) +} + +func (c *Conn) msgConnectByUID(uid []byte, i byte) []byte { + const size = 68 // or 52 or 68 or 88 + b := make([]byte, size) + copy(b, "\x04\x02\x19\x02") + b[4] = size - 16 + copy(b[8:], "\x01\x06\x21\x00") + copy(b[16:], uid) + copy(b[52:], "\x00\x03\x01\x02") // or 07000303 or 01010204 + copy(b[56:], c.sid[8:]) + b[64] = i // 1 or 2 + return b +} + +func (c *Conn) msgAvClientStart() []byte { + const size = 566 + 32 + msg := c.msg(size) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x00\x0b\x00") + binary.LittleEndian.PutUint16(cmd[16:], size-52) + //cmd[18] = 1 // ??? + binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + // important values for some cameras (not for df3) + data := cmd[cmdHdrSize:] + copy(data, "Miss") + copy(data[257:], "client") + + // 0100000004000000fb071f000000000000000000000003000000000001000000 + cfg := msg[566:] + cfg[0] = 0 // 0 - simple proto, 1 - complex proto with "0Cxx" commands + cfg[4] = 4 + copy(cfg[8:], "\xfb\x07\x1f\x00") + cfg[22] = 3 + cfg[28] = 1 + return msg +} + +func (c *Conn) msg(size uint16) []byte { + b := make([]byte, size) + copy(b, "\x04\x02\x19\x0a") + binary.LittleEndian.PutUint16(b[4:], size-16) + copy(b[8:], "\x07\x04\x21\x00") + copy(b[12:], c.sid) + return b +} + +const ( + msgPing = iota + 1 + msgClientStart00 + msgClientStart20 + msgCommand + msgCounters + msgMediaChunk + msgMediaFrame + msgMediaLost + msgCh5 + msgUnknown0010 + msgUnknown0a08 + msgDafang0012 + msgDafang0071 +) + +// handleMsg will return parsed msg type or zero +func (c *Conn) handleMsg(msg []byte) int8 { + //log.Printf("<- %x", msg) + // off sample + // 0 0402 tutk magic + // 2 120a tutk version (120a, 190a...) + // 4 0800 msg size = len(b)-16 + // 6 0000 channel seq + // 8 28041200 msg type + // 14 0100 channel (not all msg) + // 28 0700 msg data (not all msg) + switch msg[8] { + case 0x28: + _ = c.Write(msgAckPing(msg)) + return msgPing + case 0x08: + switch ch := msg[14]; ch { + case 0: + return c.handleCh0(msg[28:]) + case 1: + return c.handleCh1(msg[28:]) + case 5: + return c.handleCh5(msg) + } + } + return 0 +} + +func (c *Conn) handleCh1(cmd []byte) int8 { + switch cid := string(cmd[:2]); cid { + case "\x00\x00": + _ = c.WriteCh1(c.msgAck0000(cmd)) + return msgClientStart00 + case "\x00\x20": + //_ = c.WriteCh1(c.msgAck0020(cmd)) + return msgClientStart20 + case "\x09\x00": // skip + return msgCounters + case "\x0a\x08": + _ = c.WriteCh1(c.msgAck0A08(cmd)) + return msgUnknown0a08 + } + return 0 +} + +func (c *Conn) handleCh5(msg []byte) int8 { + if len(msg) != 48 { + return 0 + } + + // <- [48] 0402190a 20000400 07042100 7ecc05000c0000007ecc93c456c2561f 5a97c2f101050000000000000000000000010000 + // -> [48] 0402190a 20000400 08041200 7ecc05000c0000007ecc93c456c2561f 5a97c2f141050000000000000000000000010000 + copy(msg[8:], "\x07\x04\x21\x00") + msg[32] = 0x41 + _ = c.Write(msg) + return msgCh5 +} + +const msgHhrSize = 28 +const cmdHdrSize = 24 + +func (c *Conn) msgAck0000(msg28 []byte) []byte { + const cmdDataSize = 36 + + msg := c.msg(msgHhrSize + cmdHdrSize + cmdDataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x14\x0b\x00") + cmd[16] = cmdDataSize + copy(cmd[20:], msg28[20:24]) // request id (random) + + // It's better not to answer anything, so camera won't send anything to this channel. + //data := cmd[cmdHdrSize:] + //copy(data, msg28[len(msg28)-32:]) + return msg +} + +//func (c *Conn) msgAck0020(msg28 []byte) []byte { +// const cmdDataSize = 36 +// +// msg := c.msg(msgHhrSize + cmdHdrSize + cmdDataSize) +// +// cmd := msg[msgHhrSize:] +// copy(cmd, "\x00\x14\x0b\x00") +// cmd[16] = cmdDataSize +// copy(cmd[20:], msg28[20:24]) // request id (random) +// +// data := cmd[cmdHdrSize:] +// data[5] = 1 +// data[7] = 1 +// data[8] = 1 +// data[12] = 4 +// copy(data[16:], "\xfb\x07\x1f\x00") +// data[30] = 3 +// data[32] = 1 +// return msg +//} + +func (c *Conn) msgAck0A08(msg28 []byte) []byte { + msg := c.msg(48) + cmd := msg[msgHhrSize:] + copy(cmd, "\x0b\x00\x0b\x00") + copy(cmd[8:], msg28[8:10]) + return msg +} + +func msgAckPing(req []byte) []byte { + // <- [24] 0402120a 08000000 28041200 000000005b0d4202070aa8c0 + // -> [24] 04021a0a 08000000 27042100 000000005b0d4202070aa8c0 + req[8] = 0x27 + req[10] = 0x21 + return req +} diff --git a/pkg/xiaomi/tutk/proto_new.go b/pkg/xiaomi/tutk/proto_new.go new file mode 100644 index 00000000..8f84b91b --- /dev/null +++ b/pkg/xiaomi/tutk/proto_new.go @@ -0,0 +1,191 @@ +package tutk + +import ( + "encoding/binary" + "fmt" + "time" +) + +func (c *Conn) newMsgCtrl(ctrlType uint16, ctrlData []byte) []byte { + size := msgHhrSize + 28 + 4 + uint16(len(ctrlData)) + msg := c.msg(size) + + // 0 0070 command + // 2 0b00 version + // 4 1000 seq + // 6 0076 ??? + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) + c.seqSendCmd1++ + + // 8 0070 command (second time) + // 10 0300 seq + // 12 0100 chunks count + // 14 0000 chunk seq (starts from 0) + // 16 5500 size + // 18 0000 random msg id (always 0) + // 20 03000000 seq (second time) + // 24 00000000 + // 28 01010000 ctrlType + cmd[9] = 0x70 + cmd[12] = 1 + binary.LittleEndian.PutUint16(cmd[16:], size-52) + + binary.LittleEndian.PutUint16(cmd[10:], c.seqSendCmd2) + binary.LittleEndian.PutUint16(cmd[20:], c.seqSendCmd2) + c.seqSendCmd2++ + + data := cmd[28:] + binary.LittleEndian.PutUint16(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (c *Conn) newHandlerCh0() func(msg []byte) int8 { + var waitData []byte + var waitSeq uint16 + + return func(cmd []byte) int8 { + switch cmd[0] { + case 0x07, 0x05: + flag := cmd[1] + + var cmd2 []byte + if flag&0b1000 == 0 { + // off sample + // 0 0700 command + // 2 0b00 version + // 4 2700 seq + // 6 0000 ??? + // 8 0700 command (second time) + // 10 1400 seq + // 12 1300 chunks count per this frame + // 14 1100 chunk seq, starts from 0 (0x20 for last chunk) + // 16 0004 frame data size + // 18 0000 random msg id (always 0) + // 20 02000000 previous frame seq, starts from 0 + // 24 03000000 current frame seq, starts from 1 + cmd2 = cmd[8:] + } else { + // off sample + // 0 070d0b00 + // 4 30000000 + // 8 5c965500 ??? + // 12 ffff0000 ??? + // 16 0701 fixed command + // 18 190001002000a802000006000000070000000 + cmd2 = cmd[16:] + } + + seq := binary.LittleEndian.Uint16(cmd2[2:]) + + // Check if this is first chunk for frame. + // Handle protocol bug "0x20 chunk seq for last chunk" and sometimes + // "0x20 chunk seq for first chunk if only one chunk". + if binary.LittleEndian.Uint16(cmd2[6:]) == 0 || binary.LittleEndian.Uint16(cmd2[4:]) == 1 { + waitData = waitData[:0] + waitSeq = seq + } else if seq != waitSeq { + return msgMediaLost + } + + if flag&0b0001 == 0 { + waitData = append(waitData, cmd2[20:]...) + waitSeq++ + return msgMediaChunk + } + + c.seqRecvPkt1 = seq + _ = c.WriteCh0(c.msgAckCounters()) + + data := cmd2[20:] + n := len(data) - 32 + waitData = append(waitData, data[:n]...) + + packetData := make([]byte, 32+len(waitData)) + copy(packetData, data[n:]) + copy(packetData[32:], waitData) + + select { + case c.rawPkt <- packetData: + default: + c.err = fmt.Errorf("%s: media queue is full", "tutk") + return -1 + } + return msgMediaFrame + + case 0x00: + _ = c.WriteCh0(c.msgAckCounters()) + c.seqRecvCmd2 = binary.LittleEndian.Uint16(cmd[2:]) + + switch cmd[1] { + case 0x10: + return msgUnknown0010 // unknown + case 0x70: + select { + case c.rawCmd <- cmd[28:]: + default: + } + return msgCommand // cmd from camera + } + + case 0x09: + // off sample + // 0 09000b00 cmd1 + // 4 0d000000 seqCmd1 + // 12 0000 seqRecvCmd2 + seq := binary.LittleEndian.Uint16(cmd[12:]) + if c.seqSendCmd1 > seq { + if c.cmdAck != nil { + c.cmdAck() + } + } + return msgCounters + + case 0x0a: + // seq sample + // 0 0a080b00 + // 4 03000000 + // 8 e2043200 + // 12 01000000 + _ = c.WriteCh0(c.msgAck0A08(cmd)) + return msgUnknown0a08 + } + + return 0 + } +} + +func (c *Conn) msgAckCounters() []byte { + msg := c.msg(msgHhrSize + cmdHdrSize) + + // off sample + // 0 09000b00 cmd1 + // 4 2700 seqCmd1 + // 6 0000 + // 8 1300 seqRecvPkt0 + // 10 2600 seqRecvPkt1 + // 12 0400 seqRecvCmd2 + // 14 00000000 + // 18 1400 seqSendCnt + // 20 d91a random + // 22 0000 + cmd := msg[msgHhrSize:] + copy(cmd, "\x09\x00\x0b\x00") + + binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) + c.seqSendCmd1++ + + // seqRecvPkt0 stores previous value of seqRecvPkt1 + // don't understand why this needs + binary.LittleEndian.PutUint16(cmd[8:], c.seqRecvPkt0) + c.seqRecvPkt0 = c.seqRecvPkt1 + binary.LittleEndian.PutUint16(cmd[10:], c.seqRecvPkt1) + binary.LittleEndian.PutUint16(cmd[12:], c.seqRecvCmd2) + + binary.LittleEndian.PutUint16(cmd[18:], c.seqSendCnt) + c.seqSendCnt++ + binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixNano())) + return msg +} diff --git a/pkg/xiaomi/tutk/proto_old.go b/pkg/xiaomi/tutk/proto_old.go new file mode 100644 index 00000000..7ac4f803 --- /dev/null +++ b/pkg/xiaomi/tutk/proto_old.go @@ -0,0 +1,153 @@ +package tutk + +import ( + "encoding/binary" + "fmt" +) + +func (c *Conn) oldMsgCtrl(ctrlType uint16, ctrlData []byte) []byte { + size := msgHhrSize + cmdHdrSize + 4 + uint16(len(ctrlData)) + msg := c.msg(size) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + + binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) + c.seqSendCmd1++ + + binary.LittleEndian.PutUint16(cmd[16:], size-52) + //binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + data := cmd[cmdHdrSize:] + binary.LittleEndian.PutUint16(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (c *Conn) oldHandlerCh0() func([]byte) int8 { + var waitSeq uint16 + var waitSize uint32 + var waitData []byte + + return func(cmd []byte) int8 { + // 0 01030800 command + version + // 4 00000000 fixed + // 8 ac880100 total size + // 12 6200 chunk seq + // 14 2000 ??? + // 16 cc00 size + // 18 0000 + // 20 01000000 fixed + + switch cmd[0] { + case 0x01: + switch cmd[1] { + case 0x03: + seq := binary.LittleEndian.Uint16(cmd[12:]) + if seq != waitSeq { + waitSeq = 0 + return msgMediaLost + } + if seq == 0 { + waitData = waitData[:0] + waitSize = binary.LittleEndian.Uint32(cmd[8:]) + 32 + } + + waitData = append(waitData, cmd[24:]...) + if n := uint32(len(waitData)); n < waitSize { + waitSeq++ + return msgMediaChunk + } else if n > waitSize { + waitSeq = 0 + return msgMediaLost + } + + // create a buffer for the header and collected data + packetData := make([]byte, waitSize) + // there's a header at the end - let's move it to the beginning + copy(packetData, waitData[waitSize-32:]) + copy(packetData[32:], waitData) + + select { + case c.rawPkt <- packetData: + default: + c.err = fmt.Errorf("%s: media queue is full", "tutk") + return -1 + } + + waitSeq = 0 + return msgMediaFrame + + case 0x04: + waitSize2 := binary.LittleEndian.Uint32(cmd[8:]) + waitData2 := cmd[24:] + + if uint32(len(waitData2)) != waitSize2 { + return -1 // shouldn't happened for audio + } + + packetData := make([]byte, waitSize2) + copy(packetData, waitData2) + + select { + case c.rawPkt <- packetData: + default: + c.err = fmt.Errorf("%s: media queue is full", "tutk") + return -1 + } + return msgMediaFrame + } + + case 0x00: + switch cmd[1] { + case 0x70: + _ = c.WriteCh0(c.msgAck0070(cmd)) + select { + case c.rawCmd <- cmd[24:]: + default: + } + return msgCommand + case 0x12: + _ = c.WriteCh0(c.msgAck0012(cmd)) + return msgDafang0012 + case 0x71: + if c.cmdAck != nil { + c.cmdAck() + } + return msgDafang0071 + } + } + + return 0 + } +} + +func (c *Conn) msgAck0070(msg28 []byte) []byte { + // <- [104] 0402120a 58000300 08041200 e6e8 0000 0c000000e6e839da66b0dc14 00700800010000000000000000000000 3400 00007625a02f ... + // -> [ 52] 0402190a 24000400 07042100 e6e8 0000 0c000000e6e839da66b0dc14 00710800010000000000000000000000 0000 00007625a02f + msg := c.msg(52) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x71\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) + c.seqSendCmd1++ + copy(cmd[8:], msg28[8:10]) + + return msg +} + +func (c *Conn) msgAck0012(msg28 []byte) []byte { + // <- [64] 0402120a 30000000 08041200 e6e800000c000000e6e839da66b0dc14 001208000000000000000000000000000c00000000000000 020000000100000001000000 + // -> [72] 0402190a 38000300 07042100 e6e800000c000000e6e839da66b0dc14 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000 + const size = 72 + msg := c.msg(size) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x13\x0b\x00") + cmd[16] = size - 52 // data size + + data := cmd[cmdHdrSize:] + copy(data, msg28[cmdHdrSize:]) + + return msg +} From f47a041ece68464a24e7ca89edd0999c0e7288a8 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 1 Jan 2026 11:37:44 +0100 Subject: [PATCH 167/241] Improve error logging for video playback --- www/video-rtc.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index b235b974..532cbc8d 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -250,18 +250,20 @@ export class VideoRTC extends HTMLElement { this.video.addEventListener('error', ev => { const err = this.video.error; + // https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code + const MEDIA_ERRORS = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED' + }; console.error('[VideoRTC] Video error:', { - code: err ? err.code : 'unknown', - message: err ? err.message : 'unknown', - MEDIA_ERR_ABORTED: 1, - MEDIA_ERR_NETWORK: 2, - MEDIA_ERR_DECODE: 3, - MEDIA_ERR_SRC_NOT_SUPPORTED: 4, + error: MEDIA_ERRORS[err?.code] || 'unknown', + message: err?.message || 'unknown', codecs: this.mseCodecs || 'not set', readyState: this.video.readyState, networkState: this.video.networkState, - currentTime: this.video.currentTime, - event: ev + currentTime: this.video.currentTime }); if (this.ws) this.ws.close(); // run reconnect for broken MSE stream }); From f9234875463fac2110badad636c3bb4ad76e6bfd Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 1 Jan 2026 11:46:17 +0100 Subject: [PATCH 168/241] Improve error logging for video playback --- www/video-rtc.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index 532cbc8d..953fdae6 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -258,8 +258,8 @@ export class VideoRTC extends HTMLElement { 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED' }; console.error('[VideoRTC] Video error:', { - error: MEDIA_ERRORS[err?.code] || 'unknown', - message: err?.message || 'unknown', + error: err ? MEDIA_ERRORS[err.code] : 'unknown', + message: err ? err.message : 'unknown', codecs: this.mseCodecs || 'not set', readyState: this.video.readyState, networkState: this.video.networkState, From 4cff72c9a3085f6e07bb751c4193cce9552686f3 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 2 Jan 2026 02:22:14 +0100 Subject: [PATCH 169/241] Refactor discovery and session setup logic --- pkg/wyze/tutk/conn.go | 325 +++++++++++++++++++++++------------------- 1 file changed, 177 insertions(+), 148 deletions(-) diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 53659b84..47cfc0d3 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net" - "os" "sync" "time" @@ -23,6 +22,11 @@ const ( DefaultPort = 32761 // TUTK discovery port MaxPacketSize = 2048 // Max single packet size ReadBufferSize = 2 * 1024 * 1024 // 2MB for video streams + + DiscoTimeout = 5000 * time.Millisecond // Total timeout for discovery + DiscoInterval = 100 * time.Millisecond // Interval between discovery packets + SessionTimeout = 5000 * time.Millisecond // Total timeout for session setup + ReadWaitInterval = 50 * time.Millisecond // Read wait interval per iteration ) type FrameAssembler struct { @@ -33,16 +37,16 @@ type FrameAssembler struct { } type Conn struct { - udpConn *net.UDPConn - addr *net.UDPAddr - broadcastAddr *net.UDPAddr - randomID []byte - uid string - authKey string - enr string - psk []byte - iotcTxSeq uint16 - avLoginResp *AVLoginResponse + udpConn *net.UDPConn + addr *net.UDPAddr + broadcastAddrs []*net.UDPAddr + randomID []byte + uid string + authKey string + enr string + psk []byte + iotcTxSeq uint16 + avLoginResp *AVLoginResponse // DTLS - Main Channel (we = Client) mainConn *dtls.Conn @@ -93,21 +97,19 @@ func Dial(host, uid, authKey, enr string, verbose bool) (*Conn, error) { psk := hash[:] c := &Conn{ - udpConn: conn, - addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, - broadcastAddr: &net.UDPAddr{IP: net.IPv4(255, 255, 255, 255), Port: DefaultPort}, - randomID: genRandomID(), - uid: uid, - authKey: authKey, - enr: enr, - psk: psk, - verbose: verbose, - ctx: ctx, - cancel: cancel, - // DTLS channel buffers + udpConn: conn, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, + broadcastAddrs: getBroadcastAddrs(DefaultPort, verbose), + randomID: genRandomID(), + uid: uid, + authKey: authKey, + enr: enr, + psk: psk, + verbose: verbose, + ctx: ctx, + cancel: cancel, mainBuf: make(chan []byte, 64), speakerBuf: make(chan []byte, 64), - // Packet delivery (SDK-style FIFO) packetQueue: make(chan *Packet, 128), done: make(chan struct{}), ioctrl: make(chan []byte, 16), @@ -400,18 +402,26 @@ func (c *Conn) discoStage1() error { encrypted := crypto.TransCodeBlob(pkt) if c.verbose { - fmt.Printf("[IOTC] Disco Stage 1: broadcast + direct to %s\n", c.addr) + fmt.Printf("[IOTC] Disco Stage 1: timeout=%v interval=%v broadcasts=%d\n", + DiscoTimeout, DiscoInterval, len(c.broadcastAddrs)) } - for range 10 { - _, _ = c.udpConn.WriteToUDP(encrypted, c.broadcastAddr) + deadline := time.Now().Add(DiscoTimeout) + lastSend := time.Time{} + buf := make([]byte, MaxPacketSize) - if _, err := c.udpConn.WriteToUDP(encrypted, c.addr); err != nil { - return err + for time.Now().Before(deadline) { + if time.Since(lastSend) >= DiscoInterval { + for _, bcast := range c.broadcastAddrs { + c.udpConn.WriteToUDP(encrypted, bcast) + if c.verbose { + fmt.Printf("[IOTC] Disco Stage 1: sent to %s\n", bcast) + } + } + lastSend = time.Now() } - buf := make([]byte, MaxPacketSize) - c.udpConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + c.udpConn.SetReadDeadline(time.Now().Add(ReadWaitInterval)) n, addr, err := c.udpConn.ReadFromUDP(buf) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { @@ -439,7 +449,7 @@ func (c *Conn) discoStage1() error { } } - return fmt.Errorf("timeout") + return fmt.Errorf("timeout after %v", DiscoTimeout) } func (c *Conn) discoStage2() { @@ -453,28 +463,22 @@ func (c *Conn) sessionSetup() error { pkt := c.buildSession() if c.verbose { - fmt.Printf("[IOTC] Session setup: sending to %s\n", c.addr) + fmt.Printf("[IOTC] Session setup: target=%s\n", c.addr) } + // Send request if _, err := c.sendEncrypted(pkt); err != nil { return err } - for retry := range 10 { - buf := make([]byte, MaxPacketSize) - c.udpConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + // Wait for response + buf := make([]byte, MaxPacketSize) + c.udpConn.SetReadDeadline(time.Now().Add(SessionTimeout)) + + for { n, addr, err := c.udpConn.ReadFromUDP(buf) if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - if retry%3 == 2 { - if c.verbose { - fmt.Printf("[IOTC] Session setup: resending (retry %d)\n", retry) - } - _, _ = c.sendEncrypted(pkt) - } - continue - } - return err + return fmt.Errorf("timeout: %w", err) } data := crypto.ReverseTransCodeBlob(buf[:n]) @@ -495,8 +499,6 @@ func (c *Conn) sessionSetup() error { return nil } } - - return fmt.Errorf("timeout") } func (c *Conn) connect() error { @@ -1209,28 +1211,30 @@ func (c *Conn) buildDisco(stage byte) []byte { const frameSize = 16 + bodySize frame := make([]byte, frameSize) - frame[0] = 0x04 - frame[1] = 0x02 - frame[2] = 0x1a - frame[3] = 0x02 - binary.LittleEndian.PutUint16(frame[4:6], bodySize) - binary.LittleEndian.PutUint16(frame[8:10], CmdDiscoReq) - binary.LittleEndian.PutUint16(frame[10:12], 0x0021) + // IOTC Frame Header [0-15] + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x02 // [3] Mode = Disco + binary.LittleEndian.PutUint16(frame[4:6], bodySize) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[8:10], CmdDiscoReq) // [8-9] Command = 0x0601 + binary.LittleEndian.PutUint16(frame[10:12], 0x0021) // [10-11] Flags + // Body [16-87] body := frame[16:] - copy(body[0:], c.uid) + copy(body[0:20], c.uid) // [0-19] UID (20 bytes) - body[36] = 0x01 - body[37] = 0x01 - body[38] = 0x02 - body[39] = 0x04 + body[36] = 0x01 // [36] Unknown1 + body[37] = 0x01 // [37] Unknown2 + body[38] = 0x02 // [38] Unknown3 + body[39] = 0x04 // [39] Unknown4 - copy(body[40:48], c.randomID) - body[48] = stage + copy(body[40:48], c.randomID) // [40-47] RandomID + body[48] = stage // [48] Stage (1=broadcast, 2=direct) if stage == 1 && len(c.authKey) > 0 { - copy(body[58:], c.authKey) + copy(body[58:], c.authKey) // [58-65] AuthKey } return frame @@ -1241,41 +1245,28 @@ func (c *Conn) buildSession() []byte { const frameSize = 16 + bodySize frame := make([]byte, frameSize) - frame[0] = 0x04 - frame[1] = 0x02 - frame[2] = 0x1a - frame[3] = 0x02 - binary.LittleEndian.PutUint16(frame[4:6], bodySize) - binary.LittleEndian.PutUint16(frame[8:10], CmdSessionReq) - binary.LittleEndian.PutUint16(frame[10:12], 0x0033) + // IOTC Frame Header [0-15] + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x02 // [3] Mode + binary.LittleEndian.PutUint16(frame[4:6], bodySize) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[8:10], CmdSessionReq) // [8-9] Command = 0x0402 + binary.LittleEndian.PutUint16(frame[10:12], 0x0033) // [10-11] Flags + // Body [16-51] body := frame[16:] - copy(body[0:], c.uid) - copy(body[20:28], c.randomID) + copy(body[0:20], c.uid) // [0-19] UID (20 bytes) + copy(body[20:28], c.randomID) // [20-27] RandomID ts := uint32(time.Now().Unix()) - binary.LittleEndian.PutUint32(body[32:36], ts) + binary.LittleEndian.PutUint32(body[32:36], ts) // [32-35] Timestamp return frame } func (c *Conn) buildDTLSConfig(isServer bool) *dtls.Config { - var keyLogWriter io.Writer - - if c.verbose { - keyLogPath := os.Getenv("SSLKEYLOGFILE") - if keyLogPath != "" { - f, err := os.OpenFile(keyLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err == nil { - keyLogWriter = f - if c.verbose { - fmt.Printf("[DTLS] Key Log: %s\n", keyLogPath) - } - } - } - } - config := &dtls.Config{ PSK: func(hint []byte) ([]byte, error) { if c.verbose { @@ -1289,7 +1280,6 @@ func (c *Conn) buildDTLSConfig(isServer bool) *dtls.Config { MTU: 1200, FlightInterval: 300 * time.Millisecond, ExtendedMasterSecret: dtls.DisableExtendedMasterSecret, - KeyLogWriter: keyLogWriter, } // Use custom cipher suites for client, standard for server @@ -1308,32 +1298,31 @@ func (c *Conn) buildDataTXChannel(payload []byte, channel byte) []byte { frameSize := 16 + bodySize frame := make([]byte, frameSize) - frame[0] = 0x04 - frame[1] = 0x02 - frame[2] = 0x1a - frame[3] = 0x0b - - binary.LittleEndian.PutUint16(frame[4:6], uint16(bodySize)) - binary.LittleEndian.PutUint16(frame[6:8], c.iotcTxSeq) + // IOTC Frame Header [0-15] + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x0b // [3] Mode = Data + binary.LittleEndian.PutUint16(frame[4:6], uint16(bodySize)) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[6:8], c.iotcTxSeq) // [6-7] Sequence c.iotcTxSeq++ - binary.LittleEndian.PutUint16(frame[8:10], CmdDataTX) - binary.LittleEndian.PutUint16(frame[10:12], 0x0021) + binary.LittleEndian.PutUint16(frame[8:10], CmdDataTX) // [8-9] Command = 0x0407 + binary.LittleEndian.PutUint16(frame[10:12], 0x0021) // [10-11] Flags + copy(frame[12:14], c.randomID[:2]) // [12-13] RandomID[0:2] + frame[14] = channel // [14] Channel (0=Main, 1=Back) + frame[15] = 0x01 // [15] Marker - copy(frame[12:14], c.randomID[:2]) - frame[14] = channel // Channel byte: 0 = Main, 1 = Backchannel - frame[15] = 0x01 - - binary.LittleEndian.PutUint32(frame[16:20], 0x0000000c) - copy(frame[20:28], c.randomID[:8]) + // Sub-Header [16-27] + binary.LittleEndian.PutUint32(frame[16:20], 0x0000000c) // [16-19] Const + copy(frame[20:28], c.randomID[:8]) // [20-27] RandomID + // Payload [28+] copy(frame[28:], payload) return frame } func (c *Conn) buildACK() []byte { - // c.ackFlags++ - if c.ackFlags == 0 { c.ackFlags = 0x0001 } else if c.ackFlags < 0x0007 { @@ -1341,13 +1330,13 @@ func (c *Conn) buildACK() []byte { } ack := make([]byte, 24) - binary.LittleEndian.PutUint16(ack[0:2], MagicACK) // Magic - binary.LittleEndian.PutUint16(ack[2:4], ProtocolVersion) // Version - binary.LittleEndian.PutUint32(ack[4:8], c.avTxSeq) // TxSeq + binary.LittleEndian.PutUint16(ack[0:2], MagicACK) // [0-1] Magic = 0x0009 + binary.LittleEndian.PutUint16(ack[2:4], ProtocolVersion) // [2-3] Version = 0x000C + binary.LittleEndian.PutUint32(ack[4:8], c.avTxSeq) // [4-7] TxSeq c.avTxSeq++ - binary.LittleEndian.PutUint32(ack[8:12], 0xffffffff) // RxSeq - binary.LittleEndian.PutUint16(ack[12:14], c.ackFlags) // Flags - binary.LittleEndian.PutUint32(ack[16:20], uint32(c.ackFlags)<<16) // SDK uses ackFlags<<16, not avTxSeq + binary.LittleEndian.PutUint32(ack[8:12], 0xffffffff) // [8-11] RxSeq (not used) + binary.LittleEndian.PutUint16(ack[12:14], c.ackFlags) // [12-13] AckFlags + binary.LittleEndian.PutUint32(ack[16:20], uint32(c.ackFlags)<<16) // [16-19] AckCounter return ack } @@ -1355,17 +1344,18 @@ func (c *Conn) buildACK() []byte { func (c *Conn) buildKeepaliveResponse(incomingPayload []byte) []byte { frame := make([]byte, 24) - frame[0] = 0x04 - frame[1] = 0x02 - frame[2] = 0x1a - frame[3] = 0x0a - - binary.LittleEndian.PutUint16(frame[4:6], 8) - binary.LittleEndian.PutUint16(frame[8:10], CmdKeepaliveReq) - binary.LittleEndian.PutUint16(frame[10:12], 0x0021) + // IOTC Frame Header [0-15] + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x0a // [3] Mode + binary.LittleEndian.PutUint16(frame[4:6], 8) // [4-5] BodySize = 8 + binary.LittleEndian.PutUint16(frame[8:10], CmdKeepaliveReq) // [8-9] Command = 0x0427 + binary.LittleEndian.PutUint16(frame[10:12], 0x0021) // [10-11] Flags + // Body [16-23]: Echo back incoming payload if len(incomingPayload) >= 8 { - copy(frame[16:24], incomingPayload[:8]) + copy(frame[16:24], incomingPayload[:8]) // [16-23] EchoPayload } return frame @@ -1403,33 +1393,6 @@ func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID } func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { - // SDK sends 60-byte AV Login response - // Captured from SDK: 00 21 0c 00 10 00 00 00 00 00 00 00 00 00 00 00 - // 24 00 00 00 cd ac ca 40 00 00 00 00 00 01 00 01 - // 00 00 00 00 04 00 00 00 fb 07 1f 00 00 00 00 00 - // 00 00 00 00 00 00 03 00 02 00 00 00 - // - // Structure: - // [0-1] Magic: 0x2100 (Login Response) - // [2-3] Protocol Version: 0x000c - // [4] Response Type: 0x10 (success) - // [5-15] Reserved: zeros - // [16-19] Payload Size: 0x24 = 36 - // [20-23] Checksum: MUST echo from request! - // [24-27] Reserved: zeros - // [28] Flag1: 0x00 - // [29] EnableFlag: 0x01 - // [30] Flag2: 0x00 - // [31] TwoWayStreaming: 0x01 - // [32-35] Reserved: zeros - // [36-39] BufferConfig: 0x04 - // [40-43] Capabilities: 0x001f07fb - // [44-51] Reserved: zeros - // [52-53] Reserved: zeros - // [54-55] ChannelInfo1: 0x0003 - // [56-57] ChannelInfo2: 0x0002 - // [58-59] Reserved: zeros - resp := make([]byte, 60) // Header @@ -1553,3 +1516,69 @@ func genRandomID() []byte { _, _ = rand.Read(b) return b } + +func getBroadcastAddrs(port int, verbose bool) []*net.UDPAddr { + var addrs []*net.UDPAddr + + ifaces, err := net.Interfaces() + if err != nil { + if verbose { + fmt.Printf("[IOTC] Failed to get interfaces: %v\n", err) + } + // Fallback to limited broadcast + return []*net.UDPAddr{{IP: net.IPv4(255, 255, 255, 255), Port: port}} + } + + for _, iface := range ifaces { + // Skip loopback and down interfaces + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + + ifAddrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, addr := range ifAddrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + + // Only IPv4 + ip4 := ipNet.IP.To4() + if ip4 == nil { + continue + } + + // Calculate broadcast address: IP | ~mask + mask := ipNet.Mask + if len(mask) != 4 { + continue + } + + broadcast := make(net.IP, 4) + for i := 0; i < 4; i++ { + broadcast[i] = ip4[i] | ^mask[i] + } + + bcastAddr := &net.UDPAddr{IP: broadcast, Port: port} + addrs = append(addrs, bcastAddr) + + if verbose { + fmt.Printf("[IOTC] Found broadcast address: %s (iface: %s)\n", bcastAddr, iface.Name) + } + } + } + + if len(addrs) == 0 { + // Fallback to limited broadcast + if verbose { + fmt.Printf("[IOTC] No broadcast addresses found, using 255.255.255.255\n") + } + return []*net.UDPAddr{{IP: net.IPv4(255, 255, 255, 255), Port: port}} + } + + return addrs +} From d4dc670cb5fdab3759dc2c715b0ab1730d4288e4 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 2 Jan 2026 10:19:58 +0300 Subject: [PATCH 170/241] Add support two-way audio for Dafang camera --- pkg/xiaomi/backchannel.go | 5 +- pkg/xiaomi/producer.go | 12 ++-- pkg/xiaomi/tutk/conn.go | 3 +- pkg/xiaomi/tutk/proto.go | 75 +++++++++++++++++++++---- pkg/xiaomi/tutk/proto_new.go | 2 +- pkg/xiaomi/tutk/proto_old.go | 103 ++++++++++++++++++++++++----------- 6 files changed, 147 insertions(+), 53 deletions(-) diff --git a/pkg/xiaomi/backchannel.go b/pkg/xiaomi/backchannel.go index a6f57b81..17242b8e 100644 --- a/pkg/xiaomi/backchannel.go +++ b/pkg/xiaomi/backchannel.go @@ -23,7 +23,8 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv case core.CodecPCMA: var buf []byte - if p.model == "isa.camera.hlc6" { + switch p.model { + case "isa.camera.hlc6", "isa.camera.df3": dst := &core.Codec{Name: core.CodecPCML, ClockRate: 8000} transcode := pcm.Transcode(dst, track.Codec) @@ -36,7 +37,7 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv buf = buf[size:] } } - } else { + default: sender.Handler = func(pkt *rtp.Packet) { buf = append(buf, pkt.Payload...) const size = 8000 * 0.040 // 8bit 40 ms diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go index f2ce4eda..805a3f86 100644 --- a/pkg/xiaomi/producer.go +++ b/pkg/xiaomi/producer.go @@ -140,13 +140,11 @@ func probe(client *miss.Client, channel, quality, audio uint8) ([]*core.Media, e Codecs: []*core.Codec{acodec}, }) - if client.Protocol() == "cs2+udp" { - medias = append(medias, &core.Media{ - Kind: core.KindAudio, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{acodec.Clone()}, - }) - } + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{acodec.Clone()}, + }) } return medias, nil diff --git a/pkg/xiaomi/tutk/conn.go b/pkg/xiaomi/tutk/conn.go index 37489ffa..1b0ea56e 100644 --- a/pkg/xiaomi/tutk/conn.go +++ b/pkg/xiaomi/tutk/conn.go @@ -65,6 +65,7 @@ type Conn struct { seqSendCmd1 uint16 seqSendCmd2 uint16 seqSendCnt uint16 + seqSendAud uint16 seqRecvPkt0 uint16 seqRecvPkt1 uint16 @@ -249,7 +250,7 @@ func (c *Conn) ReadPacket() ([]byte, error) { } func (c *Conn) WritePacket(data []byte) error { - panic("not implemented") + return c.WriteCh1(c.oldMsgAud(data)) } func genSID() []byte { diff --git a/pkg/xiaomi/tutk/proto.go b/pkg/xiaomi/tutk/proto.go index 86cf95af..a440900a 100644 --- a/pkg/xiaomi/tutk/proto.go +++ b/pkg/xiaomi/tutk/proto.go @@ -75,7 +75,10 @@ const ( msgMediaFrame msgMediaLost msgCh5 + msgUnknown0007 + msgUnknown0008 msgUnknown0010 + msgUnknown0013 msgUnknown0a08 msgDafang0012 msgDafang0071 @@ -110,16 +113,29 @@ func (c *Conn) handleMsg(msg []byte) int8 { } func (c *Conn) handleCh1(cmd []byte) int8 { + // Channel 1 used for two-way audio. It's important: + // - answer on 0000 command with exact config response (can't set simple proto) + // - send 0012 command at start + // - respond on every 0008 command for smooth playback switch cid := string(cmd[:2]); cid { - case "\x00\x00": + case "\x00\x00": // client start _ = c.WriteCh1(c.msgAck0000(cmd)) + _ = c.WriteCh1(c.msg0012()) return msgClientStart00 - case "\x00\x20": + case "\x00\x07": // time sync without data + _ = c.WriteCh1(c.msgAck0007(cmd)) + return msgUnknown0007 + case "\x00\x08": // time sync with data + _ = c.WriteCh1(c.msgAck0008(cmd)) + return msgUnknown0008 + case "\x00\x13": // ack for 0012 + return msgUnknown0013 + case "\x00\x20": // client start2 //_ = c.WriteCh1(c.msgAck0020(cmd)) return msgClientStart20 - case "\x09\x00": // skip + case "\x09\x00": // counters sync return msgCounters - case "\x0a\x08": + case "\x0a\x08": // unknown _ = c.WriteCh1(c.msgAck0A08(cmd)) return msgUnknown0a08 } @@ -143,8 +159,9 @@ const msgHhrSize = 28 const cmdHdrSize = 24 func (c *Conn) msgAck0000(msg28 []byte) []byte { - const cmdDataSize = 36 - + // <- 000008000000000000000000000000001a0200004f47c714 ... 00000000000000000100000004000000fb071f00000000000000000000000300 + // -> 00140b00000000000000000000000000200000004f47c714 00000000000000000100000004000000fb071f00000000000000000000000300 + const cmdDataSize = 32 msg := c.msg(msgHhrSize + cmdHdrSize + cmdDataSize) cmd := msg[msgHhrSize:] @@ -152,9 +169,9 @@ func (c *Conn) msgAck0000(msg28 []byte) []byte { cmd[16] = cmdDataSize copy(cmd[20:], msg28[20:24]) // request id (random) - // It's better not to answer anything, so camera won't send anything to this channel. - //data := cmd[cmdHdrSize:] - //copy(data, msg28[len(msg28)-32:]) + // Important to answer with same data. + data := cmd[cmdHdrSize:] + copy(data, msg28[len(msg28)-32:]) return msg } @@ -179,8 +196,46 @@ func (c *Conn) msgAck0000(msg28 []byte) []byte { // return msg //} +func (c *Conn) msg0012() []byte { + // -> 00120b000000000000000000000000000c00000000000000020000000100000001000000 + const dataSize = 12 + msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) + cmd := msg[msgHhrSize:] + + copy(cmd, "\x00\x12\x0b\x00") + cmd[16] = dataSize + data := cmd[cmdHdrSize:] + + data[0] = 2 + data[4] = 1 + data[9] = 1 + return msg +} + +func (c *Conn) msgAck0007(msg28 []byte) []byte { + // <- 000708000000000000000000000000000c00000001000000000000001c551f7a00000000 + // -> 010a0b00000000000000000000000000000000000100000000000000 + msg := c.msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x0a\x0b\x00") + cmd[20] = 1 + return msg +} + +func (c *Conn) msgAck0008(msg28 []byte) []byte { + // <- 000808000000000000000000000000000000f9f0010000000200000050f31f7a + // -> 01090b0000000000000000000000000000000000010000000200000050f31f7a + msg := c.msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x09\x0b\x00") + copy(cmd[20:], msg28[20:]) + return msg +} + func (c *Conn) msgAck0A08(msg28 []byte) []byte { - msg := c.msg(48) + // <- 0a080b005b0000000b51590002000000 + // -> 0b000b00000001000b5103000300000000000000 + msg := c.msg(msgHhrSize + 20) cmd := msg[msgHhrSize:] copy(cmd, "\x0b\x00\x0b\x00") copy(cmd[8:], msg28[8:10]) diff --git a/pkg/xiaomi/tutk/proto_new.go b/pkg/xiaomi/tutk/proto_new.go index 8f84b91b..e7a08080 100644 --- a/pkg/xiaomi/tutk/proto_new.go +++ b/pkg/xiaomi/tutk/proto_new.go @@ -186,6 +186,6 @@ func (c *Conn) msgAckCounters() []byte { binary.LittleEndian.PutUint16(cmd[18:], c.seqSendCnt) c.seqSendCnt++ - binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixNano())) + binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixMilli())) return msg } diff --git a/pkg/xiaomi/tutk/proto_old.go b/pkg/xiaomi/tutk/proto_old.go index 7ac4f803..875c67a9 100644 --- a/pkg/xiaomi/tutk/proto_old.go +++ b/pkg/xiaomi/tutk/proto_old.go @@ -3,11 +3,12 @@ package tutk import ( "encoding/binary" "fmt" + "time" ) func (c *Conn) oldMsgCtrl(ctrlType uint16, ctrlData []byte) []byte { - size := msgHhrSize + cmdHdrSize + 4 + uint16(len(ctrlData)) - msg := c.msg(size) + dataSize := 4 + uint16(len(ctrlData)) + msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) cmd := msg[msgHhrSize:] copy(cmd, "\x00\x70\x0b\x00") @@ -15,7 +16,7 @@ func (c *Conn) oldMsgCtrl(ctrlType uint16, ctrlData []byte) []byte { binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) c.seqSendCmd1++ - binary.LittleEndian.PutUint16(cmd[16:], size-52) + binary.LittleEndian.PutUint16(cmd[16:], dataSize) //binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) data := cmd[cmdHdrSize:] @@ -24,6 +25,43 @@ func (c *Conn) oldMsgCtrl(ctrlType uint16, ctrlData []byte) []byte { return msg } +const pktHdrSize = 32 + +func (c *Conn) oldMsgAud(pkt []byte) []byte { + // -> 01030b001d0000008802000000002800b0020bf501000000 ... 4f4455412000000088020000030400001d000000000000000bf51f7a9b0100000000000000000000 + hdr := pkt[:pktHdrSize] + payload := pkt[pktHdrSize:] + + n := uint16(len(payload)) + dataSize := n + 8 + 32 + msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) + + // 0 01030b00 command + version + // 4 1d000000 seq + // 8 8802 media size (648) + // 10 00000000 + // 14 2800 tail (pkt header) size? + // 16 b002 size (648 + 8 + 32) + // 18 0bf5 random msg id (unixms) + // 20 01000000 fixed + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x03\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], c.seqSendAud) + c.seqSendAud++ + binary.LittleEndian.PutUint16(cmd[8:], n) + cmd[14] = 0x28 // important! + binary.LittleEndian.PutUint16(cmd[16:], dataSize) + binary.LittleEndian.PutUint16(cmd[18:], uint16(time.Now().UnixMilli())) + cmd[20] = 1 + + data := cmd[cmdHdrSize:] + copy(data, payload) + copy(data[n:], "ODUA\x20\x00\x00\x00") + copy(data[n+8:], hdr) + + return msg +} + func (c *Conn) oldHandlerCh0() func([]byte) int8 { var waitSeq uint16 var waitSize uint32 @@ -34,13 +72,15 @@ func (c *Conn) oldHandlerCh0() func([]byte) int8 { // 4 00000000 fixed // 8 ac880100 total size // 12 6200 chunk seq - // 14 2000 ??? + // 14 2000 tail (pkt header) size? // 16 cc00 size // 18 0000 // 20 01000000 fixed switch cmd[0] { case 0x01: + var packetData []byte + switch cmd[1] { case 0x03: seq := binary.LittleEndian.Uint16(cmd[12:]) @@ -62,42 +102,41 @@ func (c *Conn) oldHandlerCh0() func([]byte) int8 { return msgMediaLost } + waitSeq = 0 + // create a buffer for the header and collected data - packetData := make([]byte, waitSize) + packetData = make([]byte, waitSize) // there's a header at the end - let's move it to the beginning copy(packetData, waitData[waitSize-32:]) copy(packetData[32:], waitData) - select { - case c.rawPkt <- packetData: - default: - c.err = fmt.Errorf("%s: media queue is full", "tutk") - return -1 - } - - waitSeq = 0 - return msgMediaFrame - case 0x04: + // This is audio from miss audio start command. MiHome not using miss commands. waitSize2 := binary.LittleEndian.Uint32(cmd[8:]) waitData2 := cmd[24:] if uint32(len(waitData2)) != waitSize2 { - return -1 // shouldn't happened for audio + return -1 // shouldn't happen for audio } - packetData := make([]byte, waitSize2) + packetData = make([]byte, waitSize2) copy(packetData, waitData2) - select { - case c.rawPkt <- packetData: - default: - c.err = fmt.Errorf("%s: media queue is full", "tutk") - return -1 - } - return msgMediaFrame + default: + return 0 } + // fix Dafang bug (timestamp in seconds) + binary.LittleEndian.PutUint64(packetData[16:], uint64(time.Now().UnixMilli())) + + select { + case c.rawPkt <- packetData: + default: + c.err = fmt.Errorf("%s: media queue is full", "tutk") + return -1 + } + return msgMediaFrame + case 0x00: switch cmd[1] { case 0x70: @@ -123,9 +162,9 @@ func (c *Conn) oldHandlerCh0() func([]byte) int8 { } func (c *Conn) msgAck0070(msg28 []byte) []byte { - // <- [104] 0402120a 58000300 08041200 e6e8 0000 0c000000e6e839da66b0dc14 00700800010000000000000000000000 3400 00007625a02f ... - // -> [ 52] 0402190a 24000400 07042100 e6e8 0000 0c000000e6e839da66b0dc14 00710800010000000000000000000000 0000 00007625a02f - msg := c.msg(52) + // <- 00700800010000000000000000000000340000007625a02f ... + // -> 00710800010000000000000000000000000000007625a02f + msg := c.msg(msgHhrSize + cmdHdrSize) cmd := msg[msgHhrSize:] copy(cmd, "\x00\x71\x0b\x00") @@ -137,14 +176,14 @@ func (c *Conn) msgAck0070(msg28 []byte) []byte { } func (c *Conn) msgAck0012(msg28 []byte) []byte { - // <- [64] 0402120a 30000000 08041200 e6e800000c000000e6e839da66b0dc14 001208000000000000000000000000000c00000000000000 020000000100000001000000 - // -> [72] 0402190a 38000300 07042100 e6e800000c000000e6e839da66b0dc14 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000 - const size = 72 - msg := c.msg(size) + // <- 001208000000000000000000000000000c00000000000000 020000000100000001000000 + // -> 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000 + const dataSize = 20 + msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) cmd := msg[msgHhrSize:] copy(cmd, "\x00\x13\x0b\x00") - cmd[16] = size - 52 // data size + cmd[16] = dataSize data := cmd[cmdHdrSize:] copy(data, msg28[cmdHdrSize:]) From c74a39a30d76ee627414299fd8b982969c86b800 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 2 Jan 2026 16:51:23 +0100 Subject: [PATCH 171/241] cleanup --- pkg/wyze/tutk/conn.go | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 47cfc0d3..fa19a55f 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -108,13 +108,13 @@ func Dial(host, uid, authKey, enr string, verbose bool) (*Conn, error) { verbose: verbose, ctx: ctx, cancel: cancel, - mainBuf: make(chan []byte, 64), - speakerBuf: make(chan []byte, 64), - packetQueue: make(chan *Packet, 128), - done: make(chan struct{}), - ioctrl: make(chan []byte, 16), - ackReceived: make(chan struct{}, 1), - errors: make(chan error, 1), + mainBuf: make(chan []byte, 64), + speakerBuf: make(chan []byte, 64), + packetQueue: make(chan *Packet, 128), + done: make(chan struct{}), + ioctrl: make(chan []byte, 16), + ackReceived: make(chan struct{}, 1), + errors: make(chan error, 1), } if err = c.discovery(); err != nil { @@ -169,7 +169,6 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { return io.EOF } if len(data) >= 32 && binary.LittleEndian.Uint16(data[0:2]) == MagicAVLoginResp { - // Parse response inline c.avLoginResp = &AVLoginResponse{ ServerType: binary.LittleEndian.Uint32(data[4:8]), Resend: int32(data[29]), @@ -265,7 +264,6 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, return fmt.Errorf("speaker channel not connected") } - // Build frame with 36-byte header + audio + 16-byte FrameInfo (FrameInfo inside payload!) frame := c.buildAudioFrame(payload, timestampUS, codec, sampleRate, channels) if c.verbose { @@ -290,7 +288,6 @@ func (c *Conn) SendIOCtrl(cmdID uint16, payload []byte) error { return err } - // Block until ACK received (like SDK) select { case <-c.ackReceived: if c.verbose { @@ -350,14 +347,12 @@ func (c *Conn) SetDeadline(t time.Time) error { } func (c *Conn) Close() error { - // Signal done to stop goroutines select { case <-c.done: default: close(c.done) } - // Close DTLS connections c.mu.Lock() if c.mainConn != nil { c.mainConn.Close() @@ -370,8 +365,6 @@ func (c *Conn) Close() error { c.mu.Unlock() c.cancel() - - // Wait for goroutines c.wg.Wait() close(c.ioctrl) @@ -544,7 +537,6 @@ func (c *Conn) iotcReader() { default: } - // Inline receive with timeout c.udpConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) n, addr, err := c.udpConn.ReadFromUDP(buf) if err != nil { @@ -763,7 +755,7 @@ func (c *Conn) handleSpeakerAVLogin() error { return fmt.Errorf("AV login too short: %d bytes", n) } - // Extract checksum from incoming request (bytes 20-23) - MUST echo this back! + // Extract checksum from incoming request (bytes 20-23) checksum := binary.LittleEndian.Uint32(buf[20:24]) // Build AV Login response (60 bytes like SDK) @@ -877,7 +869,7 @@ func (c *Conn) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { headerSize = 36 frameInfoSize = FrameInfoSize default: - // Unknown frame type - use 28-byte header as fallback (most common) + // Unknown frame type - use 28-byte header as fallback headerSize = 28 } From cfbba5a52c1bcc3b0fde7b1488efbee9d1f8cca7 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 2 Jan 2026 16:52:55 +0100 Subject: [PATCH 172/241] comments --- pkg/wyze/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 5dc17e41..fb312a1f 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -123,7 +123,7 @@ func (c *Client) SetResolution(sd bool) error { return fmt.Errorf("wyze: K10056 send failed: %w", err) } - // Wait for response (SDK-style: accept any IOCtrl) + // Wait for K10057 response cmdID, data, err := c.conn.RecvIOCtrl(1 * time.Second) if err != nil { return err From 90c0b513e9ffc3350577482f7f8dc3451291877f Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 3 Jan 2026 15:41:37 +0100 Subject: [PATCH 173/241] cleanup --- pkg/wyze/client.go | 32 +++--- pkg/wyze/tutk/avframe.go | 10 +- pkg/wyze/tutk/conn.go | 206 +++++++++++++++++++-------------------- pkg/wyze/tutk/types.go | 18 ++-- 4 files changed, 134 insertions(+), 132 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index fb312a1f..80151a6b 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -409,7 +409,7 @@ func (c *Client) buildK10000() []byte { buf[0] = 'H' buf[1] = 'L' buf[2] = 5 - binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdAuth) + binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdAuth) return buf } @@ -420,15 +420,15 @@ func (c *Client) buildK10002(challenge []byte, status byte) []byte { buf[0] = 'H' buf[1] = 'L' buf[2] = 5 - binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdChallengeResp) + binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdChallengeResp) buf[6] = 22 // Payload length if len(response) >= 16 { - copy(buf[16:32], response[:16]) + copy(buf[16:], response[:16]) } if len(c.uid) >= 4 { - copy(buf[32:36], c.uid[:4]) + copy(buf[32:], c.uid[:4]) } buf[36] = 1 // Video flag (0 = disabled, 1 = enabled > will start video stream immediately) @@ -444,10 +444,10 @@ func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { buf := make([]byte, 18) buf[0] = 'H' buf[1] = 'L' - buf[2] = 5 // Version - binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdControlChannel) // 0x271a = 10010 - binary.LittleEndian.PutUint16(buf[6:8], 2) // Payload length = 2 - buf[16] = mediaType // 1=Video, 2=Audio, 3=ReturnAudio + buf[2] = 5 // Version + binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdControlChannel) // 0x271a = 10010 + binary.LittleEndian.PutUint16(buf[6:], 2) // Payload length = 2 + buf[16] = mediaType // 1=Video, 2=Audio, 3=ReturnAudio if enabled { buf[17] = 1 } else { @@ -463,11 +463,11 @@ func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { buf := make([]byte, 21) buf[0] = 'H' buf[1] = 'L' - buf[2] = 5 // Version - binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdSetResolution) // 0x2748 = 10056 - binary.LittleEndian.PutUint16(buf[6:8], 5) // Payload length = 5 - buf[16] = frameSize + 1 // 4 = HD - binary.LittleEndian.PutUint16(buf[17:19], bitrate) // 0x00f0 = 240 + buf[2] = 5 // Version + binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdSetResolution) // 0x2748 = 10056 + binary.LittleEndian.PutUint16(buf[6:], 5) // Payload length = 5 + buf[16] = frameSize + 1 // 4 = HD + binary.LittleEndian.PutUint16(buf[17:], bitrate) // 0x00f0 = 240 // buf[19], buf[20] = FPS (0 = auto) return buf } @@ -485,7 +485,7 @@ func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err er return nil, 0, fmt.Errorf("invalid HL magic: %x %x", data[0], data[1]) } - cmdID := binary.LittleEndian.Uint16(data[4:6]) + cmdID := binary.LittleEndian.Uint16(data[4:]) if cmdID != tutk.KCmdChallenge { return nil, 0, fmt.Errorf("expected cmdID 10001, got %d", cmdID) } @@ -510,8 +510,8 @@ func (c *Client) parseK10003(data []byte) (*tutk.AuthResponse, error) { return &tutk.AuthResponse{}, nil } - cmdID := binary.LittleEndian.Uint16(data[4:6]) - textLen := binary.LittleEndian.Uint16(data[6:8]) + cmdID := binary.LittleEndian.Uint16(data[4:]) + textLen := binary.LittleEndian.Uint16(data[6:]) if cmdID != tutk.KCmdAuthResult { return &tutk.AuthResponse{}, nil diff --git a/pkg/wyze/tutk/avframe.go b/pkg/wyze/tutk/avframe.go index 3c125bf7..e6c72313 100644 --- a/pkg/wyze/tutk/avframe.go +++ b/pkg/wyze/tutk/avframe.go @@ -93,17 +93,17 @@ func ParseFrameInfo(data []byte) *FrameInfo { fi := data[offset:] return &FrameInfo{ - CodecID: binary.LittleEndian.Uint16(fi[0:2]), + CodecID: binary.LittleEndian.Uint16(fi), Flags: fi[2], CamIndex: fi[3], OnlineNum: fi[4], Framerate: fi[5], FrameSize: fi[6], Bitrate: fi[7], - TimestampUS: binary.LittleEndian.Uint32(fi[8:12]), - Timestamp: binary.LittleEndian.Uint32(fi[12:16]), - PayloadSize: binary.LittleEndian.Uint32(fi[16:20]), - FrameNo: binary.LittleEndian.Uint32(fi[20:24]), + TimestampUS: binary.LittleEndian.Uint32(fi[8:]), + Timestamp: binary.LittleEndian.Uint32(fi[12:]), + PayloadSize: binary.LittleEndian.Uint32(fi[16:]), + FrameNo: binary.LittleEndian.Uint32(fi[20:]), } } diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index fa19a55f..e5c383fe 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -168,9 +168,9 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { if !ok { return io.EOF } - if len(data) >= 32 && binary.LittleEndian.Uint16(data[0:2]) == MagicAVLoginResp { + if len(data) >= 32 && binary.LittleEndian.Uint16(data) == MagicAVLoginResp { c.avLoginResp = &AVLoginResponse{ - ServerType: binary.LittleEndian.Uint32(data[4:8]), + ServerType: binary.LittleEndian.Uint32(data[4:]), Resend: int32(data[29]), TwoWayStreaming: int32(data[31]), } @@ -309,7 +309,7 @@ func (c *Conn) RecvIOCtrl(timeout time.Duration) (cmdID uint16, data []byte, err } // Parse cmdID from HL header at offset 4-5 if len(data) >= 6 { - cmdID = binary.LittleEndian.Uint16(data[4:6]) + cmdID = binary.LittleEndian.Uint16(data[4:]) } // Send ACK after receiving _ = c.sendACK() @@ -428,7 +428,7 @@ func (c *Conn) discoStage1() error { continue } - cmd := binary.LittleEndian.Uint16(data[8:10]) + cmd := binary.LittleEndian.Uint16(data[8:]) if c.verbose { fmt.Printf("[IOTC] Disco Stage 1: received cmd=0x%04x from %s\n", cmd, addr) } @@ -479,7 +479,7 @@ func (c *Conn) sessionSetup() error { continue } - cmd := binary.LittleEndian.Uint16(data[8:10]) + cmd := binary.LittleEndian.Uint16(data[8:]) if c.verbose { fmt.Printf("[IOTC] Session setup: received cmd=0x%04x from %s\n", cmd, addr) } @@ -555,7 +555,7 @@ func (c *Conn) iotcReader() { continue } - cmd := binary.LittleEndian.Uint16(data[8:10]) + cmd := binary.LittleEndian.Uint16(data[8:]) if cmd == CmdKeepaliveRes && len(data) > 16 { payload := data[16:] @@ -680,7 +680,7 @@ func (c *Conn) route(data []byte) { } // Check for control frame magic values first (uint16 LE) - magic := binary.LittleEndian.Uint16(data[0:2]) + magic := binary.LittleEndian.Uint16(data) switch magic { case MagicAVLoginResp: @@ -756,7 +756,7 @@ func (c *Conn) handleSpeakerAVLogin() error { } // Extract checksum from incoming request (bytes 20-23) - checksum := binary.LittleEndian.Uint32(buf[20:24]) + checksum := binary.LittleEndian.Uint32(buf[20:]) // Build AV Login response (60 bytes like SDK) resp := c.buildAVLoginResponse(checksum) @@ -852,7 +852,7 @@ func (c *Conn) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { // Has FrameInfo only if pkt_total == 1 (single-packet frame) headerSize = 36 if len(data) >= 22 { - pktTotal := uint16(data[20]) | uint16(data[21])<<8 + pktTotal := binary.LittleEndian.Uint16(data[20:]) if pktTotal == 1 { frameInfoSize = FrameInfoSize } @@ -1148,31 +1148,31 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, } // Type 0x09 "Single" - 36-byte header with full timestamp - frame[0] = ChannelAudio // 0x03 - frame[1] = FrameTypeStartAlt // 0x09 - binary.LittleEndian.PutUint16(frame[2:4], ProtocolVersion) // 0x000c + frame[0] = ChannelAudio // 0x03 + frame[1] = FrameTypeStartAlt // 0x09 + binary.LittleEndian.PutUint16(frame[2:], ProtocolVersion) // 0x000c - binary.LittleEndian.PutUint32(frame[4:8], c.audioTxSeq) - binary.LittleEndian.PutUint32(frame[8:12], timestampUS) // Timestamp in header + binary.LittleEndian.PutUint32(frame[4:], c.audioTxSeq) + binary.LittleEndian.PutUint32(frame[8:], timestampUS) // Timestamp in header // Flags at [12-15]: first frame uses 0x00000001, subsequent use 0x00100001 if c.audioTxFrameNo == 1 { - binary.LittleEndian.PutUint32(frame[12:16], 0x00000001) + binary.LittleEndian.PutUint32(frame[12:], 0x00000001) } else { - binary.LittleEndian.PutUint32(frame[12:16], 0x00100001) + binary.LittleEndian.PutUint32(frame[12:], 0x00100001) } // Inner header - frame[16] = ChannelAudio // 0x03 - frame[17] = FrameTypeEndSingle // 0x01 - binary.LittleEndian.PutUint16(frame[18:20], uint16(prevFrameNo)) // prev_frame_no (16-bit) + frame[16] = ChannelAudio // 0x03 + frame[17] = FrameTypeEndSingle // 0x01 + binary.LittleEndian.PutUint16(frame[18:], uint16(prevFrameNo)) // prev_frame_no (16-bit) - binary.LittleEndian.PutUint16(frame[20:22], 0x0001) // pkt_total = 1 - binary.LittleEndian.PutUint16(frame[22:24], 0x0010) // flags + binary.LittleEndian.PutUint16(frame[20:], 0x0001) // pkt_total = 1 + binary.LittleEndian.PutUint16(frame[22:], 0x0010) // flags - binary.LittleEndian.PutUint32(frame[24:28], uint32(totalPayload)) // payload size - binary.LittleEndian.PutUint32(frame[28:32], prevFrameNo) // prev_frame_no again (32-bit) - binary.LittleEndian.PutUint32(frame[32:36], c.audioTxFrameNo) // frame_no + binary.LittleEndian.PutUint32(frame[24:], uint32(totalPayload)) // payload size + binary.LittleEndian.PutUint32(frame[28:], prevFrameNo) // prev_frame_no again (32-bit) + binary.LittleEndian.PutUint32(frame[32:], c.audioTxFrameNo) // frame_no // Audio payload copy(frame[headerSize:], payload) @@ -1182,17 +1182,17 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, frameDurationMs := samplesPerFrame * 1000 / sampleRate fi := frame[headerSize+len(payload):] - binary.LittleEndian.PutUint16(fi[0:2], codec) // codec_id + binary.LittleEndian.PutUint16(fi[:], codec) // codec_id fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) // flags fi[3] = 0 // cam_index fi[4] = 1 // onlineNum = 1 fi[5] = 0 // tags // fi[6:12] = reserved (already 0) - binary.LittleEndian.PutUint32(fi[12:16], (c.audioTxFrameNo-1)*frameDurationMs) + binary.LittleEndian.PutUint32(fi[12:], (c.audioTxFrameNo-1)*frameDurationMs) if c.verbose { fmt.Printf("[AUDIO TX] FrameInfo: codec=0x%04x flags=0x%02x online=%d ts=%d\n", - codec, fi[2], fi[4], binary.LittleEndian.Uint32(fi[12:16])) + codec, fi[2], fi[4], binary.LittleEndian.Uint32(fi[12:])) } return frame @@ -1205,25 +1205,25 @@ func (c *Conn) buildDisco(stage byte) []byte { frame := make([]byte, frameSize) // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x02 // [3] Mode = Disco - binary.LittleEndian.PutUint16(frame[4:6], bodySize) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[8:10], CmdDiscoReq) // [8-9] Command = 0x0601 - binary.LittleEndian.PutUint16(frame[10:12], 0x0021) // [10-11] Flags + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x02 // [3] Mode = Disco + binary.LittleEndian.PutUint16(frame[4:], bodySize) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[8:], CmdDiscoReq) // [8-9] Command = 0x0601 + binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags // Body [16-87] body := frame[16:] - copy(body[0:20], c.uid) // [0-19] UID (20 bytes) + copy(body[:20], c.uid) // [0-19] UID (20 bytes) body[36] = 0x01 // [36] Unknown1 body[37] = 0x01 // [37] Unknown2 body[38] = 0x02 // [38] Unknown3 body[39] = 0x04 // [39] Unknown4 - copy(body[40:48], c.randomID) // [40-47] RandomID - body[48] = stage // [48] Stage (1=broadcast, 2=direct) + copy(body[40:], c.randomID) // [40-47] RandomID + body[48] = stage // [48] Stage (1=broadcast, 2=direct) if stage == 1 && len(c.authKey) > 0 { copy(body[58:], c.authKey) // [58-65] AuthKey @@ -1239,21 +1239,21 @@ func (c *Conn) buildSession() []byte { frame := make([]byte, frameSize) // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x02 // [3] Mode - binary.LittleEndian.PutUint16(frame[4:6], bodySize) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[8:10], CmdSessionReq) // [8-9] Command = 0x0402 - binary.LittleEndian.PutUint16(frame[10:12], 0x0033) // [10-11] Flags + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x02 // [3] Mode + binary.LittleEndian.PutUint16(frame[4:], bodySize) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[8:], CmdSessionReq) // [8-9] Command = 0x0402 + binary.LittleEndian.PutUint16(frame[10:], 0x0033) // [10-11] Flags // Body [16-51] body := frame[16:] - copy(body[0:20], c.uid) // [0-19] UID (20 bytes) - copy(body[20:28], c.randomID) // [20-27] RandomID + copy(body[:20], c.uid) // [0-19] UID (20 bytes) + copy(body[20:], c.randomID) // [20-27] RandomID ts := uint32(time.Now().Unix()) - binary.LittleEndian.PutUint32(body[32:36], ts) // [32-35] Timestamp + binary.LittleEndian.PutUint32(body[32:], ts) // [32-35] Timestamp return frame } @@ -1291,22 +1291,22 @@ func (c *Conn) buildDataTXChannel(payload []byte, channel byte) []byte { frame := make([]byte, frameSize) // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x0b // [3] Mode = Data - binary.LittleEndian.PutUint16(frame[4:6], uint16(bodySize)) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[6:8], c.iotcTxSeq) // [6-7] Sequence + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x0b // [3] Mode = Data + binary.LittleEndian.PutUint16(frame[4:], uint16(bodySize)) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[6:], c.iotcTxSeq) // [6-7] Sequence c.iotcTxSeq++ - binary.LittleEndian.PutUint16(frame[8:10], CmdDataTX) // [8-9] Command = 0x0407 - binary.LittleEndian.PutUint16(frame[10:12], 0x0021) // [10-11] Flags - copy(frame[12:14], c.randomID[:2]) // [12-13] RandomID[0:2] - frame[14] = channel // [14] Channel (0=Main, 1=Back) - frame[15] = 0x01 // [15] Marker + binary.LittleEndian.PutUint16(frame[8:], CmdDataTX) // [8-9] Command = 0x0407 + binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags + copy(frame[12:], c.randomID[:2]) // [12-13] RandomID[0:2] + frame[14] = channel // [14] Channel (0=Main, 1=Back) + frame[15] = 0x01 // [15] Marker // Sub-Header [16-27] - binary.LittleEndian.PutUint32(frame[16:20], 0x0000000c) // [16-19] Const - copy(frame[20:28], c.randomID[:8]) // [20-27] RandomID + binary.LittleEndian.PutUint32(frame[16:], 0x0000000c) // [16-19] Const + copy(frame[20:], c.randomID[:8]) // [20-27] RandomID // Payload [28+] copy(frame[28:], payload) @@ -1322,13 +1322,13 @@ func (c *Conn) buildACK() []byte { } ack := make([]byte, 24) - binary.LittleEndian.PutUint16(ack[0:2], MagicACK) // [0-1] Magic = 0x0009 - binary.LittleEndian.PutUint16(ack[2:4], ProtocolVersion) // [2-3] Version = 0x000C - binary.LittleEndian.PutUint32(ack[4:8], c.avTxSeq) // [4-7] TxSeq + binary.LittleEndian.PutUint16(ack[0:], MagicACK) // [0-1] Magic = 0x0009 + binary.LittleEndian.PutUint16(ack[2:], ProtocolVersion) // [2-3] Version = 0x000C + binary.LittleEndian.PutUint32(ack[4:], c.avTxSeq) // [4-7] TxSeq c.avTxSeq++ - binary.LittleEndian.PutUint32(ack[8:12], 0xffffffff) // [8-11] RxSeq (not used) - binary.LittleEndian.PutUint16(ack[12:14], c.ackFlags) // [12-13] AckFlags - binary.LittleEndian.PutUint32(ack[16:20], uint32(c.ackFlags)<<16) // [16-19] AckCounter + binary.LittleEndian.PutUint32(ack[8:], 0xffffffff) // [8-11] RxSeq (not used) + binary.LittleEndian.PutUint16(ack[12:], c.ackFlags) // [12-13] AckFlags + binary.LittleEndian.PutUint32(ack[16:], uint32(c.ackFlags)<<16) // [16-19] AckCounter return ack } @@ -1337,17 +1337,17 @@ func (c *Conn) buildKeepaliveResponse(incomingPayload []byte) []byte { frame := make([]byte, 24) // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x0a // [3] Mode - binary.LittleEndian.PutUint16(frame[4:6], 8) // [4-5] BodySize = 8 - binary.LittleEndian.PutUint16(frame[8:10], CmdKeepaliveReq) // [8-9] Command = 0x0427 - binary.LittleEndian.PutUint16(frame[10:12], 0x0021) // [10-11] Flags + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x0a // [3] Mode + binary.LittleEndian.PutUint16(frame[4:], 8) // [4-5] BodySize = 8 + binary.LittleEndian.PutUint16(frame[8:], CmdKeepaliveReq) // [8-9] Command = 0x0427 + binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags // Body [16-23]: Echo back incoming payload if len(incomingPayload) >= 8 { - copy(frame[16:24], incomingPayload[:8]) // [16-23] EchoPayload + copy(frame[16:], incomingPayload[:8]) // [16-23] EchoPayload } return frame @@ -1357,15 +1357,15 @@ func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID pkt := make([]byte, size) // Header - binary.LittleEndian.PutUint16(pkt[0:2], magic) - binary.LittleEndian.PutUint16(pkt[2:4], ProtocolVersion) + binary.LittleEndian.PutUint16(pkt, magic) + binary.LittleEndian.PutUint16(pkt[2:], ProtocolVersion) // bytes 4-15: reserved (zeros) // Payload info at offset 16 payloadSize := uint16(size - 24) // total - header(16) - random(4) - padding(4) - binary.LittleEndian.PutUint16(pkt[16:18], payloadSize) - binary.LittleEndian.PutUint16(pkt[18:20], flags) - copy(pkt[20:24], randomID[:4]) + binary.LittleEndian.PutUint16(pkt[16:], payloadSize) + binary.LittleEndian.PutUint16(pkt[18:], flags) + copy(pkt[20:], randomID[:4]) // Credentials (each field is 256 bytes) copy(pkt[24:], DefaultUser) // username at offset 24 (payload byte 0) @@ -1373,13 +1373,13 @@ func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID // Config section (AVClientStartInConfig) starts at offset 536 (= 24 + 256 + 256) // Layout: resend(4) + security_mode(4) + auth_type(4) + sync_recv_data(4) + ... - binary.LittleEndian.PutUint32(pkt[536:540], 0) // resend=0 - binary.LittleEndian.PutUint32(pkt[540:544], 2) // security_mode=2 (AV_SECURITY_AUTO) - binary.LittleEndian.PutUint32(pkt[544:548], 0) // auth_type=0 (AV_AUTH_PASSWORD) - binary.LittleEndian.PutUint32(pkt[548:552], 0) // sync_recv_data=0 - binary.LittleEndian.PutUint32(pkt[552:556], DefaultCapabilities) // capabilities - binary.LittleEndian.PutUint16(pkt[556:558], 0) // request_video_on_connect=0 - binary.LittleEndian.PutUint16(pkt[558:560], 0) // request_audio_on_connect=0 + binary.LittleEndian.PutUint32(pkt[536:], 0) // resend=0 + binary.LittleEndian.PutUint32(pkt[540:], 2) // security_mode=2 (AV_SECURITY_AUTO) + binary.LittleEndian.PutUint32(pkt[544:], 0) // auth_type=0 (AV_AUTH_PASSWORD) + binary.LittleEndian.PutUint32(pkt[548:], 0) // sync_recv_data=0 + binary.LittleEndian.PutUint32(pkt[552:], DefaultCapabilities) // capabilities + binary.LittleEndian.PutUint16(pkt[556:], 0) // request_video_on_connect=0 + binary.LittleEndian.PutUint16(pkt[558:], 0) // request_audio_on_connect=0 return pkt } @@ -1388,23 +1388,23 @@ func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { resp := make([]byte, 60) // Header - binary.LittleEndian.PutUint16(resp[0:2], 0x2100) // Magic - binary.LittleEndian.PutUint16(resp[2:4], 0x000c) // Version - resp[4] = 0x10 // Response type (success) + binary.LittleEndian.PutUint16(resp, 0x2100) // Magic + binary.LittleEndian.PutUint16(resp[2:], 0x000c) // Version + resp[4] = 0x10 // Response type (success) // Payload info - binary.LittleEndian.PutUint32(resp[16:20], 0x24) // Payload size = 36 - binary.LittleEndian.PutUint32(resp[20:24], checksum) // Echo checksum from request! + binary.LittleEndian.PutUint32(resp[16:], 0x24) // Payload size = 36 + binary.LittleEndian.PutUint32(resp[20:], checksum) // Echo checksum from request! // Payload (36 bytes starting at offset 24) resp[29] = 0x01 // EnableFlag resp[31] = 0x01 // TwoWayStreaming - binary.LittleEndian.PutUint32(resp[36:40], 0x04) // BufferConfig - binary.LittleEndian.PutUint32(resp[40:44], 0x001f07fb) // Capabilities + binary.LittleEndian.PutUint32(resp[36:], 0x04) // BufferConfig + binary.LittleEndian.PutUint32(resp[40:], 0x001f07fb) // Capabilities - binary.LittleEndian.PutUint16(resp[54:56], 0x0003) // ChannelInfo1 - binary.LittleEndian.PutUint16(resp[56:58], 0x0002) // ChannelInfo2 + binary.LittleEndian.PutUint16(resp[54:], 0x0003) // ChannelInfo1 + binary.LittleEndian.PutUint16(resp[56:], 0x0002) // ChannelInfo2 return resp } @@ -1414,32 +1414,32 @@ func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { frame := make([]byte, headerSize+len(payload)) // Magic (same as protocol version for IOCtrl frames) - binary.LittleEndian.PutUint16(frame[0:2], ProtocolVersion) + binary.LittleEndian.PutUint16(frame, ProtocolVersion) // Version - binary.LittleEndian.PutUint16(frame[2:4], ProtocolVersion) + binary.LittleEndian.PutUint16(frame[2:], ProtocolVersion) // AVSeq (4-7) seq := c.avTxSeq c.avTxSeq++ - binary.LittleEndian.PutUint32(frame[4:8], seq) + binary.LittleEndian.PutUint32(frame[4:], seq) // Bytes 8-15: reserved // Channel: MagicIOCtrl (0x7000) for IOCtrl frames - binary.LittleEndian.PutUint16(frame[16:18], MagicIOCtrl) + binary.LittleEndian.PutUint16(frame[16:], MagicIOCtrl) // SubChannel (18-19): increments with each IOCtrl command sent - binary.LittleEndian.PutUint16(frame[18:20], c.ioctrlSeq) + binary.LittleEndian.PutUint16(frame[18:], c.ioctrlSeq) // IOCTLSeq (20-23): always 1 - binary.LittleEndian.PutUint32(frame[20:24], 1) + binary.LittleEndian.PutUint32(frame[20:], 1) // PayloadSize (24-27): payload + 4 bytes padding - binary.LittleEndian.PutUint32(frame[24:28], uint32(len(payload)+4)) + binary.LittleEndian.PutUint32(frame[24:], uint32(len(payload)+4)) // Flag (28-31): matches subChannel in SDK - binary.LittleEndian.PutUint32(frame[28:32], uint32(c.ioctrlSeq)) + binary.LittleEndian.PutUint32(frame[28:], uint32(c.ioctrlSeq)) // Bytes 32-36: reserved // Byte 37: 0x01 diff --git a/pkg/wyze/tutk/types.go b/pkg/wyze/tutk/types.go index 3596a47e..4ba95f01 100644 --- a/pkg/wyze/tutk/types.go +++ b/pkg/wyze/tutk/types.go @@ -1,5 +1,7 @@ package tutk +import "encoding/binary" + const ( // Start packets - first fragment of a frame // 0x08: Extended start (36-byte header, no FrameInfo) @@ -114,10 +116,10 @@ func ParsePacketHeader(data []byte) *PacketHeader { // [14-15] pkt_idx OR 0x0028 (FrameInfo marker) - ONLY 0x0028 in End packets! // [16-17] payload_size // [24-27] frame_no (uint32) - hdr.PktTotal = uint16(data[12]) | uint16(data[13])<<8 - pktIdxOrMarker := uint16(data[14]) | uint16(data[15])<<8 - hdr.PayloadSize = uint16(data[16]) | uint16(data[17])<<8 - hdr.FrameNo = uint32(data[24]) | uint32(data[25])<<8 | uint32(data[26])<<16 | uint32(data[27])<<24 + hdr.PktTotal = binary.LittleEndian.Uint16(data[12:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[14:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[24:]) // 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40 if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 { @@ -135,10 +137,10 @@ func ParsePacketHeader(data []byte) *PacketHeader { // [24-25] payload_size // [32-35] frame_no (uint32) - GLOBAL frame counter, matches 28-byte [24-27] // NOTE: [18-19] is channel-specific frame index, NOT used for reassembly! - hdr.PktTotal = uint16(data[20]) | uint16(data[21])<<8 - pktIdxOrMarker := uint16(data[22]) | uint16(data[23])<<8 - hdr.PayloadSize = uint16(data[24]) | uint16(data[25])<<8 - hdr.FrameNo = uint32(data[32]) | uint32(data[33])<<8 | uint32(data[34])<<16 | uint32(data[35])<<24 + hdr.PktTotal = binary.LittleEndian.Uint16(data[20:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[22:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[32:]) // 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40 if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 { From 263579fa01d8d6a77b04f4ea50d24e0c46119645 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 3 Jan 2026 16:12:37 +0100 Subject: [PATCH 174/241] cleanup --- pkg/wyze/producer.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index af6c25f1..84a927ca 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -55,8 +55,6 @@ func NewProducer(rawURL string) (*Producer, error) { } func (p *Producer) Start() error { - defer p.client.Close() - for { _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) pkt, err := p.client.ReadPacket() From 3207f9e78325e5e86652e126c43def7a32ddc55e Mon Sep 17 00:00:00 2001 From: Simon Cheng Date: Sun, 4 Jan 2026 18:45:03 -0500 Subject: [PATCH 175/241] fix typo for inability to set channels for backchannel --- pkg/core/codec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/core/codec.go b/pkg/core/codec.go index f38275e8..11276bc7 100644 --- a/pkg/core/codec.go +++ b/pkg/core/codec.go @@ -277,7 +277,7 @@ func ParseCodecString(s string) *Codec { codec.ClockRate = uint32(Atoi(ss[1])) } if len(ss) >= 3 { - codec.Channels = uint8(Atoi(ss[1])) + codec.Channels = uint8(Atoi(ss[2])) } return &codec From e960f90a97f8161d9d4ce73b12b8f3ec18b35bc3 Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 5 Jan 2026 09:30:03 +0300 Subject: [PATCH 176/241] Add formats and protocols to readme --- README.md | 14 ++++++++++ pkg/README.md | 74 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5c402925..46fcaec8 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,20 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - [two-way audio](#two-way-audio) for some cameras - can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary) +**Supported Formats** - describes the communication API: authorization, encryption, command set, structure of media packets + +- devices: `alsa` (Linux audio), `v4l2` (Linux video) +- files: `adts`, `flv`, `h264`, `hevc`, `hls`, `mjpeg`, `mpegts`, `mp4`, `wav` +- network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `y2m` (yuv4mpegpipe) +- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `xiaomi` (Mi Home) +- webrtc related: `creality`, `kinesis` (Amazon), `openipc`, `switchbot`, `whep`, `whip`, `wyze` +- other: `ascii`, `echo`, `exec`, `expr`, `ffmpeg` + +**Supported Protocols** - describes the transport for data transmission + +- public: `http`, `pipe`, `rtmp`, `rtsp`, `tcp`, `udp`, `webrtc`, `ws` (WebSocket) +- private: `cs2` (PPPP), `hap` and `hds` (HomeKit), `tutk` (P2P) + **Inspired by:** - series of streaming projects from [@deepch](https://github.com/deepch) diff --git a/pkg/README.md b/pkg/README.md index e2759638..b027ed90 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -10,35 +10,51 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent - Codecs can be incoming - **Recevers codecs** - Codecs can be outgoing (two way audio) - **Senders codecs** -| Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example | -|--------------|------------------|-------------------|------------------------------|--------------------|---------------| -| adts | http,tcp,pipe | http | aac | | `http:` | -| alsa | pipe | | | pcm | `alsa:` | -| bubble | http | | h264,hevc,pcm_alaw | | `bubble:` | -| dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` | -| flv | http,tcp,pipe | http | h264,aac | | `http:` | -| gopro | http+udp | | TODO | | `gopro:` | -| hass/webrtc | ws+udp,tcp | | TODO | | `hass:` | -| hls/mpegts | http | | h264,h265,aac,opus | | `http:` | -| homekit | homekit+udp | | h264,eld* | | `homekit:` | -| isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` | -| ivideon | ws | | h264 | | `ivideon:` | -| kasa | http | | h264,pcm_mulaw | | `kasa:` | -| h264 | http,tcp,pipe | http | h264 | | `http:` | -| hevc | http,tcp,pipe | http | hevc | | `http:` | -| mjpeg | http,tcp,pipe | http | mjpeg | | `http:` | -| mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` | -| mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` | -| nest/webrtc | http+udp | | TODO | | `nest:` | -| roborock | mqtt+udp | | h264,opus | opus | `roborock:` | -| rtmp | rtmp | rtmp | h264,aac | | `rtmp:` | -| rtsp | rtsp+tcp,ws | rtsp+tcp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` | -| stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` | -| tapo | http | | h264,pcma | pcm_alaw | `tapo:` | -| wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` | -| webrtc* | TODO | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` | -| webtorrent | TODO | TODO | TODO | TODO | `webtorrent:` | -| yuv4mpegpipe | http,tcp,pipe | http | rawvideo | | `http:` | +| Group | Format | Protocols | Ingress | Recevers codecs | Senders codecs | Example | +|------------|------------|---------------|---------|------------------------------|--------------------|---------------| +| Devices | alsa | pipe | | | pcm | `alsa:` | +| Devices | v4l2 | pipe | | | | | +| Files | adts | http,tcp,pipe | http | aac | | `http:` | +| Files | flv | http,tcp,pipe | http | h264,aac | | `http:` | +| Files | h264 | http,tcp,pipe | http | h264 | | `http:` | +| Files | hevc | http,tcp,pipe | http | hevc | | `http:` | +| Files | hls | http | | h264,h265,aac,opus | | `http:` | +| Files | mjpeg | http,tcp,pipe | http | mjpeg | | `http:` | +| Files | mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` | +| Files | mp4 | | | | | | +| Files | wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` | +| Net (pub) | mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` | +| Net (pub) | onvif | rtsp | | | | | +| Net (pub) | rtmp | rtmp | rtmp | h264,aac | | `rtmp:` | +| Net (pub) | rtsp | rtsp,ws | rtsp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` | +| Net (pub) | webrtc* | webrtc | webrtc | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` | +| Net (pub) | y4m | http,tcp,pipe | http | rawvideo | | `http:` | +| Net (priv) | bubble | http | | h264,hevc,pcm_alaw | | `bubble:` | +| Net (priv) | doorbird | http | | | | | +| Net (priv) | dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` | +| Net (priv) | eseecloud | http | | | | | +| Net (priv) | gopro | udp | | TODO | | `gopro:` | +| Net (priv) | hass | webrtc | | TODO | | `hass:` | +| Net (priv) | homekit | hap | | h264,eld* | | `homekit:` | +| Net (priv) | isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` | +| Net (priv) | kasa | http | | h264,pcm_mulaw | | `kasa:` | +| Net (priv) | nest | rtsp,webrtc | | TODO | | `nest:` | +| Net (priv) | ring | webrtc | | | | | +| Net (priv) | roborock | webrtc | | h264,opus | opus | `roborock:` | +| Net (priv) | tapo | http | | h264,pcma | pcm_alaw | `tapo:` | +| Net (priv) | tuya | webrtc | | | | | +| Net (priv) | vigi | http | | | | | +| Net (priv) | webtorrent | webrtc | TODO | TODO | TODO | `webtorrent:` | +| Net (priv) | xiaomi* | cs2,tutk | | | | | +| Services | flussonic | ws | | | | | +| Services | ivideon | ws | | h264 | | `ivideon:` | +| Services | yandex | webrtc | | | | | +| Other | ascii | http | | | | | +| Other | echo | * | | | | | +| Other | exec | pipe,rtsp | | | | | +| Other | expr | * | | | | | +| Other | ffmpeg | pipe,rtsp | | | | | +| Other | stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` | - **eld** - rare variant of aac codec - **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le From c6733bf4f184e356a8c6dc85fc16daa495b0568b Mon Sep 17 00:00:00 2001 From: oeiber <46045177+oeiber@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:20:40 +0100 Subject: [PATCH 177/241] Update README with Doorbird support details Added information about Doorbird device support and user permissions. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 46fcaec8..902cb4f8 100644 --- a/README.md +++ b/README.md @@ -728,6 +728,9 @@ If you have a graphic PIN for your vacuum, add it as a numeric PIN (lines: 123, *[New in v1.9.11](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)* This source type supports Doorbird devices including MJPEG stream, audio stream as well as two-way audio. +It is recommended to create a sepearate user within your doorbird setup for go2rtc. Minimum permissions for the user are: +- Watch always +- API operator ```yaml streams: From 84e13d9d2242716aad9e246be5855e951c11b709 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 Jan 2026 02:42:47 +0100 Subject: [PATCH 178/241] Add support for latest wyze firmware --- pkg/wyze/client.go | 2 +- pkg/wyze/tutk/conn.go | 478 ++++++++++++++++++++++++++++--------- pkg/wyze/tutk/constants.go | 44 +++- 3 files changed, 399 insertions(+), 125 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 80151a6b..69514d0f 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -295,7 +295,7 @@ func (c *Client) connect() error { host = host[:idx] } - conn, err := tutk.Dial(host, c.uid, c.authKey, c.enr, c.verbose) + conn, err := tutk.Dial(host, c.uid, c.authKey, c.enr, c.mac, c.verbose) if err != nil { return fmt.Errorf("wyze: connect failed: %w", err) } diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index e5c383fe..f58c3dc8 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -2,7 +2,9 @@ package tutk import ( "context" + "crypto/hmac" "crypto/rand" + "crypto/sha1" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -44,10 +46,15 @@ type Conn struct { uid string authKey string enr string + mac string // MAC address for auth key calculation psk []byte iotcTxSeq uint16 avLoginResp *AVLoginResponse + useNewProto bool // true if camera uses NEW protocol + newProtoTicket uint16 // ticket from camera response + sessionID []byte // 8-byte session ID for NEW protocol + // DTLS - Main Channel (we = Client) mainConn *dtls.Conn mainBuf chan []byte @@ -83,7 +90,7 @@ type Conn struct { verbose bool } -func Dial(host, uid, authKey, enr string, verbose bool) (*Conn, error) { +func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { conn, err := net.ListenUDP("udp", nil) if err != nil { return nil, err @@ -104,6 +111,7 @@ func Dial(host, uid, authKey, enr string, verbose bool) (*Conn, error) { uid: uid, authKey: authKey, enr: enr, + mac: mac, psk: psk, verbose: verbose, ctx: ctx, @@ -376,27 +384,21 @@ func (c *Conn) Close() error { func (c *Conn) discovery() error { _ = c.udpConn.SetDeadline(time.Now().Add(10 * time.Second)) - if err := c.discoStage1(); err != nil { - return fmt.Errorf("disco stage 1: %w", err) - } + // Generate 8-byte session ID for NEW protocol + c.sessionID = make([]byte, 8) + rand.Read(c.sessionID[:2]) + copy(c.sessionID[2:], []byte{0x76, 0x0a, 0x9d, 0x24, 0x88, 0xba}) - c.discoStage2() - - if err := c.sessionSetup(); err != nil { - return fmt.Errorf("session setup: %w", err) - } - - _ = c.udpConn.SetDeadline(time.Time{}) - return nil -} - -func (c *Conn) discoStage1() error { - pkt := c.buildDisco(1) - encrypted := crypto.TransCodeBlob(pkt) + // Build discovery packets for both protocols + oldDiscoPkt := crypto.TransCodeBlob(c.buildDisco(1)) // OLD protocol (TransCode encoded) + newDiscoPkt := c.buildNewProtoPacket(0, 0, false) // NEW protocol (0xCC51, cmd=0x1002) if c.verbose { - fmt.Printf("[IOTC] Disco Stage 1: timeout=%v interval=%v broadcasts=%d\n", + fmt.Printf("[DISCO] Unified discovery: timeout=%v interval=%v broadcasts=%d\n", DiscoTimeout, DiscoInterval, len(c.broadcastAddrs)) + fmt.Printf("[DISCO] SessionID=%s\n", hex.EncodeToString(c.sessionID)) + fmt.Printf("[OLD] TX Discovery packet (%d bytes):\n%s", len(oldDiscoPkt), hexDump(crypto.ReverseTransCodeBlob(oldDiscoPkt))) + fmt.Printf("[NEW] TX Discovery packet (%d bytes):\n%s", len(newDiscoPkt), hexDump(newDiscoPkt)) } deadline := time.Now().Add(DiscoTimeout) @@ -404,12 +406,11 @@ func (c *Conn) discoStage1() error { buf := make([]byte, MaxPacketSize) for time.Now().Before(deadline) { + // Send both discovery packets periodically if time.Since(lastSend) >= DiscoInterval { for _, bcast := range c.broadcastAddrs { - c.udpConn.WriteToUDP(encrypted, bcast) - if c.verbose { - fmt.Printf("[IOTC] Disco Stage 1: sent to %s\n", bcast) - } + c.udpConn.WriteToUDP(oldDiscoPkt, bcast) // OLD protocol + c.udpConn.WriteToUDP(newDiscoPkt, bcast) // NEW protocol } lastSend = time.Now() } @@ -423,6 +424,97 @@ func (c *Conn) discoStage1() error { return err } + // Check for NEW protocol response (0xCC51 magic) + if n >= 12 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + cmd := binary.LittleEndian.Uint16(buf[4:]) + dir := binary.LittleEndian.Uint16(buf[8:]) + + if c.verbose { + fmt.Printf("[NEW] RX %d bytes <- %s (cmd=0x%04x dir=0x%04x)\n", n, addr, cmd, dir) + } + + // Handle cmd=0x1002 seq=1 discovery response + if cmd == CmdNewProtoDiscovery && n >= NewProtoPacketSize && dir == 0xFFFF { + seq := binary.LittleEndian.Uint16(buf[12:]) + ticket := binary.LittleEndian.Uint16(buf[14:]) + + if seq == 1 { + c.addr = addr + c.newProtoTicket = ticket + c.useNewProto = true + + if n >= 24 { + copy(c.sessionID, buf[16:24]) + } + + if c.verbose { + fmt.Printf("[NEW] Camera detected! ticket=0x%04x sessionID=%s\n", + ticket, hex.EncodeToString(c.sessionID)) + } + + _ = c.udpConn.SetDeadline(time.Time{}) + return c.newProtoComplete() + } + } + continue + } + + // Check for OLD protocol response (TransCode encoded) + data := crypto.ReverseTransCodeBlob(buf[:n]) + if len(data) >= 16 { + cmd := binary.LittleEndian.Uint16(data[8:]) + + if c.verbose { + fmt.Printf("[OLD] RX %d bytes <- %s (cmd=0x%04x)\n%s", n, addr, cmd, hexDump(data)) + } + + if cmd == CmdDiscoRes { + c.addr = addr + c.useNewProto = false + + if c.verbose { + fmt.Printf("[OLD] Camera detected at %s\n", addr) + } + + _ = c.udpConn.SetDeadline(time.Time{}) + return c.oldProtoComplete() + } + } + } + + _ = c.udpConn.SetDeadline(time.Time{}) + return fmt.Errorf("discovery timeout - no camera response") +} + +func (c *Conn) oldProtoComplete() error { + // Stage 2 + pkt := c.buildDisco(2) + if c.verbose { + fmt.Printf("[OLD] TX Stage 2 Discovery (%d bytes):\n%s", len(pkt), hexDump(pkt)) + } + encrypted := crypto.TransCodeBlob(pkt) + c.udpConn.WriteToUDP(encrypted, c.addr) + time.Sleep(100 * time.Millisecond) + + // Session setup + sessionPkt := c.buildSession() + if _, err := c.sendEncrypted(sessionPkt); err != nil { + return err + } + + buf := make([]byte, MaxPacketSize) + deadline := time.Now().Add(SessionTimeout) + + for time.Now().Before(deadline) { + c.udpConn.SetReadDeadline(time.Now().Add(ReadWaitInterval)) + n, addr, err := c.udpConn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return err + } + data := crypto.ReverseTransCodeBlob(buf[:n]) if len(data) < 16 { continue @@ -430,68 +522,64 @@ func (c *Conn) discoStage1() error { cmd := binary.LittleEndian.Uint16(data[8:]) if c.verbose { - fmt.Printf("[IOTC] Disco Stage 1: received cmd=0x%04x from %s\n", cmd, addr) + fmt.Printf("[OLD] RX %d bytes (cmd=0x%04x)\n%s", len(data), cmd, hexDump(data)) } - - if cmd == CmdDiscoRes { - c.addr = addr - if c.verbose { - fmt.Printf("[IOTC] Disco Stage 1: success! Camera at %s\n", addr) - } - return nil - } - } - - return fmt.Errorf("timeout after %v", DiscoTimeout) -} - -func (c *Conn) discoStage2() { - pkt := c.buildDisco(2) - encrypted := crypto.TransCodeBlob(pkt) - _, _ = c.udpConn.WriteToUDP(encrypted, c.addr) - time.Sleep(100 * time.Millisecond) -} - -func (c *Conn) sessionSetup() error { - pkt := c.buildSession() - - if c.verbose { - fmt.Printf("[IOTC] Session setup: target=%s\n", c.addr) - } - - // Send request - if _, err := c.sendEncrypted(pkt); err != nil { - return err - } - - // Wait for response - buf := make([]byte, MaxPacketSize) - c.udpConn.SetReadDeadline(time.Now().Add(SessionTimeout)) - - for { - n, addr, err := c.udpConn.ReadFromUDP(buf) - if err != nil { - return fmt.Errorf("timeout: %w", err) - } - - data := crypto.ReverseTransCodeBlob(buf[:n]) - if len(data) < 16 { - continue - } - - cmd := binary.LittleEndian.Uint16(data[8:]) - if c.verbose { - fmt.Printf("[IOTC] Session setup: received cmd=0x%04x from %s\n", cmd, addr) - } - if cmd == CmdSessionRes { c.addr = addr if c.verbose { - fmt.Printf("[IOTC] Session setup: success!\n") + fmt.Printf("[OLD] Session setup complete!\n") } return nil } } + + return fmt.Errorf("OLD protocol session timeout") +} + +func (c *Conn) newProtoComplete() error { + pkt2 := c.buildNewProtoPacket(2, c.newProtoTicket, false) + + if c.verbose { + fmt.Printf("[NEW] TX seq=2 with ticket=0x%04x (%d bytes):\n%s", c.newProtoTicket, len(pkt2), hexDump(pkt2)) + } + + c.udpConn.WriteToUDP(pkt2, c.addr) + + buf := make([]byte, MaxPacketSize) + deadline := time.Now().Add(SessionTimeout) + lastSend := time.Now() + + for time.Now().Before(deadline) { + if time.Since(lastSend) >= DiscoInterval { + c.udpConn.WriteToUDP(pkt2, c.addr) + lastSend = time.Now() + } + + c.udpConn.SetReadDeadline(time.Now().Add(ReadWaitInterval)) + n, addr, err := c.udpConn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return err + } + + if n >= NewProtoPacketSize && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + cmd := binary.LittleEndian.Uint16(buf[4:]) + dir := binary.LittleEndian.Uint16(buf[8:]) + seq := binary.LittleEndian.Uint16(buf[12:]) + + if cmd == CmdNewProtoDiscovery && dir == 0xFFFF && seq == 3 { + if c.verbose { + fmt.Printf("[NEW] seq=3 received, discovery complete!\n") + } + c.addr = addr + return nil + } + } + } + + return fmt.Errorf("NEW protocol handshake timeout waiting for seq=3") } func (c *Conn) connect() error { @@ -511,7 +599,7 @@ func (c *Conn) connect() error { conn, err := dtls.Client(adapter, c.addr, config) if err != nil { - return fmt.Errorf("dtls: client handshake failed: %w", err) + return fmt.Errorf("dtls: client create failed: %w", err) } c.mu.Lock() @@ -519,7 +607,7 @@ func (c *Conn) connect() error { c.mu.Unlock() if c.verbose { - fmt.Printf("[DTLS] Client handshake complete on channel %d\n", IOTCChannelMain) + fmt.Printf("[DTLS] Client created for channel %d\n", IOTCChannelMain) } return nil @@ -546,11 +634,19 @@ func (c *Conn) iotcReader() { return } - data := crypto.ReverseTransCodeBlob(buf[:n]) if addr.Port != c.addr.Port || !addr.IP.Equal(c.addr.IP) { c.addr = addr } + // Check for NEW protocol (0xCC51 magic at start) + if c.useNewProto && n >= 2 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + c.handleNewProtoPacket(buf[:n]) + continue + } + + // OLD protocol: TransCode decode + data := crypto.ReverseTransCodeBlob(buf[:n]) + if len(data) < 16 { continue } @@ -603,30 +699,89 @@ func (c *Conn) iotcReader() { copy(dataCopy, dtlsPayload) // Route based on channel - var buf chan []byte + var chBuf chan []byte switch channel { case IOTCChannelMain: - buf = c.mainBuf + chBuf = c.mainBuf case IOTCChannelBack: - buf = c.speakerBuf + chBuf = c.speakerBuf } - if buf != nil { + if chBuf != nil { select { - case buf <- dataCopy: + case chBuf <- dataCopy: default: // Drop oldest if full select { - case <-buf: + case <-chBuf: default: } - buf <- dataCopy + chBuf <- dataCopy } } } } } +func (c *Conn) handleNewProtoPacket(data []byte) { + if len(data) < 16 { + return + } + + cmd := binary.LittleEndian.Uint16(data[4:]) + seq := binary.LittleEndian.Uint16(data[12:]) + ticket := binary.LittleEndian.Uint16(data[14:]) + + if c.verbose { + fmt.Printf("[NEW] RX cmd=0x%04x seq=%d ticket=0x%04x len=%d\n", cmd, seq, ticket, len(data)) + fmt.Printf("[NEW] RX full packet:\n%s", hexDump(data)) + } + + // Handle DTLS data (cmd=0x1502) + if cmd == CmdNewProtoDTLS && len(data) > NewProtoHeaderSize+NewProtoAuthSize { + // Packet structure: [0:28] header, [28:N-20] DTLS payload, [N-20:N] auth bytes + // We need to strip the auth bytes at the end + dtlsPayload := data[NewProtoHeaderSize : len(data)-NewProtoAuthSize] + + // Channel is in the 4-byte field at offset 24 (value 1=main, 2=back) + channelFlag := binary.LittleEndian.Uint32(data[24:]) + var channel byte + if channelFlag >= 1 { + channel = byte(channelFlag - 1) // Convert back to 0=main, 1=back + } + + if c.verbose && len(dtlsPayload) >= 1 { + fmt.Printf("[NEW] DTLS RX ch=%d contentType=%d len=%d (stripped 20 auth bytes)\n%s", channel, dtlsPayload[0], len(dtlsPayload), hexDump(dtlsPayload)) + } + + // Copy data since buffer is reused + dataCopy := make([]byte, len(dtlsPayload)) + copy(dataCopy, dtlsPayload) + + // Route based on channel + var chBuf chan []byte + switch channel { + case IOTCChannelMain: + chBuf = c.mainBuf + case IOTCChannelBack: + chBuf = c.speakerBuf + } + + if chBuf != nil { + select { + case chBuf <- dataCopy: + default: + // Drop oldest if full + select { + case <-chBuf: + default: + } + chBuf <- dataCopy + } + } + } +} + func (c *Conn) worker() { defer c.wg.Done() @@ -1122,15 +1277,97 @@ func (c *Conn) sendACK() error { } func (c *Conn) sendIOTC(payload []byte, channel byte) (int, error) { + if c.useNewProto { + // NEW Protocol: send DTLS data in 0xCC51 frame with cmd=0x1502 + frame := c.buildNewProtoDTLS(payload, channel) + if c.verbose { + fmt.Printf("\n>>> TX %d bytes (DTLS cmd=0x1502 ch=%d)\n%s", + len(frame), channel, hexDump(frame)) + } + return c.udpConn.WriteToUDP(frame, c.addr) + } + // OLD Protocol: TransCode encrypted 0x0407 frame frame := c.buildDataTXChannel(payload, channel) return c.sendEncrypted(frame) } func (c *Conn) sendEncrypted(data []byte) (int, error) { + if c.verbose { + fmt.Printf("[OLD] TX %d bytes\n%s", len(data), hexDump(data)) + } encrypted := crypto.TransCodeBlob(data) return c.udpConn.WriteToUDP(encrypted, c.addr) } +func (c *Conn) buildNewProtoPacket(seq, ticket uint16, isResponse bool) []byte { + pkt := make([]byte, NewProtoPacketSize) + + // Header [0:12] + binary.LittleEndian.PutUint16(pkt[0:], MagicNewProto) // Magic 0xCC51 + binary.LittleEndian.PutUint16(pkt[2:], 0x0000) // Flags + binary.LittleEndian.PutUint16(pkt[4:], CmdNewProtoDiscovery) // Command 0x1002 + binary.LittleEndian.PutUint16(pkt[6:], NewProtoPayloadSize) // Payload size (40 bytes) + + if isResponse { + binary.LittleEndian.PutUint16(pkt[8:], 0xFFFF) // Direction (response) + } else { + binary.LittleEndian.PutUint16(pkt[8:], 0x0000) // Direction (request) + } + + binary.LittleEndian.PutUint16(pkt[10:], 0x0000) // Reserved + binary.LittleEndian.PutUint16(pkt[12:], seq) // Sequence + binary.LittleEndian.PutUint16(pkt[14:], ticket) // Ticket + + // SessionID [16:24] + copy(pkt[16:24], c.sessionID) + + // Capabilities [24:32] - SDK version 4.3.8.0 + copy(pkt[24:32], []byte{0x00, 0x08, 0x03, 0x04, 0x1d, 0x00, 0x00, 0x00}) + + // Auth Bytes [32:52] - HMAC-SHA1(UID+AuthKey, header[0:32]) + authKey := crypto.CalculateAuthKey(c.enr, c.mac) + key := append([]byte(c.uid), authKey...) + + h := hmac.New(sha1.New, key) + h.Write(pkt[:32]) + authBytes := h.Sum(nil) + copy(pkt[32:52], authBytes) + + return pkt +} + +func (c *Conn) buildNewProtoDTLS(payload []byte, channel byte) []byte { + payloadSize := uint16(16 + len(payload) + NewProtoAuthSize) + pkt := make([]byte, NewProtoHeaderSize+len(payload)+NewProtoAuthSize) + + if c.verbose { + fmt.Printf("[DTLS PKT] payload=%d, payloadSize=%d (0x%04x), pktLen=%d\n", + len(payload), payloadSize, payloadSize, len(pkt)) + } + + binary.LittleEndian.PutUint16(pkt[0:], MagicNewProto) + binary.LittleEndian.PutUint16(pkt[2:], 0x0000) + binary.LittleEndian.PutUint16(pkt[4:], CmdNewProtoDTLS) + binary.LittleEndian.PutUint16(pkt[6:], payloadSize) + binary.LittleEndian.PutUint16(pkt[8:], 0x0000) // Direction (request) + binary.LittleEndian.PutUint16(pkt[10:], 0x0000) // Reserved + binary.LittleEndian.PutUint16(pkt[12:], 0x0010) // DTLS uses fixed seq=16 (0x10) + binary.LittleEndian.PutUint16(pkt[14:], c.newProtoTicket) + copy(pkt[16:24], c.sessionID) + binary.LittleEndian.PutUint32(pkt[24:], uint32(channel)+1) // Channel flag (main=1, back=2) + copy(pkt[NewProtoHeaderSize:], payload) + + // Add Auth bytes at the end: HMAC-SHA1(UID+AuthKey, packet_header) + authKey := crypto.CalculateAuthKey(c.enr, c.mac) + key := append([]byte(c.uid), authKey...) + h := hmac.New(sha1.New, key) + h.Write(pkt[:NewProtoHeaderSize]) // Hash the header portion + authBytes := h.Sum(nil) + copy(pkt[NewProtoHeaderSize+len(payload):], authBytes) + + return pkt +} + func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { const frameInfoSize = 16 const headerSize = 36 @@ -1199,23 +1436,20 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, } func (c *Conn) buildDisco(stage byte) []byte { - const bodySize = 72 - const frameSize = 16 + bodySize - - frame := make([]byte, frameSize) + frame := make([]byte, OldProtoDiscoPacketSize) // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x02 // [3] Mode = Disco - binary.LittleEndian.PutUint16(frame[4:], bodySize) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[8:], CmdDiscoReq) // [8-9] Command = 0x0601 - binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x02 // [3] Mode = Disco + binary.LittleEndian.PutUint16(frame[4:], OldProtoDiscoBodySize) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[8:], CmdDiscoReq) // [8-9] Command = 0x0601 + binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags // Body [16-87] - body := frame[16:] - copy(body[:20], c.uid) // [0-19] UID (20 bytes) + body := frame[OldProtoHeaderSize:] + copy(body[:UIDSize], c.uid) // [0-19] UID (20 bytes) body[36] = 0x01 // [36] Unknown1 body[37] = 0x01 // [37] Unknown2 @@ -1233,24 +1467,21 @@ func (c *Conn) buildDisco(stage byte) []byte { } func (c *Conn) buildSession() []byte { - const bodySize = 36 - const frameSize = 16 + bodySize - - frame := make([]byte, frameSize) + frame := make([]byte, OldProtoSessionPacketSize) // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x02 // [3] Mode - binary.LittleEndian.PutUint16(frame[4:], bodySize) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[8:], CmdSessionReq) // [8-9] Command = 0x0402 - binary.LittleEndian.PutUint16(frame[10:], 0x0033) // [10-11] Flags + frame[0] = 0x04 // [0] Marker1 + frame[1] = 0x02 // [1] Marker2 + frame[2] = 0x1a // [2] Marker3 + frame[3] = 0x02 // [3] Mode + binary.LittleEndian.PutUint16(frame[4:], OldProtoSessionBodySize) // [4-5] BodySize + binary.LittleEndian.PutUint16(frame[8:], CmdSessionReq) // [8-9] Command = 0x0402 + binary.LittleEndian.PutUint16(frame[10:], 0x0033) // [10-11] Flags // Body [16-51] - body := frame[16:] - copy(body[:20], c.uid) // [0-19] UID (20 bytes) - copy(body[20:], c.randomID) // [20-27] RandomID + body := frame[OldProtoHeaderSize:] + copy(body[:UIDSize], c.uid) // [0-19] UID (20 bytes) + copy(body[UIDSize:], c.randomID) // [20-27] RandomID ts := uint32(time.Now().Unix()) binary.LittleEndian.PutUint32(body[32:], ts) // [32-35] Timestamp @@ -1281,6 +1512,11 @@ func (c *Conn) buildDTLSConfig(isServer bool) *dtls.Config { config.CustomCipherSuites = CustomCipherSuites } + if c.verbose { + fmt.Printf("[DTLS] Config: isServer=%v, MTU=%d, FlightInterval=%v\n", + isServer, config.MTU, config.FlightInterval) + } + return config } @@ -1574,3 +1810,19 @@ func getBroadcastAddrs(port int, verbose bool) []*net.UDPAddr { return addrs } + +func hexDump(data []byte) string { + var result string + for i := 0; i < len(data); i += 16 { + end := i + 16 + if end > len(data) { + end = len(data) + } + line := fmt.Sprintf(" %04x:", i) + for j := i; j < end; j++ { + line += fmt.Sprintf(" %02x", data[j]) + } + result += line + "\n" + } + return result +} diff --git a/pkg/wyze/tutk/constants.go b/pkg/wyze/tutk/constants.go index 74bc93b6..84e867e1 100644 --- a/pkg/wyze/tutk/constants.go +++ b/pkg/wyze/tutk/constants.go @@ -4,9 +4,9 @@ const ( CodecUnknown uint16 = 0x00 // Unknown codec CodecMPEG4 uint16 = 0x4C // 76 - MPEG4 CodecH263 uint16 = 0x4D // 77 - H.263 - CodecH264 uint16 = 0x4E // 78 - H.264/AVC (common for Wyze) + CodecH264 uint16 = 0x4E // 78 - H.264/AVC CodecMJPEG uint16 = 0x4F // 79 - MJPEG - CodecH265 uint16 = 0x50 // 80 - H.265/HEVC (common for Wyze) + CodecH265 uint16 = 0x50 // 80 - H.265/HEVC ) const ( @@ -20,7 +20,6 @@ const ( AudioCodecSPEEX uint16 = 0x8D // 141 - Speex AudioCodecMP3 uint16 = 0x8E // 142 - MP3 AudioCodecG726 uint16 = 0x8F // 143 - G.726 - // Wyze extensions (not in official SDK) AudioCodecAACWyze uint16 = 0x90 // 144 - Wyze AAC AudioCodecOpus uint16 = 0x92 // 146 - Opus codec ) @@ -109,15 +108,38 @@ const ( IOTypeGetMotionDetectRes = 0x0309 ) +// OLD DTLS Protocol (IOTC/TransCode) commands and sizes const ( - CmdDiscoReq uint16 = 0x0601 - CmdDiscoRes uint16 = 0x0602 - CmdSessionReq uint16 = 0x0402 - CmdSessionRes uint16 = 0x0404 - CmdDataTX uint16 = 0x0407 - CmdDataRX uint16 = 0x0408 - CmdKeepaliveReq uint16 = 0x0427 - CmdKeepaliveRes uint16 = 0x0428 + CmdDiscoReq uint16 = 0x0601 + CmdDiscoRes uint16 = 0x0602 + CmdSessionReq uint16 = 0x0402 + CmdSessionRes uint16 = 0x0404 + CmdDataTX uint16 = 0x0407 + CmdDataRX uint16 = 0x0408 + CmdKeepaliveReq uint16 = 0x0427 + CmdKeepaliveRes uint16 = 0x0428 + OldProtoHeaderSize = 16 + OldProtoMinPacketSize = 16 + OldProtoDiscoBodySize = 72 + OldProtoDiscoPacketSize = OldProtoHeaderSize + OldProtoDiscoBodySize + OldProtoSessionBodySize = 36 + OldProtoSessionPacketSize = OldProtoHeaderSize + OldProtoSessionBodySize +) + +// NEW DTLS Protocol (0xCC51) commands and sizes +const ( + MagicNewProto uint16 = 0xCC51 + CmdNewProtoDiscovery uint16 = 0x1002 + CmdNewProtoDTLS uint16 = 0x1502 + NewProtoPayloadSize uint16 = 0x0028 + NewProtoPacketSize = 52 + NewProtoHeaderSize = 28 + NewProtoAuthSize = 20 +) + +const ( + UIDSize = 20 + RandomIDSize = 8 ) const ( From c55fa878276fc3cf427c18150b063ccf8dd4d180 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 Jan 2026 02:44:07 +0100 Subject: [PATCH 179/241] Update README.md to include details on NEW Protocol (0xCC51) for Wyze cameras --- pkg/wyze/tutk/README.md | 236 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/pkg/wyze/tutk/README.md b/pkg/wyze/tutk/README.md index 8020dba8..37d601ec 100644 --- a/pkg/wyze/tutk/README.md +++ b/pkg/wyze/tutk/README.md @@ -20,11 +20,23 @@ This document provides a complete reverse-engineering reference for the ThroughT 14. [Wyze Cloud API](#14-wyze-cloud-api) 15. [Cryptography Details](#15-cryptography-details) 16. [Constants Reference](#16-constants-reference) +17. [NEW Protocol (0xCC51) Overview](#17-new-protocol-0xcc51-overview) +18. [NEW Protocol Discovery](#18-new-protocol-discovery) +19. [NEW Protocol DTLS Wrapper](#19-new-protocol-dtls-wrapper) --- ## 1. Protocol Stack Overview +Wyze cameras support two protocol variants depending on firmware version: + +| Protocol | Firmware | Magic | Discovery | Encryption | +|----------|----------|-------|-----------|------------| +| OLD | Cam v4 ≤ 4.52.9.4188 | TransCode | 0x0601/0x0602 | TransCode + DTLS | +| NEW | Cam v4 ≥ 4.52.9.5332 | 0xCC51 | 0x1002 | HMAC-SHA1 + DTLS | + +### OLD Protocol Stack (TransCode-based) + ``` ┌─────────────────────────────────────────────────────────────┐ │ Application Layer │ @@ -67,6 +79,45 @@ This document provides a complete reverse-engineering reference for the ThroughT └─────────────────────────────────────────────────────────────┘ ``` +### NEW Protocol Stack (0xCC51-based) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ Video (H.264/H.265) + Audio (AAC/G.711/Opus) │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ AV Frame Layer │ +│ Frame Types, Channels, FRAMEINFO, Packet Reassembly │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ K-Command Authentication │ +│ K10000-K10003 (XXTEA Challenge-Response) │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ AV Login Layer │ +│ Credentials + Capabilities Exchange │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ DTLS 1.2 Encryption │ +│ PSK = SHA256(ENR), ChaCha20-Poly1305 AEAD │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ NEW Protocol Wrapper (0xCC51) │ +│ Discovery (0x1002) + DTLS Wrapper (0x1502) + HMAC-SHA1 │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ UDP Transport │ +│ Port 32761 (default) │ +└─────────────────────────────────────────────────────────────┘ +``` + ### Required Credentials | Parameter | Description | Source | @@ -131,6 +182,8 @@ Used for K-Command challenge-response authentication. ## 3. Connection Flow +### 3.1 OLD Protocol Flow (TransCode-based) + ``` Client Camera │ │ @@ -175,6 +228,53 @@ Client Camera │ ... │ ``` +### 3.2 NEW Protocol Flow (0xCC51-based) + +``` +Client Camera + │ │ + │ ═══════════ Phase 1: Discovery (0x1002) ═══════════ │ + │ │ + │ seq=0, ticket=0 (broadcast) ────────────────────► │ + │ ◄─────────────── seq=1, ticket=T (response) │ + │ seq=2, ticket=T (echo) ─────────────────────────► │ + │ ◄───────────────────────────── seq=3, ticket=T │ + │ │ + │ ═══════════ Phase 2: DTLS Handshake (0x1502) ══════ │ + │ │ + │ ClientHello (wrapped in 0x1502) ────────────────► │ + │ ◄───────────────────── ServerHello + KeyExchange │ + │ ClientKeyExchange + Finished ───────────────────► │ + │ ◄───────────────────────────────── DTLS Finished │ + │ │ + │ ═══════════ Phase 3: AV Login ═════════════════════ │ + │ │ + │ AV Login #1 (magic=0x0000) ──────────────────────► │ + │ AV Login #2 (magic=0x2000) ──────────────────────► │ + │ ◄───────────────────── AV Login Response (0x2100) │ + │ ACK (0x0009) ────────────────────────────────────► │ + │ │ + │ ═══════════ Phase 4: K-Authentication ═════════════ │ + │ │ + │ K10000 (Auth Request) ───────────────────────────► │ + │ ◄───────────────────────── K10001 (Challenge 16B) │ + │ ACK (0x0009) ────────────────────────────────────► │ + │ K10002 (Response 38B) ───────────────────────────► │ + │ ◄───────────────────────── K10003 (Result, JSON) │ + │ ACK (0x0009) ────────────────────────────────────► │ + │ │ + │ ═══════════ Phase 5: Streaming ════════════════════ │ + │ │ + │ ◄─────────────────────────────── Video/Audio Data │ + │ ◄─────────────────────────────── Video/Audio Data │ + │ ... │ +``` + +**Key Differences from OLD Protocol:** +- Discovery uses 4-packet handshake (seq 0→1→2→3) instead of 2-stage discovery + session setup +- No TransCode encryption layer - packets use HMAC-SHA1 authentication instead +- DTLS records wrapped in 0x1502 frames with auth bytes appended + --- ## 4. IOTC Packet Structures @@ -1063,3 +1163,139 @@ authkey = b64.replace('+', 'Z').replace('/', '9').replace('=', 'A') | MaxPacketSize | 2048 | Max UDP packet | | IOTCChannelMain | 0 | Main channel (DTLS client) | | IOTCChannelBack | 1 | Backchannel (DTLS server) | + +### 16.6 NEW Protocol Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| MagicNewProto | 0xCC51 | NEW protocol magic (LE) | +| CmdNewProtoDiscovery | 0x1002 | Discovery command | +| CmdNewProtoDTLS | 0x1502 | DTLS data command | +| NewProtoPayloadSize | 0x0028 | 40 bytes payload | +| NewProtoPacketSize | 52 | Total discovery packet size | +| NewProtoHeaderSize | 28 | DTLS packet header size | +| NewProtoAuthSize | 20 | Auth bytes (HMAC-SHA1) | + +--- + +## 17. NEW Protocol (0xCC51) Overview + +The NEW protocol (magic 0xCC51) is used by Wyze Cam v4 with firmware 4.52.9.5332 and later. It replaces the TransCode cipher layer with HMAC-SHA1 authentication and simplifies the discovery process. + +### Key Differences from OLD Protocol + +| Aspect | OLD Protocol | NEW Protocol | +|--------|--------------|--------------| +| Magic | TransCode encoded | 0xCC51 | +| Discovery | 0x0601/0x0602 + 0x0402/0x0404 | 0x1002 (4-packet handshake) | +| Encryption | TransCode + DTLS | HMAC-SHA1 + DTLS | +| DTLS Wrapper | DATA_TX 0x0407 | 0x1502 with auth bytes | +| P2P Servers | Required for relay | Not required (LAN only) | + +### Authentication + +All NEW protocol packets include a 20-byte HMAC-SHA1 authentication field: + +```go +// Key derivation +authKey := CalculateAuthKey(enr, mac) // 8-byte key from ENR + MAC +key := append([]byte(uid), authKey...) // UID (20 bytes) + AuthKey (8 bytes) + +// HMAC-SHA1 calculation +h := hmac.New(sha1.New, key) +h.Write(packetHeader) // Header bytes before auth field +authBytes := h.Sum(nil) // 20 bytes +``` + +--- + +## 18. NEW Protocol Discovery + +Discovery uses command 0x1002 with a 4-packet handshake sequence. + +### 18.1 Discovery Packet Structure (52 bytes) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-1] 2 Magic 0xCC51 (little-endian) +[2-3] 2 Flags 0x0000 (constant) +[4-5] 2 Command 0x1002 (Discovery) +[6-7] 2 PayloadSize 0x0028 (40 bytes) +[8-9] 2 Direction 0x0000=Request, 0xFFFF=Response +[10-11] 2 Reserved 0x0000 +[12-13] 2 Sequence 0, 1, 2, or 3 +[14-15] 2 Ticket 0x0000 initially, then from camera +[16-23] 8 SessionID Random[2] + Constant[6] +[24-31] 8 Capabilities 0x00 08 03 04 1d 00 00 00 +[32-51] 20 AuthBytes HMAC-SHA1(key, header[0:32]) +``` + +### 18.2 Handshake Sequence + +``` +Step Direction Seq Ticket Description +──────────────────────────────────────────────────────────────── +1 Client→Cam 0 0x0000 Discovery request (broadcast) +2 Cam→Client 1 T Discovery response (ticket assigned) +3 Client→Cam 2 T Echo request (confirms ticket) +4 Cam→Client 3 T Echo ACK (handshake complete) +``` + +### 18.3 SessionID Generation + +```go +sessionID := make([]byte, 8) +rand.Read(sessionID[:2]) // Random prefix +copy(sessionID[2:], []byte{0x76, 0x0a, 0x9d, 0x24, 0x88, 0xba}) // Constant suffix +``` + +--- + +## 19. NEW Protocol DTLS Wrapper + +After discovery, DTLS records are wrapped in command 0x1502 frames with HMAC-SHA1 authentication. + +### 19.1 DTLS Wrapper Structure (variable size) + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-1] 2 Magic 0xCC51 (little-endian) +[2-3] 2 Flags 0x0000 +[4-5] 2 Command 0x1502 (DTLS) +[6-7] 2 PayloadSize 16 + dtls_len + 20 +[8-9] 2 Direction 0x0000=Request +[10-11] 2 Reserved 0x0000 +[12-13] 2 Sequence 0x0010 (fixed for DTLS) +[14-15] 2 Ticket From discovery handshake +[16-23] 8 SessionID 8 bytes from discovery +[24-27] 4 Channel 1=Main (client), 2=Back (server) +[28-N] var DTLSPayload Raw DTLS record +[N:N+20] 20 AuthBytes HMAC-SHA1(key, bytes[0:N]) +``` + +### 19.2 PayloadSize Calculation + +``` +PayloadSize = 16 + len(DTLSPayload) + 20 + +Where: + 16 = seq(2) + ticket(2) + sessionID(8) + channel(4) + 20 = AuthBytes (HMAC-SHA1) +``` + +### 19.3 TX/RX Processing + +**Transmit (TX):** +1. Build header with magic, command, payload size +2. Append session fields (seq, ticket, sessionID, channel) +3. Append DTLS payload +4. Calculate HMAC-SHA1 over entire packet (excluding auth bytes position) +5. Append auth bytes + +**Receive (RX):** +1. Verify magic == 0xCC51 +2. Extract DTLS payload from position 28 to (length - 20) +3. Strip 20 auth bytes from end +4. Pass DTLS payload to DTLS layer From cbaa1474694dfb2bb6cba3e76ba886ee81ff458f Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 Jan 2026 02:44:27 +0100 Subject: [PATCH 180/241] Update README.md for Wyze cameras --- pkg/wyze/README.md | 58 ++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/pkg/wyze/README.md b/pkg/wyze/README.md index 03e26ce8..fbbc0bc3 100644 --- a/pkg/wyze/README.md +++ b/pkg/wyze/README.md @@ -5,7 +5,7 @@ This source allows you to stream from [Wyze](https://wyze.com/) cameras using na **Important:** 1. **Requires Wyze account**. You need to login once via the WebUI to load your cameras. -2. **Requires newer firmware with DTLS**. Only cameras with DTLS-enabled firmware are currently supported. +2. **Requires firmware with DTLS**. Only cameras with DTLS-enabled firmware are supported. 3. Internet access is only needed when loading cameras from your account. After that, all streaming is local P2P. 4. Connection to the camera is local only (direct P2P to camera IP). @@ -33,7 +33,7 @@ wyze: password: "yourpassword" # or MD5 triple-hash with "md5:" prefix streams: - wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF + wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&dtls=true ``` ## Stream URL Format @@ -60,30 +60,44 @@ You can change the camera's resolution using the `quality` parameter: ```yaml streams: - wyze_hd: wyze://...&quality=hd # 1080P/2K (default) - wyze_sd: wyze://...&quality=sd # 360P + wyze_hd: wyze://...&quality=hd + wyze_sd: wyze://...&quality=sd ``` ### Two-Way Audio Two-way audio (intercom) is supported automatically. When a consumer sends audio to the stream, it will be transmitted to the camera's speaker. -## Supported Cameras +## Camera Compatibility -Cameras using the TUTK P2P protocol: - -| Model | Name | Tested | -|-------|------|--------| -| WYZE_CAKP2JFUS | Wyze Cam v3 | | -| HL_CAM3P | Wyze Cam v3 Pro | | -| HL_CAM4 | Wyze Cam v4 | Yes | -| WYZECP1_JEF | Wyze Cam Pan | | -| HL_PANP | Wyze Cam Pan v2 | | -| HL_PAN3 | Wyze Cam Pan v3 | | -| WVOD1 | Wyze Video Doorbell | | -| WVOD2 | Wyze Video Doorbell v2 | | -| AN_RSCW | Wyze Video Doorbell Pro | | -| GW_BE1 | Wyze Cam Floodlight | | -| HL_WCO2 | Wyze Cam Outdoor | | -| HL_CFL2 | Wyze Cam Floodlight v2 | | -| LD_CFP | Wyze Battery Cam Pro | | +| Name | Model | Firmware | Protocol | Encryption | Codecs | +|------|-------|----------|----------|------------|--------| +| Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | hevc, aac | +| | | 4.52.9.5332 | TUTK | HMAC-SHA1 | hevc, aac | +| Wyze Cam v3 Pro | | | | | | +| Wyze Cam v3 | | | | | | +| Wyze Cam v2 | | | | | | +| Wyze Cam v1 | | | | | | +| Wyze Cam Pan v4 | | | | | | +| Wyze Cam Pan v3 | | | | | | +| Wyze Cam Pan v2 | | | | | | +| Wyze Cam Pan v1 | | | | | | +| Wyze Cam OG | | | | | | +| Wyze Cam OG Telephoto | | | | | | +| Wyze Cam OG (2025) | | | | | | +| Wyze Cam Outdoor v2 | | | | | | +| Wyze Cam Outdoor v1 | | | | | | +| Wyze Cam Outdoor Base Station | | | | | | +| Wyze Cam Floodlight Pro | | | | | | +| Wyze Cam Floodlight v2 | | | | | | +| Wyze Cam Floodlight | | | | | | +| Wyze Video Doorbell v2 | | | | | | +| Wyze Video Doorbell v1 | | | | | | +| Wyze Video Doorbell Pro | | | | | | +| Wyze Battery Video Doorbell | | | | | | +| Wyze Duo Cam Doorbell | | | | | | +| Wyze Battery Cam Pro | | | | | | +| Wyze Solar Cam Pan | | | | | | +| Wyze Duo Cam Pan | | | | | | +| Wyze Window Cam | | | | | | +| Wyze Bulb Cam | | | | | | \ No newline at end of file From 29f966f28032423ea1eb84701d3889730f4ac639 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 Jan 2026 03:13:41 +0100 Subject: [PATCH 181/241] Fix intercom for new firmware --- pkg/wyze/tutk/conn.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index f58c3dc8..d539fce2 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -743,12 +743,9 @@ func (c *Conn) handleNewProtoPacket(data []byte) { // We need to strip the auth bytes at the end dtlsPayload := data[NewProtoHeaderSize : len(data)-NewProtoAuthSize] - // Channel is in the 4-byte field at offset 24 (value 1=main, 2=back) - channelFlag := binary.LittleEndian.Uint32(data[24:]) - var channel byte - if channelFlag >= 1 { - channel = byte(channelFlag - 1) // Convert back to 0=main, 1=back - } + // Channel is encoded in the high byte of the sequence field: + // seq=0x0010 -> channel 0 (main), seq=0x0110 -> channel 1 (back) + channel := byte(seq >> 8) if c.verbose && len(dtlsPayload) >= 1 { fmt.Printf("[NEW] DTLS RX ch=%d contentType=%d len=%d (stripped 20 auth bytes)\n%s", channel, dtlsPayload[0], len(dtlsPayload), hexDump(dtlsPayload)) @@ -894,6 +891,10 @@ func (c *Conn) route(data []byte) { func (c *Conn) handleSpeakerAVLogin() error { // Read AV Login request from camera (SDK receives 570 bytes) + if c.verbose { + fmt.Printf("[SPEAK] Waiting for AV Login request from camera...\n") + } + buf := make([]byte, 1024) c.speakerConn.SetReadDeadline(time.Now().Add(5 * time.Second)) n, err := c.speakerConn.Read(buf) @@ -1351,10 +1352,12 @@ func (c *Conn) buildNewProtoDTLS(payload []byte, channel byte) []byte { binary.LittleEndian.PutUint16(pkt[6:], payloadSize) binary.LittleEndian.PutUint16(pkt[8:], 0x0000) // Direction (request) binary.LittleEndian.PutUint16(pkt[10:], 0x0000) // Reserved - binary.LittleEndian.PutUint16(pkt[12:], 0x0010) // DTLS uses fixed seq=16 (0x10) + // Channel is encoded in high byte of sequence: 0x0010=main, 0x0110=back + seq := uint16(0x0010) | (uint16(channel) << 8) + binary.LittleEndian.PutUint16(pkt[12:], seq) binary.LittleEndian.PutUint16(pkt[14:], c.newProtoTicket) copy(pkt[16:24], c.sessionID) - binary.LittleEndian.PutUint32(pkt[24:], uint32(channel)+1) // Channel flag (main=1, back=2) + binary.LittleEndian.PutUint32(pkt[24:], 1) // Always 1 for DTLS wrapper copy(pkt[NewProtoHeaderSize:], payload) // Add Auth bytes at the end: HMAC-SHA1(UID+AuthKey, packet_header) From 58d8a86a92b11b0d47983d85e8afb0fc8f27a500 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 Jan 2026 10:46:26 +0100 Subject: [PATCH 182/241] cleanup --- internal/wyze/wyze.go | 84 +++++++---------- pkg/wyze/client.go | 6 -- pkg/wyze/cloud.go | 204 +++++++++++++++--------------------------- 3 files changed, 106 insertions(+), 188 deletions(-) diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go index aad01d76..85d4c19c 100644 --- a/internal/wyze/wyze.go +++ b/internal/wyze/wyze.go @@ -13,6 +13,14 @@ import ( "github.com/AlexxIT/go2rtc/pkg/wyze" ) +type AccountConfig struct { + APIKey string `yaml:"api_key"` + APIID string `yaml:"api_id"` + Password string `yaml:"password"` +} + +var accounts map[string]AccountConfig + func Init() { var v struct { Cfg map[string]AccountConfig `yaml:"wyze"` @@ -31,27 +39,18 @@ func Init() { api.HandleFunc("api/wyze", apiWyze) } -type AccountConfig struct { - APIKey string `yaml:"api_key"` - APIID string `yaml:"api_id"` - Password string `yaml:"password"` -} - -var accounts map[string]AccountConfig - func getCloud(email string) (*wyze.Cloud, error) { cfg, ok := accounts[email] if !ok { return nil, fmt.Errorf("wyze: account not found: %s", email) } - var cloud *wyze.Cloud - if cfg.APIKey != "" && cfg.APIID != "" { - cloud = wyze.NewCloudWithAPIKey(cfg.APIKey, cfg.APIID) - } else { - cloud = wyze.NewCloud() + if cfg.APIKey == "" || cfg.APIID == "" { + return nil, fmt.Errorf("wyze: api_key and api_id required for account: %s", email) } + cloud := wyze.NewCloud(cfg.APIKey, cfg.APIID) + if err := cloud.Login(email, cfg.Password); err != nil { return nil, err } @@ -73,7 +72,6 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) { email := query.Get("id") if email == "" { - // Return list of configured accounts accountList := make([]string, 0, len(accounts)) for id := range accounts { accountList = append(accountList, id) @@ -99,7 +97,7 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) { items = append(items, &api.Source{ Name: cam.Nickname, - Info: fmt.Sprintf("%s | %s | %s", cam.ModelName(), cam.MAC, cam.IP), + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), URL: streamURL, }) } @@ -113,25 +111,6 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) { } } -func buildStreamURL(cam *wyze.Camera) string { - // Use IP if available, otherwise use P2P_ID as host - host := cam.IP - if host == "" { - host = cam.P2PID - } - - query := url.Values{} - query.Set("uid", cam.P2PID) - query.Set("enr", cam.ENR) - query.Set("mac", cam.MAC) - - if cam.DTLS == 1 { - query.Set("dtls", "true") - } - - return fmt.Sprintf("wyze://%s?%s", host, query.Encode()) -} - func apiAuth(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -143,18 +122,13 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { apiKey := r.Form.Get("api_key") apiID := r.Form.Get("api_id") - if email == "" || password == "" { - http.Error(w, "email and password required", http.StatusBadRequest) + if email == "" || password == "" || apiKey == "" || apiID == "" { + http.Error(w, "email, password, api_key and api_id required", http.StatusBadRequest) return } // Try to login - var cloud *wyze.Cloud - if apiKey != "" && apiID != "" { - cloud = wyze.NewCloudWithAPIKey(apiKey, apiID) - } else { - cloud = wyze.NewCloud() - } + cloud := wyze.NewCloud(apiKey, apiID) if err := cloud.Login(email, password); err != nil { // Check for MFA error @@ -169,15 +143,10 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { return } - // Save credentials to config (not tokens!) cfg := map[string]string{ "password": password, - } - if apiKey != "" { - cfg["api_key"] = apiKey - } - if apiID != "" { - cfg["api_id"] = apiID + "api_key": apiKey, + "api_id": apiID, } if err := app.PatchConfig([]string{"wyze", email}, cfg); err != nil { @@ -185,7 +154,6 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { return } - // Update in-memory config if accounts == nil { accounts = make(map[string]AccountConfig) } @@ -195,7 +163,6 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { Password: password, } - // Return camera list with direct URLs cameras, err := cloud.GetCameraList() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -208,7 +175,7 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { items = append(items, &api.Source{ Name: cam.Nickname, - Info: fmt.Sprintf("%s | %s | %s", cam.ModelName(), cam.MAC, cam.IP), + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), URL: streamURL, }) } @@ -216,6 +183,19 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { api.ResponseSources(w, items) } +func buildStreamURL(cam *wyze.Camera) string { + query := url.Values{} + query.Set("uid", cam.P2PID) + query.Set("enr", cam.ENR) + query.Set("mac", cam.MAC) + + if cam.DTLS == 1 { + query.Set("dtls", "true") + } + + return fmt.Sprintf("wyze://%s?%s", cam.IP, query.Encode()) +} + func isAuthError(err error, target **wyze.AuthError) bool { if e, ok := err.(*wyze.AuthError); ok { *target = e diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 69514d0f..7f59be58 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -438,9 +438,6 @@ func (c *Client) buildK10002(challenge []byte, status byte) []byte { } func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { - // SDK format: 18 bytes total - // Header: 16 bytes, Payload: 2 bytes (media_type + enabled) - // TX K10010: 48 4c 05 00 1a 27 02 00 00 00 00 00 00 00 00 00 01 01 buf := make([]byte, 18) buf[0] = 'H' buf[1] = 'L' @@ -457,9 +454,6 @@ func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { } func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { - // SDK format: 21 bytes total - // Header: 16 bytes, Payload: 5 bytes - // TX K10056: 48 4c 05 00 48 27 05 00 00 00 00 00 00 00 00 00 04 f0 00 00 00 buf := make([]byte, 21) buf[0] = 'H' buf[1] = 'L' diff --git a/pkg/wyze/cloud.go b/pkg/wyze/cloud.go index f10268cf..7034b141 100644 --- a/pkg/wyze/cloud.go +++ b/pkg/wyze/cloud.go @@ -22,14 +22,12 @@ const ( ) type Cloud struct { - client *http.Client - apiKey string - keyID string - accessToken string - refreshToken string - phoneID string - openUserID string - cameras []*Camera + client *http.Client + apiKey string + keyID string + accessToken string + phoneID string + cameras []*Camera } type Camera struct { @@ -45,46 +43,36 @@ type Camera struct { IsOnline bool `json:"is_online"` } -func (c *Camera) ModelName() string { - models := map[string]string{ - "WYZEC1": "Wyze Cam v1", - "WYZEC1-JZ": "Wyze Cam v2", - "WYZE_CAKP2JFUS": "Wyze Cam v3", - "HL_CAM3P": "Wyze Cam v3 Pro", - "HL_CAM4": "Wyze Cam v4", - "WYZECP1_JEF": "Wyze Cam Pan", - "HL_PANP": "Wyze Cam Pan v2", - "HL_PAN3": "Wyze Cam Pan v3", - "WVOD1": "Wyze Video Doorbell", - "WVOD2": "Wyze Video Doorbell v2", - "AN_RSCW": "Wyze Video Doorbell Pro", - "GW_BE1": "Wyze Cam Floodlight", - "HL_WCO2": "Wyze Cam Outdoor", - "HL_CFL2": "Wyze Cam Floodlight v2", - "LD_CFP": "Wyze Battery Cam Pro", - } - if name, ok := models[c.ProductModel]; ok { - return name - } - return c.ProductModel +type deviceListResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data struct { + DeviceList []deviceInfo `json:"device_list"` + } `json:"data"` } -func NewCloud() *Cloud { - return &Cloud{ - client: &http.Client{Timeout: 30 * time.Second}, - phoneID: generatePhoneID(), - } +type deviceInfo struct { + MAC string `json:"mac"` + ENR string `json:"enr"` + Nickname string `json:"nickname"` + ProductModel string `json:"product_model"` + ProductType string `json:"product_type"` + FirmwareVer string `json:"firmware_ver"` + ConnState int `json:"conn_state"` + DeviceParams deviceParams `json:"device_params"` } -func NewCloudWithAPIKey(apiKey, keyID string) *Cloud { - c := NewCloud() - c.apiKey = apiKey - c.keyID = keyID - return c +type deviceParams struct { + P2PID string `json:"p2p_id"` + P2PType int `json:"p2p_type"` + IP string `json:"ip"` + DTLS int `json:"dtls"` } -func generatePhoneID() string { - return core.RandString(16, 16) // 16 hex chars +type p2pInfoResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` } type loginResponse struct { @@ -96,35 +84,13 @@ type loginResponse struct { EmailSessionID string `json:"email_session_id"` } -type apiError struct { - Code string `json:"code"` - ErrorCode int `json:"errorCode"` - Msg string `json:"msg"` - Description string `json:"description"` -} - -func (e *apiError) hasError() bool { - if e.Code == "1" || e.Code == "0" { - return false +func NewCloud(apiKey, keyID string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 30 * time.Second}, + phoneID: generatePhoneID(), + apiKey: apiKey, + keyID: keyID, } - if e.Code == "" && e.ErrorCode == 0 { - return false - } - return e.Code != "" || e.ErrorCode != 0 -} - -func (e *apiError) message() string { - if e.Msg != "" { - return e.Msg - } - return e.Description -} - -func (e *apiError) code() string { - if e.Code != "" { - return e.Code - } - return fmt.Sprintf("%d", e.ErrorCode) } func (c *Cloud) Login(email, password string) error { @@ -141,15 +107,9 @@ func (c *Cloud) Login(email, password string) error { } req.Header.Set("Content-Type", "application/json") - if c.apiKey != "" && c.keyID != "" { - req.Header.Set("Apikey", c.apiKey) - req.Header.Set("Keyid", c.keyID) - req.Header.Set("User-Agent", "go2rtc") - } else { - req.Header.Set("X-API-Key", "WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ") - req.Header.Set("Phone-Id", c.phoneID) - req.Header.Set("User-Agent", "wyze_ios_"+appVersion) - } + req.Header.Set("Apikey", c.apiKey) + req.Header.Set("Keyid", c.keyID) + req.Header.Set("User-Agent", "go2rtc") resp, err := c.client.Do(req) if err != nil { @@ -186,55 +146,10 @@ func (c *Cloud) Login(email, password string) error { } c.accessToken = result.AccessToken - c.refreshToken = result.RefreshToken - c.openUserID = result.UserID return nil } -func (c *Cloud) LoginWithToken(accessToken, phoneID string) error { - c.accessToken = accessToken - if phoneID != "" { - c.phoneID = phoneID - } - _, err := c.GetCameraList() - return err -} - -func (c *Cloud) Credentials() (phoneID, openUserID string) { - return c.phoneID, c.openUserID -} - -func (c *Cloud) AccessToken() string { - return c.accessToken -} - -type deviceListResponse struct { - Code string `json:"code"` - Msg string `json:"msg"` - Data struct { - DeviceList []deviceInfo `json:"device_list"` - } `json:"data"` -} - -type deviceInfo struct { - MAC string `json:"mac"` - ENR string `json:"enr"` - Nickname string `json:"nickname"` - ProductModel string `json:"product_model"` - ProductType string `json:"product_type"` - FirmwareVer string `json:"firmware_ver"` - ConnState int `json:"conn_state"` - DeviceParams deviceParams `json:"device_params"` -} - -type deviceParams struct { - P2PID string `json:"p2p_id"` - P2PType int `json:"p2p_type"` - IP string `json:"ip"` - DTLS int `json:"dtls"` -} - func (c *Cloud) GetCameraList() ([]*Camera, error) { payload := map[string]any{ "access_token": c.accessToken, @@ -316,12 +231,6 @@ func (c *Cloud) GetCamera(id string) (*Camera, error) { return nil, fmt.Errorf("wyze: camera not found: %s", id) } -type p2pInfoResponse struct { - Code string `json:"code"` - Msg string `json:"msg"` - Data map[string]any `json:"data"` -} - func (c *Cloud) GetP2PInfo(mac string) (map[string]any, error) { payload := map[string]any{ "access_token": c.accessToken, @@ -367,6 +276,37 @@ func (c *Cloud) GetP2PInfo(mac string) (map[string]any, error) { return result.Data, nil } +type apiError struct { + Code string `json:"code"` + ErrorCode int `json:"errorCode"` + Msg string `json:"msg"` + Description string `json:"description"` +} + +func (e *apiError) hasError() bool { + if e.Code == "1" || e.Code == "0" { + return false + } + if e.Code == "" && e.ErrorCode == 0 { + return false + } + return e.Code != "" || e.ErrorCode != 0 +} + +func (e *apiError) message() string { + if e.Msg != "" { + return e.Msg + } + return e.Description +} + +func (e *apiError) code() string { + if e.Code != "" { + return e.Code + } + return fmt.Sprintf("%d", e.ErrorCode) +} + type AuthError struct { Message string `json:"message"` NeedsMFA bool `json:"needs_mfa,omitempty"` @@ -377,6 +317,10 @@ func (e *AuthError) Error() string { return e.Message } +func generatePhoneID() string { + return core.RandString(16, 16) // 16 hex chars +} + func hashPassword(password string) string { encoded := strings.TrimSpace(password) if strings.HasPrefix(strings.ToLower(encoded), "md5:") { From c4b32e3a0be49eb6a33182a575ab589e496a7d92 Mon Sep 17 00:00:00 2001 From: Johnnybyzhang Date: Sun, 11 Jan 2026 14:57:50 +0800 Subject: [PATCH 183/241] xiaomi/cs2: fix TCP keepalive to match official Mi Home app Based on PCAP analysis of official Mi Home app traffic, the keepalive mechanism was incorrect: Before (broken): - Sent PING every 5s only when receiving data - Responded to PING with PONG After (fixed): - Send PING every 1 second independently via dedicated goroutine - Don't respond to PING with PONG (official app doesn't either) - Both sides send PING bidirectionally as heartbeats The official app sends 199 PING messages and 0 PONG messages in a typical session. This fix matches that behavior. Fixes connection resets after prolonged streaming sessions with Xiaomi cameras using the CS2 P2P protocol. --- pkg/xiaomi/cs2/conn.go | 47 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/pkg/xiaomi/cs2/conn.go b/pkg/xiaomi/cs2/conn.go index 198b3beb..a99f0af5 100644 --- a/pkg/xiaomi/cs2/conn.go +++ b/pkg/xiaomi/cs2/conn.go @@ -24,8 +24,16 @@ func Dial(host, transport string) (*Conn, error) { isTCP: isTCP, rawCh0: make(chan []byte, 10), rawCh2: make(chan []byte, 100), + done: make(chan struct{}), } go c.worker() + + // For TCP connections, start independent keepalive goroutine + // Official Mi Home app sends PING every 1 second bidirectionally + if isTCP { + go c.keepalive() + } + return c, nil } @@ -38,6 +46,7 @@ type Conn struct { seqCh3 uint16 rawCh0 chan []byte rawCh2 chan []byte + done chan struct{} // signals connection close to keepalive goroutine cmdMu sync.Mutex cmdAck func() @@ -54,6 +63,7 @@ const ( msgDrwAck = 0xD1 msgPing = 0xE0 msgPong = 0xE1 + msgAlive = 0xF0 // Camera heartbeat/alive signal msgClose = 0xF1 ) @@ -102,17 +112,38 @@ func handshake(host, transport string) (net.Conn, error) { return conn, nil } +// keepalive sends PING every 1 second for TCP connections. +// Based on PCAP analysis of official Mi Home app: both sides send PING bidirectionally, +// neither responds with PONG. This keeps the connection alive. +func (c *Conn) keepalive() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + ping := []byte{magic, msgPing, 0, 0} + + for { + select { + case <-c.done: + return + case <-ticker.C: + if _, err := c.conn.Write(ping); err != nil { + return + } + } + } +} + func (c *Conn) worker() { defer func() { close(c.rawCh0) close(c.rawCh2) + close(c.done) // signal keepalive goroutine to stop }() chAck := make([]uint16, 4) // only for UDP buf := make([]byte, 1200) var ch2WaitSize int var ch2WaitData []byte - var keepaliveTS time.Time for { n, err := c.conn.Read(buf) @@ -125,13 +156,7 @@ func (c *Conn) worker() { case msgDrw: ch := buf[5] - if c.isTCP { - // For TCP we should using ping/pong. - if now := time.Now(); now.After(keepaliveTS) { - _, _ = c.conn.Write([]byte{magic, msgPing, 0, 0}) - keepaliveTS = now.Add(5 * time.Second) - } - } else { + if !c.isTCP { // For UDP we should using ack. seqHI := buf[6] seqLO := buf[7] @@ -179,7 +204,11 @@ func (c *Conn) worker() { } case msgPing: - _, _ = c.conn.Write([]byte{magic, msgPong, 0, 0}) + // Official Mi Home app: both sides send PING, neither responds with PONG + // Just acknowledge receipt, don't send PONG + continue + case msgAlive: + // Some cameras may send 0xF0 as heartbeat - just ignore like PING continue case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose: continue // skip it From ff04a0d4b2c3ef4dafc794ee0dfb3121c135aaaa Mon Sep 17 00:00:00 2001 From: Johnnybyzhang Date: Sun, 11 Jan 2026 15:05:03 +0800 Subject: [PATCH 184/241] xiaomi/cs2: reduce TCP keepalive interval to 1 second Based on PCAP analysis of official Mi Home app traffic: - Official app sends PING every ~1 second - Previous 5-second interval was too slow, causing connection resets Simply reduce keepalive timeout from 5s to 1s. --- pkg/xiaomi/cs2/conn.go | 48 +++++++++--------------------------------- 1 file changed, 10 insertions(+), 38 deletions(-) diff --git a/pkg/xiaomi/cs2/conn.go b/pkg/xiaomi/cs2/conn.go index a99f0af5..cde09ab5 100644 --- a/pkg/xiaomi/cs2/conn.go +++ b/pkg/xiaomi/cs2/conn.go @@ -24,16 +24,8 @@ func Dial(host, transport string) (*Conn, error) { isTCP: isTCP, rawCh0: make(chan []byte, 10), rawCh2: make(chan []byte, 100), - done: make(chan struct{}), } go c.worker() - - // For TCP connections, start independent keepalive goroutine - // Official Mi Home app sends PING every 1 second bidirectionally - if isTCP { - go c.keepalive() - } - return c, nil } @@ -46,7 +38,6 @@ type Conn struct { seqCh3 uint16 rawCh0 chan []byte rawCh2 chan []byte - done chan struct{} // signals connection close to keepalive goroutine cmdMu sync.Mutex cmdAck func() @@ -63,7 +54,6 @@ const ( msgDrwAck = 0xD1 msgPing = 0xE0 msgPong = 0xE1 - msgAlive = 0xF0 // Camera heartbeat/alive signal msgClose = 0xF1 ) @@ -112,38 +102,17 @@ func handshake(host, transport string) (net.Conn, error) { return conn, nil } -// keepalive sends PING every 1 second for TCP connections. -// Based on PCAP analysis of official Mi Home app: both sides send PING bidirectionally, -// neither responds with PONG. This keeps the connection alive. -func (c *Conn) keepalive() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - ping := []byte{magic, msgPing, 0, 0} - - for { - select { - case <-c.done: - return - case <-ticker.C: - if _, err := c.conn.Write(ping); err != nil { - return - } - } - } -} - func (c *Conn) worker() { defer func() { close(c.rawCh0) close(c.rawCh2) - close(c.done) // signal keepalive goroutine to stop }() chAck := make([]uint16, 4) // only for UDP buf := make([]byte, 1200) var ch2WaitSize int var ch2WaitData []byte + var keepaliveTS time.Time for { n, err := c.conn.Read(buf) @@ -156,7 +125,14 @@ func (c *Conn) worker() { case msgDrw: ch := buf[5] - if !c.isTCP { + if c.isTCP { + // For TCP we should send ping every second to keep connection alive. + // Based on PCAP analysis: official Mi Home app sends PING every ~1s. + if now := time.Now(); now.After(keepaliveTS) { + _, _ = c.conn.Write([]byte{magic, msgPing, 0, 0}) + keepaliveTS = now.Add(time.Second) + } + } else { // For UDP we should using ack. seqHI := buf[6] seqLO := buf[7] @@ -204,11 +180,7 @@ func (c *Conn) worker() { } case msgPing: - // Official Mi Home app: both sides send PING, neither responds with PONG - // Just acknowledge receipt, don't send PONG - continue - case msgAlive: - // Some cameras may send 0xF0 as heartbeat - just ignore like PING + _, _ = c.conn.Write([]byte{magic, msgPong, 0, 0}) continue case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose: continue // skip it From eb39b80883b7c2c3c4ff2ab7957d4560bfae7220 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 11 Jan 2026 16:46:23 +0300 Subject: [PATCH 185/241] Fix login to Xiaomi account with captcha #1985 --- pkg/xiaomi/cloud.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/pkg/xiaomi/cloud.go b/pkg/xiaomi/cloud.go index 5e1e73cc..0dcfd241 100644 --- a/pkg/xiaomi/cloud.go +++ b/pkg/xiaomi/cloud.go @@ -78,12 +78,9 @@ func (c *Cloud) Login(username, password string) error { } req := Request{ - Method: "POST", - URL: "https://account.xiaomi.com/pass/serviceLoginAuth2", - RawBody: form.Encode(), - Headers: url.Values{ - "Content-Type": {"application/x-www-form-urlencoded"}, - }, + Method: "POST", + URL: "https://account.xiaomi.com/pass/serviceLoginAuth2", + Body: form, RawCookies: cookies, }.Encode() @@ -105,7 +102,7 @@ func (c *Cloud) Login(username, password string) error { return err } - // save auth for two step verification + // save auth for two-step verification c.auth = map[string]string{ "username": username, "password": password, @@ -265,11 +262,17 @@ func (c *Cloud) sendTicket() error { cookies += "; ick=" + c.auth["ick"] } + form := url.Values{ + "_json": {"true"}, + "icode": {captCode}, + "retry": {"0"}, + } + req = Request{ Method: "POST", URL: "https://account.xiaomi.com/identity/auth/send" + name + "Ticket", + Body: form, RawCookies: cookies, - RawBody: `{"retry":0,"icode":"` + captCode + `","_json":"true"}`, }.Encode() res, err = c.client.Do(req) @@ -531,7 +534,7 @@ type Request struct { Method string URL string RawParams string - RawBody string + Body url.Values Headers url.Values RawCookies string } @@ -542,8 +545,8 @@ func (r Request) Encode() *http.Request { } var body io.Reader - if r.RawBody != "" { - body = strings.NewReader(r.RawBody) + if r.Body != nil { + body = strings.NewReader(r.Body.Encode()) } req, err := http.NewRequest(r.Method, r.URL, body) @@ -554,7 +557,9 @@ func (r Request) Encode() *http.Request { if r.Headers != nil { req.Header = http.Header(r.Headers) } - + if r.Body != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } if r.RawCookies != "" { req.Header.Set("Cookie", r.RawCookies) } From f9f22cdd0b8c10fd0ff61db4a34f0345dc480d11 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 11 Jan 2026 19:56:45 +0300 Subject: [PATCH 186/241] Add about OS versions to readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 46fcaec8..03db381f 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac. +PS. The application is compiled with the latest versions of the Go language for maximum speed and security. Therefore, the [minimum OS versions](https://go.dev/wiki/MinimumRequirements) depend on the Go language. + ### go2rtc: Docker The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant Add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg) and [Python](#source-echo). From e614513b9752b9565d1998e9b24f38e8f5fba700 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 11 Jan 2026 19:57:52 +0300 Subject: [PATCH 187/241] Fix formats table in readme --- pkg/README.md | 122 +++++++++++++++++++++++++------------------------- 1 file changed, 60 insertions(+), 62 deletions(-) diff --git a/pkg/README.md b/pkg/README.md index b027ed90..479dcce3 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -10,51 +10,49 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent - Codecs can be incoming - **Recevers codecs** - Codecs can be outgoing (two way audio) - **Senders codecs** -| Group | Format | Protocols | Ingress | Recevers codecs | Senders codecs | Example | -|------------|------------|---------------|---------|------------------------------|--------------------|---------------| -| Devices | alsa | pipe | | | pcm | `alsa:` | -| Devices | v4l2 | pipe | | | | | -| Files | adts | http,tcp,pipe | http | aac | | `http:` | -| Files | flv | http,tcp,pipe | http | h264,aac | | `http:` | -| Files | h264 | http,tcp,pipe | http | h264 | | `http:` | -| Files | hevc | http,tcp,pipe | http | hevc | | `http:` | -| Files | hls | http | | h264,h265,aac,opus | | `http:` | -| Files | mjpeg | http,tcp,pipe | http | mjpeg | | `http:` | -| Files | mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` | -| Files | mp4 | | | | | | -| Files | wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` | -| Net (pub) | mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` | -| Net (pub) | onvif | rtsp | | | | | -| Net (pub) | rtmp | rtmp | rtmp | h264,aac | | `rtmp:` | -| Net (pub) | rtsp | rtsp,ws | rtsp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` | -| Net (pub) | webrtc* | webrtc | webrtc | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` | -| Net (pub) | y4m | http,tcp,pipe | http | rawvideo | | `http:` | -| Net (priv) | bubble | http | | h264,hevc,pcm_alaw | | `bubble:` | -| Net (priv) | doorbird | http | | | | | -| Net (priv) | dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` | -| Net (priv) | eseecloud | http | | | | | -| Net (priv) | gopro | udp | | TODO | | `gopro:` | -| Net (priv) | hass | webrtc | | TODO | | `hass:` | -| Net (priv) | homekit | hap | | h264,eld* | | `homekit:` | -| Net (priv) | isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` | -| Net (priv) | kasa | http | | h264,pcm_mulaw | | `kasa:` | -| Net (priv) | nest | rtsp,webrtc | | TODO | | `nest:` | -| Net (priv) | ring | webrtc | | | | | -| Net (priv) | roborock | webrtc | | h264,opus | opus | `roborock:` | -| Net (priv) | tapo | http | | h264,pcma | pcm_alaw | `tapo:` | -| Net (priv) | tuya | webrtc | | | | | -| Net (priv) | vigi | http | | | | | -| Net (priv) | webtorrent | webrtc | TODO | TODO | TODO | `webtorrent:` | -| Net (priv) | xiaomi* | cs2,tutk | | | | | -| Services | flussonic | ws | | | | | -| Services | ivideon | ws | | h264 | | `ivideon:` | -| Services | yandex | webrtc | | | | | -| Other | ascii | http | | | | | -| Other | echo | * | | | | | -| Other | exec | pipe,rtsp | | | | | -| Other | expr | * | | | | | -| Other | ffmpeg | pipe,rtsp | | | | | -| Other | stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` | +| Group | Format | Protocols | Ingress | Recevers codecs | Senders codecs | Example | +|------------|--------------|-----------------|---------|---------------------------------|---------------------|---------------| +| Devices | alsa | pipe | | | pcm | `alsa:` | +| Devices | v4l2 | pipe | | | | `v4l2:` | +| Files | adts | http, tcp, pipe | http | aac | | `http:` | +| Files | flv | http, tcp, pipe | http | h264, aac | | `http:` | +| Files | h264 | http, tcp, pipe | http | h264 | | `http:` | +| Files | hevc | http, tcp, pipe | http | hevc | | `http:` | +| Files | hls | http | | h264, h265, aac, opus | | `http:` | +| Files | mjpeg | http, tcp, pipe | http | mjpeg | | `http:` | +| Files | mpegts | http, tcp, pipe | http | h264, hevc, aac, opus | | `http:` | +| Files | wav | http, tcp, pipe | http | pcm_alaw, pcm_mulaw | | `http:` | +| Net (pub) | mpjpeg | http, tcp, pipe | http | mjpeg | | `http:` | +| Net (pub) | onvif | rtsp | | | | `onvif:` | +| Net (pub) | rtmp | rtmp | rtmp | h264, aac | | `rtmp:` | +| Net (pub) | rtsp | rtsp, ws | rtsp | h264, hevc, aac, pcm*, opus | pcm*, opus | `rtsp:` | +| Net (pub) | webrtc* | webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw | `webrtc:` | +| Net (pub) | yuv4mpegpipe | http, tcp, pipe | http | rawvideo | | `http:` | +| Net (priv) | bubble | http | | h264, hevc, pcm_alaw | | `bubble:` | +| Net (priv) | doorbird | http | | | | `doorbird:` | +| Net (priv) | dvrip | tcp | | h264, hevc, pcm_alaw, pcm_mulaw | pcm_alaw | `dvrip:` | +| Net (priv) | eseecloud | http | | | | `eseecloud:` | +| Net (priv) | gopro | udp | | TODO | | `gopro:` | +| Net (priv) | hass | webrtc | | TODO | | `hass:` | +| Net (priv) | homekit | hap | | h264, eld* | | `homekit:` | +| Net (priv) | isapi | http | | | pcm_alaw, pcm_mulaw | `isapi:` | +| Net (priv) | kasa | http | | h264, pcm_mulaw | | `kasa:` | +| Net (priv) | nest | rtsp, webrtc | | TODO | | `nest:` | +| Net (priv) | ring | webrtc | | | | `ring:` | +| Net (priv) | roborock | webrtc | | h264, opus | opus | `roborock:` | +| Net (priv) | tapo | http | | h264, pcma | pcm_alaw | `tapo:` | +| Net (priv) | tuya | webrtc | | | | `tuya:` | +| Net (priv) | vigi | http | | | | `vigi:` | +| Net (priv) | webtorrent | webrtc | TODO | TODO | TODO | `webtorrent:` | +| Net (priv) | xiaomi* | cs2, tutk | | | | `xiaomi:` | +| Services | flussonic | ws | | | | `flussonic:` | +| Services | ivideon | ws | | h264 | | `ivideon:` | +| Services | yandex | webrtc | | | | `yandex:` | +| Other | echo | * | | | | `echo:` | +| Other | exec | pipe, rtsp | | | | `exec:` | +| Other | expr | * | | | | `expr:` | +| Other | ffmpeg | pipe, rtsp | | | | `ffmpeg:` | +| Other | stdin | pipe | | | pcm_alaw, pcm_mulaw | `stdin:` | - **eld** - rare variant of aac codec - **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le @@ -62,23 +60,23 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent ## Consumers (output) -| Format | Protocol | Send codecs | Recv codecs | Example | -|--------------|-------------|------------------------------|-------------------------|---------------------------------------| -| adts | http | aac | | `GET /api/stream.adts` | -| ascii | http | mjpeg | | `GET /api/stream.ascii` | -| flv | http | h264,aac | | `GET /api/stream.flv` | -| hls/mpegts | http | h264,hevc,aac | | `GET /api/stream.m3u8` | -| hls/fmp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.m3u8?mp4` | -| homekit | homekit+udp | h264,opus | | Apple HomeKit app | -| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` | -| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` | -| mp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.mp4` | -| mse/fmp4 | ws | h264,hevc,aac,pcm*,opus | | `{"type":"mse"}` -> `/api/ws` | -| mpegts | http | h264,hevc,aac | | `GET /api/stream.ts` | -| rtmp | rtmp | h264,aac | | `rtmp://localhost:1935/{stream_name}` | -| rtsp | rtsp+tcp | h264,hevc,aac,pcm*,opus | | `rtsp://localhost:8554/{stream_name}` | -| webrtc | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw,opus | `{"type":"webrtc"}` -> `/api/ws` | -| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` | +| Format | Protocol | Send codecs | Recv codecs | Example | +|--------------|----------|---------------------------------|---------------------------|---------------------------------------| +| adts | http | aac | | `GET /api/stream.adts` | +| ascii | http | mjpeg | | `GET /api/stream.ascii` | +| flv | http | h264, aac | | `GET /api/stream.flv` | +| hls/mpegts | http | h264, hevc, aac | | `GET /api/stream.m3u8` | +| hls/fmp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.m3u8?mp4` | +| homekit | hap | h264, opus | | Apple HomeKit app | +| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` | +| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` | +| mp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.mp4` | +| mse/fmp4 | ws | h264, hevc, aac, pcm*, opus | | `{"type":"mse"}` -> `/api/ws` | +| mpegts | http | h264, hevc, aac | | `GET /api/stream.ts` | +| rtmp | rtmp | h264, aac | | `rtmp://localhost:1935/{stream_name}` | +| rtsp | rtsp | h264, hevc, aac, pcm*, opus | | `rtsp://localhost:8554/{stream_name}` | +| webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw, opus | `{"type":"webrtc"}` -> `/api/ws` | +| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` | - **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le From 659a042c424aac4e462f2a6d16789ca893578fb5 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 02:13:00 +0100 Subject: [PATCH 188/241] Implement new authentication commands and improve PSK handling --- pkg/wyze/client.go | 94 +++++++++++++++++--- pkg/wyze/producer.go | 8 ++ pkg/wyze/tutk/README.md | 28 +++++- pkg/wyze/tutk/conn.go | 171 +++++++++++++++++-------------------- pkg/wyze/tutk/constants.go | 2 + 5 files changed, 196 insertions(+), 107 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 7f59be58..dee4b4d6 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -352,24 +352,26 @@ func (c *Client) doKAuth() error { fmt.Printf("[Wyze] K10001 received, status=%d\n", status) } - // Step 3: Send K10002 - k10002 := c.buildK10002(challenge, status) - if err := c.conn.SendIOCtrl(tutk.KCmdChallengeResp, k10002); err != nil { - return fmt.Errorf("wyze: K10002 send failed: %w", err) + // Step 3: Send K10008 + k10008 := c.buildK10008(challenge, status) + + if err := c.conn.SendIOCtrl(tutk.KCmdChallengeResp, k10008); err != nil { + return fmt.Errorf("wyze: K10008 send failed: %w", err) } - // Step 4: Wait for K10003 + // Step 4: Wait for K10009 cmdID, data, err = c.conn.RecvIOCtrl(10 * time.Second) if err != nil { - return fmt.Errorf("wyze: K10003 recv failed: %w", err) - } - if cmdID != tutk.KCmdAuthResult { - return fmt.Errorf("wyze: expected K10003, got K%d", cmdID) + return fmt.Errorf("wyze: K10009 recv failed: %w", err) } - authResp, err := c.parseK10003(data) + if cmdID != tutk.KCmdAuthSuccess { + return fmt.Errorf("wyze: expected K10009, got K%d", cmdID) + } + + authResp, err := c.parseK10009(data) if err != nil { - return fmt.Errorf("wyze: K10003 parse failed: %w", err) + return fmt.Errorf("wyze: K10009 parse failed: %w", err) } // Parse capabilities @@ -405,11 +407,18 @@ func (c *Client) doKAuth() error { } func (c *Client) buildK10000() []byte { - buf := make([]byte, 16) + // 137 = G.711 μ-law (PCMU) + // 138 = G.711 A-law (PCMA) + // 140 = PCM 16-bit + jsonPayload := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) + + buf := make([]byte, 16+len(jsonPayload)) buf[0] = 'H' buf[1] = 'L' buf[2] = 5 binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdAuth) + binary.LittleEndian.PutUint16(buf[6:], uint16(len(jsonPayload))) + copy(buf[16:], jsonPayload) return buf } @@ -437,6 +446,28 @@ func (c *Client) buildK10002(challenge []byte, status byte) []byte { return buf } +func (c *Client) buildK10008(challenge []byte, status byte) []byte { + response := crypto.GenerateChallengeResponse(challenge, c.enr, status) + openUserID := []byte(c.enr) + payloadLen := 16 + 4 + 1 + 1 + 1 + len(openUserID) + + buf := make([]byte, 16+payloadLen) + buf[0] = 'H' + buf[1] = 'L' + buf[2] = 5 // Protocol version + binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdAuthWithPayload) // 10008 + binary.LittleEndian.PutUint16(buf[6:], uint16(payloadLen)) + + copy(buf[16:], response[:16]) // Challenge response + copy(buf[32:], c.uid[:4]) // UID prefix + buf[36] = 1 // Video enabled + buf[37] = 1 // Audio enabled + buf[38] = byte(len(openUserID)) + copy(buf[39:], openUserID) + + return buf +} + func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { buf := make([]byte, 18) buf[0] = 'H' @@ -529,3 +560,42 @@ func (c *Client) parseK10003(data []byte) (*tutk.AuthResponse, error) { return &tutk.AuthResponse{}, nil } + +func (c *Client) parseK10009(data []byte) (*tutk.AuthResponse, error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10009: received %d bytes\n", len(data)) + } + + if len(data) < 16 { + return &tutk.AuthResponse{}, nil + } + + if data[0] != 'H' || data[1] != 'L' { + return &tutk.AuthResponse{}, nil + } + + cmdID := binary.LittleEndian.Uint16(data[4:]) + textLen := binary.LittleEndian.Uint16(data[6:]) + + if cmdID != tutk.KCmdAuthSuccess { + return &tutk.AuthResponse{}, nil + } + + if len(data) > 16 && textLen > 0 { + jsonData := data[16:] + for i := range jsonData { + if jsonData[i] == '{' { + var resp tutk.AuthResponse + if err := json.Unmarshal(jsonData[i:], &resp); err == nil { + if c.verbose { + fmt.Printf("[Wyze] parseK10009: parsed JSON\n") + } + return &resp, nil + } + break + } + } + } + + return &tutk.AuthResponse{}, nil +} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 84a927ca..7526115f 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -56,6 +56,10 @@ func NewProducer(rawURL string) (*Producer, error) { func (p *Producer) Start() error { for { + if p.client.verbose { + fmt.Println("[Wyze] Reading packet...") + } + _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) pkt, err := p.client.ReadPacket() if err != nil { @@ -136,6 +140,10 @@ func probe(client *Client, sd bool) ([]*core.Media, error) { var tutkAudioCodec uint16 for { + if client.verbose { + fmt.Println("[Wyze] Probing for codecs...") + } + pkt, err := client.ReadPacket() if err != nil { return nil, fmt.Errorf("wyze: probe: %w", err) diff --git a/pkg/wyze/tutk/README.md b/pkg/wyze/tutk/README.md index 37d601ec..ed98a857 100644 --- a/pkg/wyze/tutk/README.md +++ b/pkg/wyze/tutk/README.md @@ -349,7 +349,33 @@ DTLS records are wrapped in IOTC DATA_TX (0x0407) packets for transmission and e ``` Identity: "AUTHPWD_admin" -PSK: SHA256(ENR_string) → 32 bytes +PSK: SHA256(ENR_string) → variable length (see below) +``` + +#### PSK Length Determination + +**CRITICAL**: The TUTK SDK treats the binary PSK as a NULL-terminated C string. +This means the effective PSK length is determined by the first `0x00` byte in the SHA256 hash: + +``` +hash = SHA256(ENR) +psk_length = position of first 0x00 byte in hash (or 32 if no 0x00) +psk = hash[0:psk_length] + zeros[psk_length:32] +``` + +**Example 1** - No NULL byte in hash (full 32-byte PSK): +``` +ENR: "aKzdqckqZ8HUHFe5" +SHA256: 3e5b96b8d6fc7264b531e1633de9526929d453cb47606c55d574a6e0ef5eb95f + ^^ No 0x00 byte → PSK length = 32 +``` + +**Example 2** - NULL byte at position 11 (11-byte PSK): +``` +ENR: "GkB9S7cX38GgzSC6" +SHA256: 16549c533b4e9812808f91|00|95f6edf00365266f09ea1e0328df3eee1ce127ed + ^^ 0x00 at position 11 → PSK length = 11 +PSK: 16549c533b4e9812808f91000000000000000000000000000000000000000000 ``` ### Nonce Construction diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index d539fce2..58f8bc9d 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -39,10 +39,9 @@ type FrameAssembler struct { } type Conn struct { - udpConn *net.UDPConn - addr *net.UDPAddr - broadcastAddrs []*net.UDPAddr - randomID []byte + udpConn *net.UDPConn + addr *net.UDPAddr + randomID []byte uid string authKey string enr string @@ -100,14 +99,18 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { ctx, cancel := context.WithCancel(context.Background()) - hash := sha256.Sum256([]byte(enr)) - psk := hash[:] + psk := derivePSK(enr) + + if verbose { + hash := sha256.Sum256([]byte(enr)) + fmt.Printf("[PSK] ENR: %q → SHA256: %x\n", enr, hash) + fmt.Printf("[PSK] PSK: %x\n", psk) + } c := &Conn{ - udpConn: conn, - addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, - broadcastAddrs: getBroadcastAddrs(DefaultPort, verbose), - randomID: genRandomID(), + udpConn: conn, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, + randomID: genRandomID(), uid: uid, authKey: authKey, enr: enr, @@ -394,8 +397,8 @@ func (c *Conn) discovery() error { newDiscoPkt := c.buildNewProtoPacket(0, 0, false) // NEW protocol (0xCC51, cmd=0x1002) if c.verbose { - fmt.Printf("[DISCO] Unified discovery: timeout=%v interval=%v broadcasts=%d\n", - DiscoTimeout, DiscoInterval, len(c.broadcastAddrs)) + fmt.Printf("[DISCO] Discovery: target=%s timeout=%v interval=%v\n", + c.addr, DiscoTimeout, DiscoInterval) fmt.Printf("[DISCO] SessionID=%s\n", hex.EncodeToString(c.sessionID)) fmt.Printf("[OLD] TX Discovery packet (%d bytes):\n%s", len(oldDiscoPkt), hexDump(crypto.ReverseTransCodeBlob(oldDiscoPkt))) fmt.Printf("[NEW] TX Discovery packet (%d bytes):\n%s", len(newDiscoPkt), hexDump(newDiscoPkt)) @@ -406,12 +409,9 @@ func (c *Conn) discovery() error { buf := make([]byte, MaxPacketSize) for time.Now().Before(deadline) { - // Send both discovery packets periodically if time.Since(lastSend) >= DiscoInterval { - for _, bcast := range c.broadcastAddrs { - c.udpConn.WriteToUDP(oldDiscoPkt, bcast) // OLD protocol - c.udpConn.WriteToUDP(newDiscoPkt, bcast) // NEW protocol - } + c.udpConn.WriteToUDP(oldDiscoPkt, c.addr) + c.udpConn.WriteToUDP(newDiscoPkt, c.addr) lastSend = time.Now() } @@ -424,6 +424,10 @@ func (c *Conn) discovery() error { return err } + if !addr.IP.Equal(c.addr.IP) { + continue + } + // Check for NEW protocol response (0xCC51 magic) if n >= 12 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { cmd := binary.LittleEndian.Uint16(buf[4:]) @@ -448,8 +452,7 @@ func (c *Conn) discovery() error { } if c.verbose { - fmt.Printf("[NEW] Camera detected! ticket=0x%04x sessionID=%s\n", - ticket, hex.EncodeToString(c.sessionID)) + fmt.Printf("[NEW] RX Discovery Response seq=1 (%d bytes):\n%s", n, hexDump(buf[:n])) } _ = c.udpConn.SetDeadline(time.Time{}) @@ -571,7 +574,8 @@ func (c *Conn) newProtoComplete() error { if cmd == CmdNewProtoDiscovery && dir == 0xFFFF && seq == 3 { if c.verbose { - fmt.Printf("[NEW] seq=3 received, discovery complete!\n") + fmt.Printf("[NEW] RX Echo Response seq=3 (%d bytes):\n%s", n, hexDump(buf[:n])) + fmt.Printf("[NEW] Discovery complete!\n") } c.addr = addr return nil @@ -634,8 +638,13 @@ func (c *Conn) iotcReader() { return } - if addr.Port != c.addr.Port || !addr.IP.Equal(c.addr.IP) { - c.addr = addr + if !addr.IP.Equal(c.addr.IP) { + continue + } + + // Update port if camera responds from different port + if addr.Port != c.addr.Port { + c.addr.Port = addr.Port } // Check for NEW protocol (0xCC51 magic at start) @@ -823,10 +832,6 @@ func (c *Conn) worker() { } func (c *Conn) route(data []byte) { - // [channel][frameType][version_lo][version_hi][seq_lo][seq_hi]... - // channel: 0x03=Audio, 0x05=I-Video, 0x07=P-Video - // frameType: 0x00=cont, 0x05=end, 0x08=I-start, 0x0d=end-44 - if len(data) < 2 { return } @@ -1334,6 +1339,17 @@ func (c *Conn) buildNewProtoPacket(seq, ticket uint16, isResponse bool) []byte { authBytes := h.Sum(nil) copy(pkt[32:52], authBytes) + if c.verbose { + fmt.Printf("[AUTH] Discovery Auth Debug:\n") + fmt.Printf("[AUTH] ENR: %s\n", c.enr) + fmt.Printf("[AUTH] MAC: %s\n", c.mac) + fmt.Printf("[AUTH] UID: %s\n", c.uid) + fmt.Printf("[AUTH] AuthKey: %x\n", authKey) + fmt.Printf("[AUTH] HMAC Key (UID+AuthKey): %x\n", key) + fmt.Printf("[AUTH] Hash Input (32 bytes): %x\n", pkt[:32]) + fmt.Printf("[AUTH] Auth Bytes: %x\n", authBytes) + } + return pkt } @@ -1360,14 +1376,25 @@ func (c *Conn) buildNewProtoDTLS(payload []byte, channel byte) []byte { binary.LittleEndian.PutUint32(pkt[24:], 1) // Always 1 for DTLS wrapper copy(pkt[NewProtoHeaderSize:], payload) - // Add Auth bytes at the end: HMAC-SHA1(UID+AuthKey, packet_header) + // Add Auth bytes at the end: HMAC-SHA1(UID+AuthKey, header only) authKey := crypto.CalculateAuthKey(c.enr, c.mac) key := append([]byte(c.uid), authKey...) h := hmac.New(sha1.New, key) - h.Write(pkt[:NewProtoHeaderSize]) // Hash the header portion + h.Write(pkt[:NewProtoHeaderSize]) // Hash the header portion only authBytes := h.Sum(nil) copy(pkt[NewProtoHeaderSize+len(payload):], authBytes) + if c.verbose { + fmt.Printf("[AUTH] DTLS Auth Debug:\n") + fmt.Printf("[AUTH] ENR: %s\n", c.enr) + fmt.Printf("[AUTH] MAC: %s\n", c.mac) + fmt.Printf("[AUTH] UID: %s\n", c.uid) + fmt.Printf("[AUTH] AuthKey: %x\n", authKey) + fmt.Printf("[AUTH] HMAC Key (UID+AuthKey): %x\n", key) + fmt.Printf("[AUTH] Hash Input (Header 28 bytes): %x\n", pkt[:NewProtoHeaderSize]) + fmt.Printf("[AUTH] Auth Bytes: %x\n", authBytes) + } + return pkt } @@ -1742,78 +1769,34 @@ func (c *Conn) logAudioTX(frame []byte, codec uint16, payloadLen int, timestampU } } +func derivePSK(enr string) []byte { + // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) + // contains a 0x00 byte, the PSK is truncated at that position. + // This matches iOS Wyze app behavior discovered via Frida instrumentation. + + hash := sha256.Sum256([]byte(enr)) + + // Find first NULL byte - TUTK uses strlen() on binary PSK + pskLen := 32 + for i := range 32 { + if hash[i] == 0x00 { + pskLen = i + break + } + } + + // Create PSK: bytes up to first 0x00, rest padded with zeros + psk := make([]byte, 32) + copy(psk[:pskLen], hash[:pskLen]) + return psk +} + func genRandomID() []byte { b := make([]byte, 8) _, _ = rand.Read(b) return b } -func getBroadcastAddrs(port int, verbose bool) []*net.UDPAddr { - var addrs []*net.UDPAddr - - ifaces, err := net.Interfaces() - if err != nil { - if verbose { - fmt.Printf("[IOTC] Failed to get interfaces: %v\n", err) - } - // Fallback to limited broadcast - return []*net.UDPAddr{{IP: net.IPv4(255, 255, 255, 255), Port: port}} - } - - for _, iface := range ifaces { - // Skip loopback and down interfaces - if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { - continue - } - - ifAddrs, err := iface.Addrs() - if err != nil { - continue - } - - for _, addr := range ifAddrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - - // Only IPv4 - ip4 := ipNet.IP.To4() - if ip4 == nil { - continue - } - - // Calculate broadcast address: IP | ~mask - mask := ipNet.Mask - if len(mask) != 4 { - continue - } - - broadcast := make(net.IP, 4) - for i := 0; i < 4; i++ { - broadcast[i] = ip4[i] | ^mask[i] - } - - bcastAddr := &net.UDPAddr{IP: broadcast, Port: port} - addrs = append(addrs, bcastAddr) - - if verbose { - fmt.Printf("[IOTC] Found broadcast address: %s (iface: %s)\n", bcastAddr, iface.Name) - } - } - } - - if len(addrs) == 0 { - // Fallback to limited broadcast - if verbose { - fmt.Printf("[IOTC] No broadcast addresses found, using 255.255.255.255\n") - } - return []*net.UDPAddr{{IP: net.IPv4(255, 255, 255, 255), Port: port}} - } - - return addrs -} - func hexDump(data []byte) string { var result string for i := 0; i < len(data); i += 16 { diff --git a/pkg/wyze/tutk/constants.go b/pkg/wyze/tutk/constants.go index 84e867e1..5645f969 100644 --- a/pkg/wyze/tutk/constants.go +++ b/pkg/wyze/tutk/constants.go @@ -164,6 +164,8 @@ const ( KCmdChallenge = 10001 KCmdChallengeResp = 10002 KCmdAuthResult = 10003 + KCmdAuthWithPayload = 10008 + KCmdAuthSuccess = 10009 KCmdControlChannel = 10010 KCmdControlChannelResp = 10011 KCmdSetResolution = 10056 From 406159cce537fd4736d38bb20623534ab106966c Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 03:15:48 +0100 Subject: [PATCH 189/241] refactor --- pkg/wyze/client.go | 384 +++---- pkg/wyze/tutk/avframe.go | 126 --- pkg/wyze/tutk/channel.go | 64 -- pkg/wyze/tutk/conn.go | 1936 ++++++++++-------------------------- pkg/wyze/tutk/constants.go | 306 ------ pkg/wyze/tutk/dtls.go | 74 ++ pkg/wyze/tutk/frame.go | 505 ++++++++++ pkg/wyze/tutk/proto.go | 278 ++++++ pkg/wyze/tutk/types.go | 157 --- 9 files changed, 1521 insertions(+), 2309 deletions(-) delete mode 100644 pkg/wyze/tutk/avframe.go delete mode 100644 pkg/wyze/tutk/channel.go delete mode 100644 pkg/wyze/tutk/constants.go create mode 100644 pkg/wyze/tutk/dtls.go create mode 100644 pkg/wyze/tutk/frame.go create mode 100644 pkg/wyze/tutk/proto.go delete mode 100644 pkg/wyze/tutk/types.go diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index dee4b4d6..ab1394b8 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -14,6 +14,47 @@ import ( "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" ) +const ( + FrameSize1080P = 0 + FrameSize360P = 1 + FrameSize720P = 2 + FrameSize2K = 3 +) + +const ( + BitrateMax uint16 = 0xF0 + BitrateSD uint16 = 0x3C +) + +const ( + QualityUnknown = 0 + QualityMax = 1 + QualityHigh = 2 + QualityMiddle = 3 + QualityLow = 4 + QualityMin = 5 +) + +const ( + MediaTypeVideo = 1 + MediaTypeAudio = 2 + MediaTypeReturnAudio = 3 + MediaTypeRDT = 4 +) + +const ( + KCmdAuth = 10000 + KCmdChallenge = 10001 + KCmdChallengeResp = 10002 + KCmdAuthResult = 10003 + KCmdAuthWithPayload = 10008 + KCmdAuthSuccess = 10009 + KCmdControlChannel = 10010 + KCmdControlChannelResp = 10011 + KCmdSetResolution = 10056 + KCmdSetResolutionResp = 10057 +) + type Client struct { conn *tutk.Conn @@ -36,6 +77,11 @@ type Client struct { audioChannels uint8 } +type AuthResponse struct { + ConnectionRes string `json:"connectionRes"` + CameraInfo map[string]any `json:"cameraInfo"` +} + func Dial(rawURL string) (*Client, error) { u, err := url.Parse(rawURL) if err != nil { @@ -107,11 +153,11 @@ func (c *Client) SetResolution(sd bool) error { var bitrate uint16 if sd { - frameSize = tutk.FrameSize360P - bitrate = tutk.BitrateSD + frameSize = FrameSize360P + bitrate = BitrateSD } else { - frameSize = tutk.FrameSize2K - bitrate = tutk.BitrateMax + frameSize = FrameSize2K + bitrate = BitrateMax } if c.verbose { @@ -119,120 +165,33 @@ func (c *Client) SetResolution(sd bool) error { } k10056 := c.buildK10056(frameSize, bitrate) - if err := c.conn.SendIOCtrl(tutk.KCmdSetResolution, k10056); err != nil { - return fmt.Errorf("wyze: K10056 send failed: %w", err) - } - - // Wait for K10057 response - cmdID, data, err := c.conn.RecvIOCtrl(1 * time.Second) - if err != nil { - return err - } - - if c.verbose { - fmt.Printf("[Wyze] SetResolution response: K%d (%d bytes)\n", cmdID, len(data)) - } - - if cmdID == tutk.KCmdSetResolutionResp && len(data) >= 17 { - result := data[16] - if c.verbose { - fmt.Printf("[Wyze] K10057 result: %d\n", result) - } - } - - return nil + _, err := c.conn.WriteAndWaitIOCtrl(KCmdSetResolution, k10056, KCmdSetResolutionResp, 5*time.Second) + return err } func (c *Client) StartVideo() error { - k10010 := c.buildK10010(tutk.MediaTypeVideo, true) - if c.verbose { - fmt.Printf("[Wyze] TX K10010 video (%d bytes): % x\n", len(k10010), k10010) - } - - if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil { - return fmt.Errorf("K10010 video send failed: %w", err) - } - - // Wait for K10011 response - cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second) - if err != nil { - return fmt.Errorf("K10011 video recv failed: %w", err) - } - - if c.verbose { - fmt.Printf("[Wyze] K10011 video response: cmdID=%d, len=%d\n", cmdID, len(data)) - if len(data) >= 18 { - fmt.Printf("[Wyze] K10011 video: media=%d status=%d\n", data[16], data[17]) - } - } - - return nil + k10010 := c.buildK10010(MediaTypeVideo, true) + _, err := c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second) + return err } func (c *Client) StartAudio() error { - k10010 := c.buildK10010(tutk.MediaTypeAudio, true) - if c.verbose { - fmt.Printf("[Wyze] TX K10010 audio (%d bytes): % x\n", len(k10010), k10010) - } - - if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil { - return fmt.Errorf("K10010 audio send failed: %w", err) - } - - // Wait for K10011 response - cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second) - if err != nil { - return fmt.Errorf("K10011 audio recv failed: %w", err) - } - - if c.verbose { - fmt.Printf("[Wyze] K10011 audio response: cmdID=%d, len=%d\n", cmdID, len(data)) - if len(data) >= 18 { - fmt.Printf("[Wyze] K10011 audio: media=%d status=%d\n", data[16], data[17]) - } - } - - return nil + k10010 := c.buildK10010(MediaTypeAudio, true) + _, err := c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second) + return err } func (c *Client) StartIntercom() error { if c.conn.IsBackchannelReady() { - return nil // Already enabled + return nil } - if c.verbose { - fmt.Printf("[Wyze] Sending K10010 (enable return audio)\n") + k10010 := c.buildK10010(MediaTypeReturnAudio, true) + if _, err := c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second); err != nil { + return err } - k10010 := c.buildK10010(tutk.MediaTypeReturnAudio, true) - if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil { - return fmt.Errorf("K10010 send failed: %w", err) - } - - // Wait for K10011 response - cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second) - if err != nil { - return fmt.Errorf("K10011 recv failed: %w", err) - } - - if c.verbose { - fmt.Printf("[Wyze] K10011 response: cmdID=%d, len=%d\n", cmdID, len(data)) - } - - // Perform DTLS server handshake on backchannel (camera connects to us) - if c.verbose { - fmt.Printf("[Wyze] Starting speaker channel DTLS handshake\n") - } - - if err := c.conn.AVServStart(); err != nil { - return fmt.Errorf("speaker channel handshake failed: %w", err) - } - - if c.verbose { - fmt.Printf("[Wyze] Backchannel ready\n") - } - - return nil + return c.conn.AVServStart() } func (c *Client) ReadPacket() (*tutk.Packet, error) { @@ -324,23 +283,10 @@ func (c *Client) doAVLogin() error { } func (c *Client) doKAuth() error { - if c.verbose { - fmt.Printf("[Wyze] Starting K-command authentication\n") - } - - // Step 1: Send K10000 - k10000 := c.buildK10000() - if err := c.conn.SendIOCtrl(tutk.KCmdAuth, k10000); err != nil { - return fmt.Errorf("wyze: K10000 send failed: %w", err) - } - - // Step 2: Wait for K10001 - cmdID, data, err := c.conn.RecvIOCtrl(10 * time.Second) + // Step 1: K10000 -> K10001 + data, err := c.conn.WriteAndWaitIOCtrl(KCmdAuth, c.buildK10000(), KCmdChallenge, 10*time.Second) if err != nil { - return fmt.Errorf("wyze: K10001 recv failed: %w", err) - } - if cmdID != tutk.KCmdChallenge { - return fmt.Errorf("wyze: expected K10001, got K%d", cmdID) + return fmt.Errorf("wyze: K10001 failed: %w", err) } challenge, status, err := c.parseK10001(data) @@ -348,45 +294,18 @@ func (c *Client) doKAuth() error { return fmt.Errorf("wyze: K10001 parse failed: %w", err) } - if c.verbose { - fmt.Printf("[Wyze] K10001 received, status=%d\n", status) - } - - // Step 3: Send K10008 - k10008 := c.buildK10008(challenge, status) - - if err := c.conn.SendIOCtrl(tutk.KCmdChallengeResp, k10008); err != nil { - return fmt.Errorf("wyze: K10008 send failed: %w", err) - } - - // Step 4: Wait for K10009 - cmdID, data, err = c.conn.RecvIOCtrl(10 * time.Second) + // Step 2: K10002 -> K10009 + data, err = c.conn.WriteAndWaitIOCtrl(KCmdChallengeResp, c.buildK10002(challenge, status), KCmdAuthResult, 10*time.Second) if err != nil { - return fmt.Errorf("wyze: K10009 recv failed: %w", err) + return fmt.Errorf("wyze: K10009 failed: %w", err) } - if cmdID != tutk.KCmdAuthSuccess { - return fmt.Errorf("wyze: expected K10009, got K%d", cmdID) - } - - authResp, err := c.parseK10009(data) - if err != nil { - return fmt.Errorf("wyze: K10009 parse failed: %w", err) - } - - // Parse capabilities + authResp, _ := c.parseK10003(data) if authResp != nil && authResp.CameraInfo != nil { - if c.verbose { - fmt.Printf("[Wyze] CameraInfo authResp: ") - b, _ := json.Marshal(authResp) - fmt.Printf("%s\n", b) - } - - // Audio receiving support if audio, ok := authResp.CameraInfo["audio"].(bool); ok { c.hasAudio = audio } else { - c.hasAudio = true // Default to true + c.hasAudio = true } } else { c.hasAudio = true @@ -394,9 +313,6 @@ func (c *Client) doKAuth() error { if avResp := c.conn.GetAVLoginResponse(); avResp != nil { c.hasIntercom = avResp.TwoWayStreaming == 1 - if c.verbose { - fmt.Printf("[Wyze] two_way_streaming=%d (from AV Login Response)\n", avResp.TwoWayStreaming) - } } if c.verbose { @@ -407,94 +323,72 @@ func (c *Client) doKAuth() error { } func (c *Client) buildK10000() []byte { - // 137 = G.711 μ-law (PCMU) - // 138 = G.711 A-law (PCMA) - // 140 = PCM 16-bit - jsonPayload := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) - - buf := make([]byte, 16+len(jsonPayload)) - buf[0] = 'H' - buf[1] = 'L' - buf[2] = 5 - binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdAuth) - binary.LittleEndian.PutUint16(buf[6:], uint16(len(jsonPayload))) - copy(buf[16:], jsonPayload) - return buf + json := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) // 137=PCMU, 138=PCMA, 140=PCM + b := make([]byte, 16+len(json)) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdAuth) // 10000 + binary.LittleEndian.PutUint16(b[6:], uint16(len(json))) // payload len + copy(b[16:], json) + return b } func (c *Client) buildK10002(challenge []byte, status byte) []byte { - response := crypto.GenerateChallengeResponse(challenge, c.enr, status) - - buf := make([]byte, 38) - buf[0] = 'H' - buf[1] = 'L' - buf[2] = 5 - binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdChallengeResp) - buf[6] = 22 // Payload length - - if len(response) >= 16 { - copy(buf[16:], response[:16]) - } - - if len(c.uid) >= 4 { - copy(buf[32:], c.uid[:4]) - } - - buf[36] = 1 // Video flag (0 = disabled, 1 = enabled > will start video stream immediately) - buf[37] = 1 // Audio flag (0 = disabled, 1 = enabled > will start audio stream immediately) - - return buf + resp := crypto.GenerateChallengeResponse(challenge, c.enr, status) + b := make([]byte, 38) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdChallengeResp) // 10002 + b[6] = 22 // payload len + copy(b[16:], resp[:16]) // challenge response + copy(b[32:], c.uid[:4]) // UID prefix + b[36] = 1 // video enabled + b[37] = 1 // audio enabled + return b } func (c *Client) buildK10008(challenge []byte, status byte) []byte { - response := crypto.GenerateChallengeResponse(challenge, c.enr, status) - openUserID := []byte(c.enr) - payloadLen := 16 + 4 + 1 + 1 + 1 + len(openUserID) - - buf := make([]byte, 16+payloadLen) - buf[0] = 'H' - buf[1] = 'L' - buf[2] = 5 // Protocol version - binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdAuthWithPayload) // 10008 - binary.LittleEndian.PutUint16(buf[6:], uint16(payloadLen)) - - copy(buf[16:], response[:16]) // Challenge response - copy(buf[32:], c.uid[:4]) // UID prefix - buf[36] = 1 // Video enabled - buf[37] = 1 // Audio enabled - buf[38] = byte(len(openUserID)) - copy(buf[39:], openUserID) - - return buf + resp := crypto.GenerateChallengeResponse(challenge, c.enr, status) + userID := []byte(c.enr) + payloadLen := 16 + 4 + 1 + 1 + 1 + len(userID) + b := make([]byte, 16+payloadLen) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdAuthWithPayload) // 10008 + binary.LittleEndian.PutUint16(b[6:], uint16(payloadLen)) // payload len + copy(b[16:], resp[:16]) // challenge response + copy(b[32:], c.uid[:4]) // UID prefix + b[36] = 1 // video enabled + b[37] = 1 // audio enabled + b[38] = byte(len(userID)) // userID len + copy(b[39:], userID) // userID + return b } func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { - buf := make([]byte, 18) - buf[0] = 'H' - buf[1] = 'L' - buf[2] = 5 // Version - binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdControlChannel) // 0x271a = 10010 - binary.LittleEndian.PutUint16(buf[6:], 2) // Payload length = 2 - buf[16] = mediaType // 1=Video, 2=Audio, 3=ReturnAudio - if enabled { - buf[17] = 1 - } else { - buf[17] = 2 + b := make([]byte, 18) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdControlChannel) // 10010 + binary.LittleEndian.PutUint16(b[6:], 2) // payload len + b[16] = mediaType // 1=video, 2=audio, 3=return audio + b[17] = 1 // 1=enable, 2=disable + if !enabled { + b[17] = 2 } - return buf + return b } func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { - buf := make([]byte, 21) - buf[0] = 'H' - buf[1] = 'L' - buf[2] = 5 // Version - binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdSetResolution) // 0x2748 = 10056 - binary.LittleEndian.PutUint16(buf[6:], 5) // Payload length = 5 - buf[16] = frameSize + 1 // 4 = HD - binary.LittleEndian.PutUint16(buf[17:], bitrate) // 0x00f0 = 240 - // buf[19], buf[20] = FPS (0 = auto) - return buf + b := make([]byte, 21) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdSetResolution) // 10056 + binary.LittleEndian.PutUint16(b[6:], 5) // payload len + b[16] = frameSize + 1 // frame size + binary.LittleEndian.PutUint16(b[17:], bitrate) // bitrate + // b[19:21] = FPS (0 = auto) + return b } func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err error) { @@ -511,7 +405,7 @@ func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err er } cmdID := binary.LittleEndian.Uint16(data[4:]) - if cmdID != tutk.KCmdChallenge { + if cmdID != KCmdChallenge { return nil, 0, fmt.Errorf("expected cmdID 10001, got %d", cmdID) } @@ -522,31 +416,31 @@ func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err er return challenge, status, nil } -func (c *Client) parseK10003(data []byte) (*tutk.AuthResponse, error) { +func (c *Client) parseK10003(data []byte) (*AuthResponse, error) { if c.verbose { fmt.Printf("[Wyze] parseK10003: received %d bytes\n", len(data)) } if len(data) < 16 { - return &tutk.AuthResponse{}, nil + return &AuthResponse{}, nil } if data[0] != 'H' || data[1] != 'L' { - return &tutk.AuthResponse{}, nil + return &AuthResponse{}, nil } cmdID := binary.LittleEndian.Uint16(data[4:]) textLen := binary.LittleEndian.Uint16(data[6:]) - if cmdID != tutk.KCmdAuthResult { - return &tutk.AuthResponse{}, nil + if cmdID != KCmdAuthResult { + return &AuthResponse{}, nil } if len(data) > 16 && textLen > 0 { jsonData := data[16:] for i := range jsonData { if jsonData[i] == '{' { - var resp tutk.AuthResponse + var resp AuthResponse if err := json.Unmarshal(jsonData[i:], &resp); err == nil { if c.verbose { fmt.Printf("[Wyze] parseK10003: parsed JSON\n") @@ -558,34 +452,34 @@ func (c *Client) parseK10003(data []byte) (*tutk.AuthResponse, error) { } } - return &tutk.AuthResponse{}, nil + return &AuthResponse{}, nil } -func (c *Client) parseK10009(data []byte) (*tutk.AuthResponse, error) { +func (c *Client) parseK10009(data []byte) (*AuthResponse, error) { if c.verbose { fmt.Printf("[Wyze] parseK10009: received %d bytes\n", len(data)) } if len(data) < 16 { - return &tutk.AuthResponse{}, nil + return &AuthResponse{}, nil } if data[0] != 'H' || data[1] != 'L' { - return &tutk.AuthResponse{}, nil + return &AuthResponse{}, nil } cmdID := binary.LittleEndian.Uint16(data[4:]) textLen := binary.LittleEndian.Uint16(data[6:]) - if cmdID != tutk.KCmdAuthSuccess { - return &tutk.AuthResponse{}, nil + if cmdID != KCmdAuthSuccess { + return &AuthResponse{}, nil } if len(data) > 16 && textLen > 0 { jsonData := data[16:] for i := range jsonData { if jsonData[i] == '{' { - var resp tutk.AuthResponse + var resp AuthResponse if err := json.Unmarshal(jsonData[i:], &resp); err == nil { if c.verbose { fmt.Printf("[Wyze] parseK10009: parsed JSON\n") @@ -597,5 +491,5 @@ func (c *Client) parseK10009(data []byte) (*tutk.AuthResponse, error) { } } - return &tutk.AuthResponse{}, nil + return &AuthResponse{}, nil } diff --git a/pkg/wyze/tutk/avframe.go b/pkg/wyze/tutk/avframe.go deleted file mode 100644 index e6c72313..00000000 --- a/pkg/wyze/tutk/avframe.go +++ /dev/null @@ -1,126 +0,0 @@ -package tutk - -import ( - "encoding/binary" - - "github.com/AlexxIT/go2rtc/pkg/aac" -) - -const FrameInfoSize = 40 - -// Wire format (little-endian) - Wyze extended FRAMEINFO: -// -// [0-1] codec_id uint16 (0x004e=H.264, 0x0050=H.265, 0x0088=AAC) -// [2] flags uint8 (Video: 0=P/1=I, Audio: sr_idx<<2|bits16<<1|ch) -// [3] cam_index uint8 -// [4] online_num uint8 -// [5] framerate uint8 (FPS, e.g. 20) -// [6] frame_size uint8 (Resolution: 1=1080P, 2=360P, 4=2K) -// [7] bitrate uint8 (e.g. 0xF0=240) -// [8-11] timestamp_us uint32 (microseconds component) -// [12-15] timestamp uint32 (Unix timestamp in seconds) -// [16-19] payload_sz uint32 (frame payload size) -// [20-23] frame_no uint32 (frame number) -// [24-39] device_id 16 bytes (MAC address + padding) -type FrameInfo struct { - CodecID uint16 - Flags uint8 - CamIndex uint8 - OnlineNum uint8 - Framerate uint8 // FPS (e.g. 20) - FrameSize uint8 // Resolution: 1=1080P, 2=360P, 4=2K - Bitrate uint8 // Bitrate value (e.g. 240) - TimestampUS uint32 - Timestamp uint32 - PayloadSize uint32 - FrameNo uint32 -} - -// Resolution constants (as received in FrameSize field) -// Note: Some cameras only support 2K + 360P, others support 1080P + 360P -// The actual resolution depends on camera model! -const ( - ResolutionUnknown = 0 - ResolutionSD = 1 // 360P (640x360) on 2K cameras, or 1080P on older cams - Resolution360P = 2 // 360P (640x360) - Resolution2K = 4 // 2K (2560x1440) -) - -func (fi *FrameInfo) IsKeyframe() bool { - return fi.Flags == 0x01 -} - -// Resolution returns a human-readable resolution string -func (fi *FrameInfo) Resolution() string { - switch fi.FrameSize { - case ResolutionSD: - return "SD" // Could be 360P or 1080P depending on camera - case Resolution360P: - return "360P" - case Resolution2K: - return "2K" - default: - return "unknown" - } -} - -func (fi *FrameInfo) SampleRate() uint32 { - srIdx := (fi.Flags >> 2) & 0x0F - return uint32(SampleRateValue(srIdx)) -} - -func (fi *FrameInfo) Channels() uint8 { - if fi.Flags&0x01 == 1 { - return 2 - } - return 1 -} - -func (fi *FrameInfo) IsVideo() bool { - return IsVideoCodec(fi.CodecID) -} - -func (fi *FrameInfo) IsAudio() bool { - return IsAudioCodec(fi.CodecID) -} - -func ParseFrameInfo(data []byte) *FrameInfo { - if len(data) < FrameInfoSize { - return nil - } - - offset := len(data) - FrameInfoSize - fi := data[offset:] - - return &FrameInfo{ - CodecID: binary.LittleEndian.Uint16(fi), - Flags: fi[2], - CamIndex: fi[3], - OnlineNum: fi[4], - Framerate: fi[5], - FrameSize: fi[6], - Bitrate: fi[7], - TimestampUS: binary.LittleEndian.Uint32(fi[8:]), - Timestamp: binary.LittleEndian.Uint32(fi[12:]), - PayloadSize: binary.LittleEndian.Uint32(fi[16:]), - FrameNo: binary.LittleEndian.Uint32(fi[20:]), - } -} - -func ParseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { - // Try ADTS header first (more reliable than FRAMEINFO flags) - if aac.IsADTS(payload) { - codec := aac.ADTSToCodec(payload) - if codec != nil { - return codec.ClockRate, codec.Channels - } - } - - // Fallback to FRAMEINFO flags - if fi != nil { - return fi.SampleRate(), fi.Channels() - } - - // Default values - return 16000, 1 -} diff --git a/pkg/wyze/tutk/channel.go b/pkg/wyze/tutk/channel.go deleted file mode 100644 index 4fc25e33..00000000 --- a/pkg/wyze/tutk/channel.go +++ /dev/null @@ -1,64 +0,0 @@ -package tutk - -import ( - "fmt" - "net" - "time" -) - -type ChannelAdapter struct { - conn *Conn - channel uint8 -} - -func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { - var buf chan []byte - if a.channel == IOTCChannelMain { - buf = a.conn.mainBuf - } else { - buf = a.conn.speakerBuf - } - - select { - case data := <-buf: - n = copy(p, data) - if a.conn.verbose && len(data) >= 1 { - fmt.Printf("[ChannelAdapter] ch=%d ReadFrom: len=%d contentType=%d\n", - a.channel, len(data), data[0]) - } - return n, a.conn.addr, nil - case <-a.conn.done: - return 0, nil, net.ErrClosed - } -} - -func (a *ChannelAdapter) WriteTo(p []byte, addr net.Addr) (n int, err error) { - if a.conn.verbose { - fmt.Printf("[IOTC TX] channel=%d size=%d\n", a.channel, len(p)) - } - _, err = a.conn.sendIOTC(p, a.channel) - if err != nil { - return 0, err - } - return len(p), nil -} - -func (a *ChannelAdapter) Close() error { - return nil -} - -func (a *ChannelAdapter) LocalAddr() net.Addr { - return &net.UDPAddr{IP: net.IPv4(0, 0, 0, 0), Port: 0} -} - -func (a *ChannelAdapter) SetDeadline(time.Time) error { - return nil -} - -func (a *ChannelAdapter) SetReadDeadline(time.Time) error { - return nil -} - -func (a *ChannelAdapter) SetWriteDeadline(time.Time) error { - return nil -} diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 58f8bc9d..962f9166 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -19,86 +19,75 @@ import ( ) const ( - PSKIdentity = "AUTHPWD_admin" - DefaultUser = "admin" - DefaultPort = 32761 // TUTK discovery port - MaxPacketSize = 2048 // Max single packet size - ReadBufferSize = 2 * 1024 * 1024 // 2MB for video streams - - DiscoTimeout = 5000 * time.Millisecond // Total timeout for discovery - DiscoInterval = 100 * time.Millisecond // Interval between discovery packets - SessionTimeout = 5000 * time.Millisecond // Total timeout for session setup - ReadWaitInterval = 50 * time.Millisecond // Read wait interval per iteration + MaxPacketSize = 2048 + ReadBufferSize = 2 * 1024 * 1024 + DiscoTimeout = 5000 * time.Millisecond + DiscoInterval = 100 * time.Millisecond + SessionTimeout = 5000 * time.Millisecond + ReadWaitInterval = 50 * time.Millisecond ) -type FrameAssembler struct { - frameNo uint32 - pktTotal uint16 - packets map[uint16][]byte // pkt_idx -> payload - frameInfo *FrameInfo -} - type Conn struct { - udpConn *net.UDPConn - addr *net.UDPAddr - randomID []byte - uid string - authKey string - enr string - mac string // MAC address for auth key calculation - psk []byte - iotcTxSeq uint16 - avLoginResp *AVLoginResponse + conn *net.UDPConn + addr *net.UDPAddr - useNewProto bool // true if camera uses NEW protocol - newProtoTicket uint16 // ticket from camera response - sessionID []byte // 8-byte session ID for NEW protocol + // Identity + uid string + authKey string + enr string + mac string + psk []byte + rid []byte - // DTLS - Main Channel (we = Client) - mainConn *dtls.Conn + // Session + sid []byte + ticket uint16 + avResp *AVLoginResponse + + // Protocol + newProto bool + seq uint16 + seqCmd uint16 + avSeq uint32 + + // DTLS + main *dtls.Conn + speaker *dtls.Conn mainBuf chan []byte + speakBuf chan []byte - // DTLS - Speaker Channel (we = Server) - speakerConn *dtls.Conn - speakerBuf chan []byte + // Channels + rawCmd chan []byte - ioctrl chan []byte - ackReceived chan struct{} - errors chan error + // Audio TX + audioSeq uint32 + audioFrame uint32 - frameAssemblers map[byte]*FrameAssembler // channel -> assembler - packetQueue chan *Packet + // Frame assembly + frames *FrameHandler + ackFlags uint16 - avTxSeq uint32 - ioctrlSeq uint16 - - // Audio TX state (for intercom) - audioTxSeq uint32 - audioTxFrameNo uint32 - - lastAckCounter uint16 - ackFlags uint16 - - baseTS uint64 - - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - mu sync.RWMutex - done chan struct{} + // State + err error verbose bool + + // Sync + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex + cmdAck func() } func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { - conn, err := net.ListenUDP("udp", nil) + udp, err := net.ListenUDP("udp", nil) if err != nil { return nil, err } - _ = conn.SetReadBuffer(ReadBufferSize) + _ = udp.SetReadBuffer(ReadBufferSize) ctx, cancel := context.WithCancel(context.Background()) - psk := derivePSK(enr) if verbose { @@ -108,24 +97,17 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { } c := &Conn{ - udpConn: conn, - addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, - randomID: genRandomID(), - uid: uid, - authKey: authKey, - enr: enr, - mac: mac, - psk: psk, - verbose: verbose, - ctx: ctx, - cancel: cancel, - mainBuf: make(chan []byte, 64), - speakerBuf: make(chan []byte, 64), - packetQueue: make(chan *Packet, 128), - done: make(chan struct{}), - ioctrl: make(chan []byte, 16), - ackReceived: make(chan struct{}, 1), - errors: make(chan error, 1), + conn: udp, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, + rid: genRandomID(), + uid: uid, + authKey: authKey, + enr: enr, + mac: mac, + psk: psk, + verbose: verbose, + ctx: ctx, + cancel: cancel, } if err = c.discovery(); err != nil { @@ -133,17 +115,19 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { return nil, err } - // Start IOTC reader goroutine for DTLS routing - c.wg.Add(1) - go c.iotcReader() + c.mainBuf = make(chan []byte, 64) + c.speakBuf = make(chan []byte, 64) + c.rawCmd = make(chan []byte, 16) + c.frames = NewFrameHandler(c.verbose) + + c.wg.Add(1) + go c.reader() - // Perform DTLS client handshake on Main channel if err = c.connect(); err != nil { _ = c.Close() return nil, err } - // Start AV data worker c.wg.Add(1) go c.worker() @@ -156,45 +140,43 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { pkt2 := c.buildAVLoginPacket(MagicAVLogin2, 572, 0x0000, randomID) pkt2[20]++ // pkt2 has randomID incremented by 1 - if _, err := c.mainConn.Write(pkt1); err != nil { + if _, err := c.main.Write(pkt1); err != nil { return fmt.Errorf("AV login 1 failed: %w", err) } time.Sleep(50 * time.Millisecond) - if _, err := c.mainConn.Write(pkt2); err != nil { + if _, err := c.main.Write(pkt2); err != nil { return fmt.Errorf("AV login 2 failed: %w", err) } // Wait for response - deadline := time.Now().Add(timeout) + timer := time.NewTimer(timeout) + defer timer.Stop() for { - remaining := time.Until(deadline) - if remaining <= 0 { - return context.DeadlineExceeded - } - select { - case data, ok := <-c.ioctrl: + case data, ok := <-c.rawCmd: if !ok { return io.EOF } if len(data) >= 32 && binary.LittleEndian.Uint16(data) == MagicAVLoginResp { - c.avLoginResp = &AVLoginResponse{ + c.avResp = &AVLoginResponse{ ServerType: binary.LittleEndian.Uint32(data[4:]), Resend: int32(data[29]), TwoWayStreaming: int32(data[31]), } if c.verbose { - fmt.Printf("[TUTK] AV Login Response: two_way_streaming=%d\n", c.avLoginResp.TwoWayStreaming) + fmt.Printf("[TUTK] AV Login Response: two_way_streaming=%d\n", c.avResp.TwoWayStreaming) } - _ = c.sendACK() + ack := c.buildACK() + c.main.Write(ack) + return nil } - case <-c.ctx.Done(): - return c.ctx.Err() + case <-timer.C: + return context.DeadlineExceeded } } } @@ -206,21 +188,13 @@ func (c *Conn) AVServStart() error { fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) } - config := c.buildDTLSConfig(true) - - // Create adapter for speaker channel - adapter := &ChannelAdapter{ - conn: c, - channel: IOTCChannelBack, - } - - conn, err := dtls.Server(adapter, c.addr, config) + conn, err := NewDtlsServer(c, IOTCChannelBack, c.psk) if err != nil { return fmt.Errorf("dtls: server handshake failed: %w", err) } c.mu.Lock() - c.speakerConn = conn + c.speaker = conn c.mu.Unlock() if c.verbose { @@ -240,12 +214,12 @@ func (c *Conn) AVServStop() error { defer c.mu.Unlock() // Reset audio TX state - c.audioTxSeq = 0 - c.audioTxFrameNo = 0 + c.audioSeq = 0 + c.audioFrame = 0 - if c.speakerConn != nil { - err := c.speakerConn.Close() - c.speakerConn = nil + if c.speaker != nil { + err := c.speaker.Close() + c.speaker = nil return err } return nil @@ -253,23 +227,19 @@ func (c *Conn) AVServStop() error { func (c *Conn) AVRecvFrameData() (*Packet, error) { select { - case pkt, ok := <-c.packetQueue: + case pkt, ok := <-c.frames.Recv(): if !ok { - return nil, io.EOF + return nil, c.Error() } return pkt, nil - case err := <-c.errors: - return nil, err - case <-c.done: - return nil, io.EOF case <-c.ctx.Done(): - return nil, io.EOF + return nil, c.Error() } } func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { c.mu.Lock() - conn := c.speakerConn + conn := c.speaker if conn == nil { c.mu.Unlock() return fmt.Errorf("speaker channel not connected") @@ -277,9 +247,6 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, frame := c.buildAudioFrame(payload, timestampUS, codec, sampleRate, channels) - if c.verbose { - c.logAudioTX(frame, codec, len(payload), timestampUS, sampleRate, channels) - } c.mu.Unlock() n, err := conn.Write(frame) @@ -293,56 +260,105 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, return err } -func (c *Conn) SendIOCtrl(cmdID uint16, payload []byte) error { - frame := c.buildIOCtrlFrame(payload) - if _, err := c.mainConn.Write(frame); err != nil { +func (c *Conn) Write(data []byte) error { + if c.newProto { + _, err := c.conn.WriteToUDP(data, c.addr) return err } + _, err := c.conn.WriteToUDP(crypto.TransCodeBlob(data), c.addr) + return err +} - select { - case <-c.ackReceived: - if c.verbose { - fmt.Printf("[Conn] SendIOCtrl K%d: ACK received\n", cmdID) +func (c *Conn) WriteDTLS(payload []byte, channel byte) error { + var frame []byte + if c.newProto { + frame = c.buildNewTxData(payload, channel) + } else { + frame = c.buildTxData(payload, channel) + } + return c.Write(frame) +} + +func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byte) bool) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + if err := c.Write(req); err == nil && t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + _ = c.conn.SetDeadline(time.Now().Add(timeout)) + defer c.conn.SetDeadline(time.Time{}) + + buf := make([]byte, MaxPacketSize) + for { + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + return nil, err + } + if string(addr.IP) != string(c.addr.IP) || n < 16 { + continue + } + + var res []byte + if c.newProto { + res = buf[:n] + } else { + res = crypto.ReverseTransCodeBlob(buf[:n]) + } + + if ok(res) { + c.addr.Port = addr.Port + return res, nil } - return nil - case <-time.After(5 * time.Second): - return fmt.Errorf("ACK timeout for K%d", cmdID) - case <-c.ctx.Done(): - return c.ctx.Err() } } -func (c *Conn) RecvIOCtrl(timeout time.Duration) (cmdID uint16, data []byte, err error) { - select { - case data, ok := <-c.ioctrl: - if !ok { - return 0, nil, io.EOF +func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, timeout time.Duration) ([]byte, error) { + frame := c.buildIOCtrlFrame(payload) + + // Retry send every second + var t *time.Timer + t = time.AfterFunc(1, func() { + if _, err := c.main.Write(frame); err == nil && t != nil { + t.Reset(time.Second) } - // Parse cmdID from HL header at offset 4-5 - if len(data) >= 6 { - cmdID = binary.LittleEndian.Uint16(data[4:]) + }) + defer t.Stop() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case data, ok := <-c.rawCmd: + if !ok { + return nil, io.EOF + } + + ack := c.buildACK() + c.main.Write(ack) + + if len(data) >= 6 { + if binary.LittleEndian.Uint16(data[4:]) == expectCmd { + return data, nil + } + } + case <-timer.C: + return nil, fmt.Errorf("timeout waiting for K%d", expectCmd) } - // Send ACK after receiving - _ = c.sendACK() - if c.verbose { - fmt.Printf("[Conn] RecvIOCtrl: received K%d (%d bytes)\n", cmdID, len(data)) - } - return cmdID, data, nil - case <-time.After(timeout): - return 0, nil, context.DeadlineExceeded - case <-c.ctx.Done(): - return 0, nil, c.ctx.Err() } } func (c *Conn) GetAVLoginResponse() *AVLoginResponse { - return c.avLoginResp + return c.avResp } func (c *Conn) IsBackchannelReady() bool { c.mu.RLock() defer c.mu.RUnlock() - return c.speakerConn != nil + return c.speaker != nil } func (c *Conn) RemoteAddr() *net.UDPAddr { @@ -350,240 +366,109 @@ func (c *Conn) RemoteAddr() *net.UDPAddr { } func (c *Conn) LocalAddr() *net.UDPAddr { - return c.udpConn.LocalAddr().(*net.UDPAddr) + return c.conn.LocalAddr().(*net.UDPAddr) } func (c *Conn) SetDeadline(t time.Time) error { - return c.udpConn.SetDeadline(t) + return c.conn.SetDeadline(t) } func (c *Conn) Close() error { - select { - case <-c.done: - default: - close(c.done) - } + c.cancel() c.mu.Lock() - if c.mainConn != nil { - c.mainConn.Close() - c.mainConn = nil + if c.main != nil { + c.main.Close() + c.main = nil } - if c.speakerConn != nil { - c.speakerConn.Close() - c.speakerConn = nil + if c.speaker != nil { + c.speaker.Close() + c.speaker = nil + } + if c.frames != nil { + c.frames.Close() } c.mu.Unlock() - c.cancel() c.wg.Wait() - close(c.ioctrl) - close(c.errors) + return c.conn.Close() +} - return c.udpConn.Close() +func (c *Conn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF } func (c *Conn) discovery() error { - _ = c.udpConn.SetDeadline(time.Now().Add(10 * time.Second)) + c.sid = make([]byte, 8) + rand.Read(c.sid[:2]) + copy(c.sid[2:], []byte{0x76, 0x0a, 0x9d, 0x24, 0x88, 0xba}) - // Generate 8-byte session ID for NEW protocol - c.sessionID = make([]byte, 8) - rand.Read(c.sessionID[:2]) - copy(c.sessionID[2:], []byte{0x76, 0x0a, 0x9d, 0x24, 0x88, 0xba}) - - // Build discovery packets for both protocols - oldDiscoPkt := crypto.TransCodeBlob(c.buildDisco(1)) // OLD protocol (TransCode encoded) - newDiscoPkt := c.buildNewProtoPacket(0, 0, false) // NEW protocol (0xCC51, cmd=0x1002) - - if c.verbose { - fmt.Printf("[DISCO] Discovery: target=%s timeout=%v interval=%v\n", - c.addr, DiscoTimeout, DiscoInterval) - fmt.Printf("[DISCO] SessionID=%s\n", hex.EncodeToString(c.sessionID)) - fmt.Printf("[OLD] TX Discovery packet (%d bytes):\n%s", len(oldDiscoPkt), hexDump(crypto.ReverseTransCodeBlob(oldDiscoPkt))) - fmt.Printf("[NEW] TX Discovery packet (%d bytes):\n%s", len(newDiscoPkt), hexDump(newDiscoPkt)) - } - - deadline := time.Now().Add(DiscoTimeout) - lastSend := time.Time{} + oldPkt := crypto.TransCodeBlob(c.buildDisco(1)) + newPkt := c.buildNewDisco(0, 0, false) buf := make([]byte, MaxPacketSize) + deadline := time.Now().Add(DiscoTimeout) for time.Now().Before(deadline) { - if time.Since(lastSend) >= DiscoInterval { - c.udpConn.WriteToUDP(oldDiscoPkt, c.addr) - c.udpConn.WriteToUDP(newDiscoPkt, c.addr) - lastSend = time.Now() - } + c.conn.WriteToUDP(oldPkt, c.addr) + c.conn.WriteToUDP(newPkt, c.addr) - c.udpConn.SetReadDeadline(time.Now().Add(ReadWaitInterval)) - n, addr, err := c.udpConn.ReadFromUDP(buf) + c.conn.SetReadDeadline(time.Now().Add(DiscoInterval)) + n, addr, err := c.conn.ReadFromUDP(buf) if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - return err + continue } - if !addr.IP.Equal(c.addr.IP) { continue } - // Check for NEW protocol response (0xCC51 magic) - if n >= 12 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { - cmd := binary.LittleEndian.Uint16(buf[4:]) - dir := binary.LittleEndian.Uint16(buf[8:]) - - if c.verbose { - fmt.Printf("[NEW] RX %d bytes <- %s (cmd=0x%04x dir=0x%04x)\n", n, addr, cmd, dir) - } - - // Handle cmd=0x1002 seq=1 discovery response - if cmd == CmdNewProtoDiscovery && n >= NewProtoPacketSize && dir == 0xFFFF { - seq := binary.LittleEndian.Uint16(buf[12:]) - ticket := binary.LittleEndian.Uint16(buf[14:]) - - if seq == 1 { - c.addr = addr - c.newProtoTicket = ticket - c.useNewProto = true - - if n >= 24 { - copy(c.sessionID, buf[16:24]) - } - - if c.verbose { - fmt.Printf("[NEW] RX Discovery Response seq=1 (%d bytes):\n%s", n, hexDump(buf[:n])) - } - - _ = c.udpConn.SetDeadline(time.Time{}) - return c.newProtoComplete() + // NEW protocol + if n >= NewPacketSize && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + if binary.LittleEndian.Uint16(buf[4:]) == CmdNewDisco { + c.addr, c.newProto, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) + if n >= 24 { + copy(c.sid, buf[16:24]) } + return c.newDiscoDone() } continue } - // Check for OLD protocol response (TransCode encoded) + // OLD protocol data := crypto.ReverseTransCodeBlob(buf[:n]) - if len(data) >= 16 { - cmd := binary.LittleEndian.Uint16(data[8:]) - - if c.verbose { - fmt.Printf("[OLD] RX %d bytes <- %s (cmd=0x%04x)\n%s", n, addr, cmd, hexDump(data)) - } - - if cmd == CmdDiscoRes { - c.addr = addr - c.useNewProto = false - - if c.verbose { - fmt.Printf("[OLD] Camera detected at %s\n", addr) - } - - _ = c.udpConn.SetDeadline(time.Time{}) - return c.oldProtoComplete() - } + if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == CmdDiscoRes { + c.addr, c.newProto = addr, false + return c.oldDiscoDone() } } - _ = c.udpConn.SetDeadline(time.Time{}) - return fmt.Errorf("discovery timeout - no camera response") + return fmt.Errorf("discovery timeout") } -func (c *Conn) oldProtoComplete() error { - // Stage 2 - pkt := c.buildDisco(2) - if c.verbose { - fmt.Printf("[OLD] TX Stage 2 Discovery (%d bytes):\n%s", len(pkt), hexDump(pkt)) - } - encrypted := crypto.TransCodeBlob(pkt) - c.udpConn.WriteToUDP(encrypted, c.addr) +func (c *Conn) oldDiscoDone() error { + c.Write(c.buildDisco(2)) time.Sleep(100 * time.Millisecond) - // Session setup - sessionPkt := c.buildSession() - if _, err := c.sendEncrypted(sessionPkt); err != nil { - return err - } - - buf := make([]byte, MaxPacketSize) - deadline := time.Now().Add(SessionTimeout) - - for time.Now().Before(deadline) { - c.udpConn.SetReadDeadline(time.Now().Add(ReadWaitInterval)) - n, addr, err := c.udpConn.ReadFromUDP(buf) - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - return err - } - - data := crypto.ReverseTransCodeBlob(buf[:n]) - if len(data) < 16 { - continue - } - - cmd := binary.LittleEndian.Uint16(data[8:]) - if c.verbose { - fmt.Printf("[OLD] RX %d bytes (cmd=0x%04x)\n%s", len(data), cmd, hexDump(data)) - } - if cmd == CmdSessionRes { - c.addr = addr - if c.verbose { - fmt.Printf("[OLD] Session setup complete!\n") - } - return nil - } - } - - return fmt.Errorf("OLD protocol session timeout") + _, err := c.WriteAndWait(c.buildSession(), SessionTimeout, func(res []byte) bool { + return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == CmdSessionRes + }) + return err } -func (c *Conn) newProtoComplete() error { - pkt2 := c.buildNewProtoPacket(2, c.newProtoTicket, false) - - if c.verbose { - fmt.Printf("[NEW] TX seq=2 with ticket=0x%04x (%d bytes):\n%s", c.newProtoTicket, len(pkt2), hexDump(pkt2)) - } - - c.udpConn.WriteToUDP(pkt2, c.addr) - - buf := make([]byte, MaxPacketSize) - deadline := time.Now().Add(SessionTimeout) - lastSend := time.Now() - - for time.Now().Before(deadline) { - if time.Since(lastSend) >= DiscoInterval { - c.udpConn.WriteToUDP(pkt2, c.addr) - lastSend = time.Now() +func (c *Conn) newDiscoDone() error { + _, err := c.WriteAndWait(c.buildNewDisco(2, c.ticket, false), SessionTimeout, func(res []byte) bool { + if len(res) < NewPacketSize || binary.LittleEndian.Uint16(res[:2]) != MagicNewProto { + return false } - - c.udpConn.SetReadDeadline(time.Now().Add(ReadWaitInterval)) - n, addr, err := c.udpConn.ReadFromUDP(buf) - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - return err - } - - if n >= NewProtoPacketSize && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { - cmd := binary.LittleEndian.Uint16(buf[4:]) - dir := binary.LittleEndian.Uint16(buf[8:]) - seq := binary.LittleEndian.Uint16(buf[12:]) - - if cmd == CmdNewProtoDiscovery && dir == 0xFFFF && seq == 3 { - if c.verbose { - fmt.Printf("[NEW] RX Echo Response seq=3 (%d bytes):\n%s", n, hexDump(buf[:n])) - fmt.Printf("[NEW] Discovery complete!\n") - } - c.addr = addr - return nil - } - } - } - - return fmt.Errorf("NEW protocol handshake timeout waiting for seq=3") + cmd := binary.LittleEndian.Uint16(res[4:]) + dir := binary.LittleEndian.Uint16(res[8:]) + seq := binary.LittleEndian.Uint16(res[12:]) + return cmd == CmdNewDisco && dir == 0xFFFF && seq == 3 + }) + return err } func (c *Conn) connect() error { @@ -593,21 +478,13 @@ func (c *Conn) connect() error { fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) } - config := c.buildDTLSConfig(false) - - // Create adapter for main channel - adapter := &ChannelAdapter{ - conn: c, - channel: IOTCChannelMain, - } - - conn, err := dtls.Client(adapter, c.addr, config) + conn, err := NewDtlsClient(c, IOTCChannelMain, c.psk) if err != nil { return fmt.Errorf("dtls: client create failed: %w", err) } c.mu.Lock() - c.mainConn = conn + c.main = conn c.mu.Unlock() if c.verbose { @@ -617,177 +494,6 @@ func (c *Conn) connect() error { return nil } -func (c *Conn) iotcReader() { - defer c.wg.Done() - - buf := make([]byte, MaxPacketSize) - - for { - select { - case <-c.done: - return - default: - } - - c.udpConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) - n, addr, err := c.udpConn.ReadFromUDP(buf) - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - return - } - - if !addr.IP.Equal(c.addr.IP) { - continue - } - - // Update port if camera responds from different port - if addr.Port != c.addr.Port { - c.addr.Port = addr.Port - } - - // Check for NEW protocol (0xCC51 magic at start) - if c.useNewProto && n >= 2 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { - c.handleNewProtoPacket(buf[:n]) - continue - } - - // OLD protocol: TransCode decode - data := crypto.ReverseTransCodeBlob(buf[:n]) - - if len(data) < 16 { - continue - } - - cmd := binary.LittleEndian.Uint16(data[8:]) - - if cmd == CmdKeepaliveRes && len(data) > 16 { - payload := data[16:] - if len(payload) >= 8 { - keepaliveResp := c.buildKeepaliveResponse(payload) - _, _ = c.sendEncrypted(keepaliveResp) - if c.verbose { - fmt.Printf("[DTLS] Keepalive response sent\n") - } - } - continue - } - - if cmd == CmdDataRX && len(data) > 28 { - // Debug: Dump IOTC header to verify structure - if c.verbose && len(data) >= 32 { - fmt.Printf("[IOTC] RX Header dump (32 bytes):\n") - fmt.Printf(" [0-7]: %02x %02x %02x %02x %02x %02x %02x %02x\n", - data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]) - fmt.Printf(" [8-15]: %02x %02x %02x %02x %02x %02x %02x %02x (cmd@8-9, ch@14)\n", - data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15]) - fmt.Printf(" [16-23]: %02x %02x %02x %02x %02x %02x %02x %02x\n", - data[16], data[17], data[18], data[19], data[20], data[21], data[22], data[23]) - fmt.Printf(" [24-31]: %02x %02x %02x %02x %02x %02x %02x %02x (dtls starts @28)\n", - data[24], data[25], data[26], data[27], data[28], data[29], data[30], data[31]) - } - - dtlsPayload := data[28:] - - // Channel byte is at position 14 in IOTC header - channel := data[14] - - if c.verbose { - fmt.Printf("[IOTC] RX cmd=0x%04x len=%d ch=%d dtlsLen=%d\n", cmd, len(data), channel, len(dtlsPayload)) - if len(dtlsPayload) >= 13 { - contentType := dtlsPayload[0] - fmt.Printf("[DTLS] ch=%d contentType=%d first8=[%02x %02x %02x %02x %02x %02x %02x %02x]\n", - channel, contentType, dtlsPayload[0], dtlsPayload[1], dtlsPayload[2], dtlsPayload[3], - dtlsPayload[4], dtlsPayload[5], dtlsPayload[6], dtlsPayload[7]) - } - } - - // Copy data since buffer is reused - dataCopy := make([]byte, len(dtlsPayload)) - copy(dataCopy, dtlsPayload) - - // Route based on channel - var chBuf chan []byte - switch channel { - case IOTCChannelMain: - chBuf = c.mainBuf - case IOTCChannelBack: - chBuf = c.speakerBuf - } - - if chBuf != nil { - select { - case chBuf <- dataCopy: - default: - // Drop oldest if full - select { - case <-chBuf: - default: - } - chBuf <- dataCopy - } - } - } - } -} - -func (c *Conn) handleNewProtoPacket(data []byte) { - if len(data) < 16 { - return - } - - cmd := binary.LittleEndian.Uint16(data[4:]) - seq := binary.LittleEndian.Uint16(data[12:]) - ticket := binary.LittleEndian.Uint16(data[14:]) - - if c.verbose { - fmt.Printf("[NEW] RX cmd=0x%04x seq=%d ticket=0x%04x len=%d\n", cmd, seq, ticket, len(data)) - fmt.Printf("[NEW] RX full packet:\n%s", hexDump(data)) - } - - // Handle DTLS data (cmd=0x1502) - if cmd == CmdNewProtoDTLS && len(data) > NewProtoHeaderSize+NewProtoAuthSize { - // Packet structure: [0:28] header, [28:N-20] DTLS payload, [N-20:N] auth bytes - // We need to strip the auth bytes at the end - dtlsPayload := data[NewProtoHeaderSize : len(data)-NewProtoAuthSize] - - // Channel is encoded in the high byte of the sequence field: - // seq=0x0010 -> channel 0 (main), seq=0x0110 -> channel 1 (back) - channel := byte(seq >> 8) - - if c.verbose && len(dtlsPayload) >= 1 { - fmt.Printf("[NEW] DTLS RX ch=%d contentType=%d len=%d (stripped 20 auth bytes)\n%s", channel, dtlsPayload[0], len(dtlsPayload), hexDump(dtlsPayload)) - } - - // Copy data since buffer is reused - dataCopy := make([]byte, len(dtlsPayload)) - copy(dataCopy, dtlsPayload) - - // Route based on channel - var chBuf chan []byte - switch channel { - case IOTCChannelMain: - chBuf = c.mainBuf - case IOTCChannelBack: - chBuf = c.speakerBuf - } - - if chBuf != nil { - select { - case chBuf <- dataCopy: - default: - // Drop oldest if full - select { - case <-chBuf: - default: - } - chBuf <- dataCopy - } - } - } -} - func (c *Conn) worker() { defer c.wg.Done() @@ -800,12 +506,9 @@ func (c *Conn) worker() { default: } - n, err := c.mainConn.Read(buf) + n, err := c.main.Read(buf) if err != nil { - select { - case c.errors <- err: - default: - } + c.err = err return } @@ -813,96 +516,139 @@ func (c *Conn) worker() { continue } - // Debug: dump first bytes to see what we actually receive - if c.verbose && n >= 36 { - fmt.Printf("[Conn] worker raw: n=%d\n", n) - fmt.Printf("[Conn] first16: %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x\n", - buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], - buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15]) - fmt.Printf("[Conn] off16-31: %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x\n", - buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23], - buf[24], buf[25], buf[26], buf[27], buf[28], buf[29], buf[30], buf[31]) - } else if c.verbose && n >= 8 { - fmt.Printf("[Conn] worker raw: n=%d first8=[%02x %02x %02x %02x %02x %02x %02x %02x]\n", - n, buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7]) - } + data := buf[:n] + magic := binary.LittleEndian.Uint16(data) - c.route(buf[:n]) - } -} + switch magic { + case MagicAVLoginResp: + c.queue(c.rawCmd, data) -func (c *Conn) route(data []byte) { - if len(data) < 2 { - return - } - - // Check for control frame magic values first (uint16 LE) - magic := binary.LittleEndian.Uint16(data) - - switch magic { - case MagicAVLoginResp: - // AV Login Response - send full data for parsing - c.queueIOCtrlData(data) - return - - case MagicIOCtrl: - // IOCTRL Response Frame (K10001, K10003) - if len(data) >= 32 { - for i := 32; i+2 < len(data); i++ { - if data[i] == 'H' && data[i+1] == 'L' { - c.queueIOCtrlData(data[i:]) - return - } - } - } - return - - case MagicChannelMsg: - // Channel message - if len(data) >= 36 { - opCode := data[16] - if opCode == 0x00 { - for i := 36; i+2 < len(data); i++ { + case MagicIOCtrl: + if len(data) >= 32 { + for i := 32; i+2 < len(data); i++ { if data[i] == 'H' && data[i+1] == 'L' { - c.queueIOCtrlData(data[i:]) - return + c.queue(c.rawCmd, data[i:]) + break } } } - } - return - case MagicACK: - // ACK from camera + case MagicChannelMsg: + if len(data) >= 36 && data[16] == 0x00 { + for i := 36; i+2 < len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + c.queue(c.rawCmd, data[i:]) + break + } + } + } + + case MagicACK: + c.mu.RLock() + ack := c.cmdAck + c.mu.RUnlock() + if ack != nil { + ack() + } + + default: + channel := data[0] + if channel == ChannelAudio || channel == ChannelIVideo || channel == ChannelPVideo { + c.frames.Handle(data) + } + } + } +} + +func (c *Conn) reader() { + defer c.wg.Done() + buf := make([]byte, MaxPacketSize) + + for { select { - case c.ackReceived <- struct{}{}: + case <-c.ctx.Done(): + return default: } - return - } - // Check for AV Data packet (channel byte at offset 0) - channel := data[0] - if channel == ChannelAudio || channel == ChannelIVideo || channel == ChannelPVideo { - c.handleAVData(data) - return - } + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return + } - // Unknown packet type - if c.verbose { - fmt.Printf("[Conn] Unknown frame: type=0x%02x len=%d\n", data[0], len(data)) + if !addr.IP.Equal(c.addr.IP) { + continue + } + if addr.Port != c.addr.Port { + c.addr.Port = addr.Port + } + + // NEW protocol (0xCC51) + if c.newProto && n >= NewHeaderSize+NewAuthSize && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + if binary.LittleEndian.Uint16(buf[4:]) == CmdNewDTLS { + ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) + dtls := buf[NewHeaderSize : n-NewAuthSize] + switch ch { + case IOTCChannelMain: + c.queue(c.mainBuf, dtls) + case IOTCChannelBack: + c.queue(c.speakBuf, dtls) + } + } + continue + } + + // OLD protocol (TransCode) + data := crypto.ReverseTransCodeBlob(buf[:n]) + if len(data) < 16 { + continue + } + + switch binary.LittleEndian.Uint16(data[8:]) { + case CmdKeepaliveRes: + if len(data) > 24 { + _ = c.Write(c.buildKeepAlive(data[16:])) + } + case CmdDataRX: + if len(data) > 28 { + ch := data[14] + switch ch { + case IOTCChannelMain: + c.queue(c.mainBuf, data[28:]) + case IOTCChannelBack: + c.queue(c.speakBuf, data[28:]) + } + } + } + } +} + +func (c *Conn) queue(ch chan []byte, data []byte) { + b := make([]byte, len(data)) + copy(b, data) + select { + case ch <- b: + default: + select { + case <-ch: + default: + } + ch <- b } } func (c *Conn) handleSpeakerAVLogin() error { - // Read AV Login request from camera (SDK receives 570 bytes) if c.verbose { fmt.Printf("[SPEAK] Waiting for AV Login request from camera...\n") } buf := make([]byte, 1024) - c.speakerConn.SetReadDeadline(time.Now().Add(5 * time.Second)) - n, err := c.speakerConn.Read(buf) + c.speaker.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, err := c.speaker.Read(buf) if err != nil { return fmt.Errorf("read AV login: %w", err) } @@ -911,42 +657,31 @@ func (c *Conn) handleSpeakerAVLogin() error { fmt.Printf("[SPEAK] Received AV Login request: %d bytes\n", n) } - // Need at least 24 bytes to read the checksum if n < 24 { return fmt.Errorf("AV login too short: %d bytes", n) } - // Extract checksum from incoming request (bytes 20-23) checksum := binary.LittleEndian.Uint32(buf[20:]) - - // Build AV Login response (60 bytes like SDK) resp := c.buildAVLoginResponse(checksum) if c.verbose { fmt.Printf("[SPEAK] Sending AV Login response: %d bytes\n", len(resp)) } - _, err = c.speakerConn.Write(resp) - if err != nil { + if _, err = c.speaker.Write(resp); err != nil { return fmt.Errorf("write AV login response: %w", err) } - // Camera will resend AV-Login, respond again with AV-LoginResp - c.speakerConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - n, _ = c.speakerConn.Read(buf) - if n > 0 { + // Camera may resend, respond again + c.speaker.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if n, _ = c.speaker.Read(buf); n > 0 { if c.verbose { fmt.Printf("[SPEAK] Received AV Login resend: %d bytes\n", n) } - // Send second AV-LoginResp - if c.verbose { - fmt.Printf("[SPEAK] Sending second AV Login response: %d bytes\n", len(resp)) - } - c.speakerConn.Write(resp) + c.speaker.Write(resp) } - // Clear deadline - c.speakerConn.SetReadDeadline(time.Time{}) + c.speaker.SetReadDeadline(time.Time{}) if c.verbose { fmt.Printf("[SPEAK] AV Login complete, ready for audio\n") @@ -955,629 +690,168 @@ func (c *Conn) handleSpeakerAVLogin() error { return nil } -func (c *Conn) handleAVData(data []byte) { - // Parse packet header to get pkt_idx, pkt_total, frame_no - hdr := ParsePacketHeader(data) - if hdr == nil { - fmt.Printf("[Conn] Invalid AV packet header, len=%d\n", len(data)) - return - } - - // Debug: Log raw Wire-Header bytes - if c.verbose { - fmt.Printf("[WIRE] ch=0x%02x type=0x%02x len=%d pkt=%d/%d frame=%d\n", - hdr.Channel, hdr.FrameType, len(data), hdr.PktIdx, hdr.PktTotal, hdr.FrameNo) - fmt.Printf(" RAW[0..35]: ") - for i := 0; i < 36 && i < len(data); i++ { - fmt.Printf("%02x ", data[i]) - } - fmt.Printf("\n") - } - - // Extract payload and try to detect FRAMEINFO - payload, fi := c.extractPayload(data, hdr.Channel) - if payload == nil { - return - } - - if c.verbose { - c.logAVPacket(hdr.Channel, hdr.FrameType, payload, fi) - } - - // Route to handler - switch hdr.Channel { - case ChannelAudio: - c.handleAudio(payload, fi) - case ChannelIVideo, ChannelPVideo: - c.handleVideo(hdr.Channel, hdr, payload, fi) - } -} - -func (c *Conn) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { - if len(data) < 2 { - return nil, nil - } - - frameType := data[1] - - // Determine header size and FrameInfo size based on frameType - headerSize := 28 - frameInfoSize := 0 // 0 means no FrameInfo - - switch frameType { - case FrameTypeStart: - // Extended start packet - 36-byte header, no FrameInfo - headerSize = 36 - case FrameTypeStartAlt: - // StartAlt - 36-byte header - // Has FrameInfo only if pkt_total == 1 (single-packet frame) - headerSize = 36 - if len(data) >= 22 { - pktTotal := binary.LittleEndian.Uint16(data[20:]) - if pktTotal == 1 { - frameInfoSize = FrameInfoSize - } - } - case FrameTypeCont, FrameTypeContAlt: - // Continuation packet - standard 28-byte header, no FrameInfo - headerSize = 28 - case FrameTypeEndSingle, FrameTypeEndMulti: - // End packet - standard 28-byte header, 40-byte FrameInfo - headerSize = 28 - frameInfoSize = FrameInfoSize - case FrameTypeEndExt: - // Extended end packet - 36-byte header, 40-byte FrameInfo - headerSize = 36 - frameInfoSize = FrameInfoSize - default: - // Unknown frame type - use 28-byte header as fallback - headerSize = 28 - } - - if len(data) < headerSize { - return nil, nil - } - - // If this packet type doesn't have FrameInfo, return payload without it - if frameInfoSize == 0 { - return data[headerSize:], nil - } - - // End packets have FrameInfo - validate size - if len(data) < headerSize+frameInfoSize { - return data[headerSize:], nil - } - - fi := ParseFrameInfo(data) - - // Validate codec matches channel type - validCodec := false - switch channel { - case ChannelIVideo, ChannelPVideo: - validCodec = IsVideoCodec(fi.CodecID) - case ChannelAudio: - validCodec = IsAudioCodec(fi.CodecID) - } - - if validCodec { - if c.verbose { - fiRaw := data[len(data)-frameInfoSize:] - fmt.Printf("[FRAMEINFO RAW %d bytes]:\n", frameInfoSize) - fmt.Printf(" [0-15]: ") - for i := 0; i < 16 && i < len(fiRaw); i++ { - fmt.Printf("%02x ", fiRaw[i]) - } - fmt.Printf("\n [16-31]: ") - for i := 16; i < 32 && i < len(fiRaw); i++ { - fmt.Printf("%02x ", fiRaw[i]) - } - fmt.Printf("\n [32-%d]: ", frameInfoSize-1) - for i := 32; i < frameInfoSize && i < len(fiRaw); i++ { - fmt.Printf("%02x ", fiRaw[i]) - } - fmt.Printf("\n") - } - - payload := data[headerSize : len(data)-frameInfoSize] - return payload, fi - } - - return data[headerSize:], nil -} - -func (c *Conn) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) { - if c.frameAssemblers == nil { - c.frameAssemblers = make(map[byte]*FrameAssembler) - } - - asm := c.frameAssemblers[channel] - - // Frame transition detection: new frame number = previous frame complete - if asm != nil && hdr.FrameNo != asm.frameNo { - gotAll := uint16(len(asm.packets)) == asm.pktTotal - - if gotAll && asm.frameInfo != nil { - // Perfect: all packets + FrameInfo present - c.assembleAndQueueVideo(channel, asm) - } else if c.verbose { - // Debugging: what exactly is missing? - if gotAll && asm.frameInfo == nil { - fmt.Printf("[VIDEO] Frame #%d: all %d packets received but End packet lost (no FrameInfo)\n", - asm.frameNo, asm.pktTotal) - } else { - fmt.Printf("[VIDEO] Frame #%d: incomplete %d/%d packets\n", - asm.frameNo, len(asm.packets), asm.pktTotal) - } - } - asm = nil - } - - // Create new assembler if needed - if asm == nil { - asm = &FrameAssembler{ - frameNo: hdr.FrameNo, - pktTotal: hdr.PktTotal, - packets: make(map[uint16][]byte, hdr.PktTotal), - } - c.frameAssemblers[channel] = asm - } - - // Store packet (with pkt_idx as key!) - // IMPORTANT: Always register the packet, even if payload is empty! - // End packets may have 0 bytes payload (all data in previous packets) - // but still need to be counted for completeness check. - // CRITICAL: Must copy payload! The underlying buffer is reused by the worker. - payloadCopy := make([]byte, len(payload)) - copy(payloadCopy, payload) - asm.packets[hdr.PktIdx] = payloadCopy - - // Store FrameInfo if present - if fi != nil { - asm.frameInfo = fi - } - - // Check if frame is complete - if uint16(len(asm.packets)) == asm.pktTotal && asm.frameInfo != nil { - c.assembleAndQueueVideo(channel, asm) - delete(c.frameAssemblers, channel) - } -} - -func (c *Conn) assembleAndQueueVideo(channel byte, asm *FrameAssembler) { - fi := asm.frameInfo - - // Assemble packets in correct order - var payload []byte - for i := uint16(0); i < asm.pktTotal; i++ { - if pkt, ok := asm.packets[i]; ok { - payload = append(payload, pkt...) - } - } - - // Size validation - if fi.PayloadSize > 0 && len(payload) != int(fi.PayloadSize) { - if c.verbose { - fmt.Printf("[VIDEO] Frame #%d size mismatch: got=%d expected=%d, discarding\n", - asm.frameNo, len(payload), fi.PayloadSize) - } - return - } - - if len(payload) == 0 { - return - } - - // Calculate RTP timestamp (90kHz for video) using relative timestamps - // to avoid uint64 overflow (absoluteTS * clockRate exceeds uint64 max) - absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) - if c.baseTS == 0 { - c.baseTS = absoluteTS - } - relativeUS := absoluteTS - c.baseTS - const clockRate uint64 = 90000 - rtpTS := uint32(relativeUS * clockRate / 1000000) - - pkt := &Packet{ - Channel: channel, - Payload: payload, - Codec: fi.CodecID, - Timestamp: rtpTS, - IsKeyframe: fi.IsKeyframe(), - FrameNo: fi.FrameNo, - } - - if c.verbose { - frameType := "P" - if fi.IsKeyframe() { - frameType = "I" - } - fmt.Printf("[VIDEO] #%d %s %s size=%d rtp=%d\n", - fi.FrameNo, CodecName(fi.CodecID), frameType, len(payload), rtpTS) - } - - c.queuePacket(pkt) -} - -func (c *Conn) handleAudio(payload []byte, fi *FrameInfo) { - if len(payload) == 0 || fi == nil { - return - } - - var sampleRate uint32 - var channels uint8 - - // Parse ADTS for AAC codecs, use FRAMEINFO for others - switch fi.CodecID { - case AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACLATM, AudioCodecAACWyze: - sampleRate, channels = ParseAudioParams(payload, fi) - default: - sampleRate = fi.SampleRate() - channels = fi.Channels() - } - - // Calculate RTP timestamp using relative timestamps to avoid uint64 overflow - // Uses shared baseTS with video for proper A/V sync - absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) - if c.baseTS == 0 { - c.baseTS = absoluteTS - } - relativeUS := absoluteTS - c.baseTS - clockRate := uint64(sampleRate) - rtpTS := uint32(relativeUS * clockRate / 1000000) - - pkt := &Packet{ - Channel: ChannelAudio, - Payload: payload, - Codec: fi.CodecID, - Timestamp: rtpTS, - SampleRate: sampleRate, - Channels: channels, - FrameNo: fi.FrameNo, - } - - if c.verbose { - fmt.Printf("[AUDIO] #%d %s size=%d rate=%d ch=%d rtp=%d\n", - fi.FrameNo, AudioCodecName(fi.CodecID), len(payload), sampleRate, channels, rtpTS) - } - - c.queuePacket(pkt) -} - -func (c *Conn) queuePacket(pkt *Packet) { - select { - case c.packetQueue <- pkt: - default: - // Queue full - drop oldest - select { - case <-c.packetQueue: - default: - } - c.packetQueue <- pkt - } -} - -func (c *Conn) queueIOCtrlData(data []byte) { - dataCopy := make([]byte, len(data)) - copy(dataCopy, data) - - select { - case c.ioctrl <- dataCopy: - default: - select { - case <-c.ioctrl: - default: - } - c.ioctrl <- dataCopy - } -} - -func (c *Conn) sendACK() error { - ack := c.buildACK() - - if c.verbose { - fmt.Printf("[Conn] SendACK: txSeq=%d flags=0x%04x\n", c.avTxSeq-1, c.ackFlags) - } - - _, err := c.mainConn.Write(ack) - return err -} - -func (c *Conn) sendIOTC(payload []byte, channel byte) (int, error) { - if c.useNewProto { - // NEW Protocol: send DTLS data in 0xCC51 frame with cmd=0x1502 - frame := c.buildNewProtoDTLS(payload, channel) - if c.verbose { - fmt.Printf("\n>>> TX %d bytes (DTLS cmd=0x1502 ch=%d)\n%s", - len(frame), channel, hexDump(frame)) - } - return c.udpConn.WriteToUDP(frame, c.addr) - } - // OLD Protocol: TransCode encrypted 0x0407 frame - frame := c.buildDataTXChannel(payload, channel) - return c.sendEncrypted(frame) -} - -func (c *Conn) sendEncrypted(data []byte) (int, error) { - if c.verbose { - fmt.Printf("[OLD] TX %d bytes\n%s", len(data), hexDump(data)) - } - encrypted := crypto.TransCodeBlob(data) - return c.udpConn.WriteToUDP(encrypted, c.addr) -} - -func (c *Conn) buildNewProtoPacket(seq, ticket uint16, isResponse bool) []byte { - pkt := make([]byte, NewProtoPacketSize) - - // Header [0:12] - binary.LittleEndian.PutUint16(pkt[0:], MagicNewProto) // Magic 0xCC51 - binary.LittleEndian.PutUint16(pkt[2:], 0x0000) // Flags - binary.LittleEndian.PutUint16(pkt[4:], CmdNewProtoDiscovery) // Command 0x1002 - binary.LittleEndian.PutUint16(pkt[6:], NewProtoPayloadSize) // Payload size (40 bytes) - - if isResponse { - binary.LittleEndian.PutUint16(pkt[8:], 0xFFFF) // Direction (response) - } else { - binary.LittleEndian.PutUint16(pkt[8:], 0x0000) // Direction (request) - } - - binary.LittleEndian.PutUint16(pkt[10:], 0x0000) // Reserved - binary.LittleEndian.PutUint16(pkt[12:], seq) // Sequence - binary.LittleEndian.PutUint16(pkt[14:], ticket) // Ticket - - // SessionID [16:24] - copy(pkt[16:24], c.sessionID) - - // Capabilities [24:32] - SDK version 4.3.8.0 - copy(pkt[24:32], []byte{0x00, 0x08, 0x03, 0x04, 0x1d, 0x00, 0x00, 0x00}) - - // Auth Bytes [32:52] - HMAC-SHA1(UID+AuthKey, header[0:32]) - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - key := append([]byte(c.uid), authKey...) - - h := hmac.New(sha1.New, key) - h.Write(pkt[:32]) - authBytes := h.Sum(nil) - copy(pkt[32:52], authBytes) - - if c.verbose { - fmt.Printf("[AUTH] Discovery Auth Debug:\n") - fmt.Printf("[AUTH] ENR: %s\n", c.enr) - fmt.Printf("[AUTH] MAC: %s\n", c.mac) - fmt.Printf("[AUTH] UID: %s\n", c.uid) - fmt.Printf("[AUTH] AuthKey: %x\n", authKey) - fmt.Printf("[AUTH] HMAC Key (UID+AuthKey): %x\n", key) - fmt.Printf("[AUTH] Hash Input (32 bytes): %x\n", pkt[:32]) - fmt.Printf("[AUTH] Auth Bytes: %x\n", authBytes) - } - - return pkt -} - -func (c *Conn) buildNewProtoDTLS(payload []byte, channel byte) []byte { - payloadSize := uint16(16 + len(payload) + NewProtoAuthSize) - pkt := make([]byte, NewProtoHeaderSize+len(payload)+NewProtoAuthSize) - - if c.verbose { - fmt.Printf("[DTLS PKT] payload=%d, payloadSize=%d (0x%04x), pktLen=%d\n", - len(payload), payloadSize, payloadSize, len(pkt)) - } - - binary.LittleEndian.PutUint16(pkt[0:], MagicNewProto) - binary.LittleEndian.PutUint16(pkt[2:], 0x0000) - binary.LittleEndian.PutUint16(pkt[4:], CmdNewProtoDTLS) - binary.LittleEndian.PutUint16(pkt[6:], payloadSize) - binary.LittleEndian.PutUint16(pkt[8:], 0x0000) // Direction (request) - binary.LittleEndian.PutUint16(pkt[10:], 0x0000) // Reserved - // Channel is encoded in high byte of sequence: 0x0010=main, 0x0110=back - seq := uint16(0x0010) | (uint16(channel) << 8) - binary.LittleEndian.PutUint16(pkt[12:], seq) - binary.LittleEndian.PutUint16(pkt[14:], c.newProtoTicket) - copy(pkt[16:24], c.sessionID) - binary.LittleEndian.PutUint32(pkt[24:], 1) // Always 1 for DTLS wrapper - copy(pkt[NewProtoHeaderSize:], payload) - - // Add Auth bytes at the end: HMAC-SHA1(UID+AuthKey, header only) - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - key := append([]byte(c.uid), authKey...) - h := hmac.New(sha1.New, key) - h.Write(pkt[:NewProtoHeaderSize]) // Hash the header portion only - authBytes := h.Sum(nil) - copy(pkt[NewProtoHeaderSize+len(payload):], authBytes) - - if c.verbose { - fmt.Printf("[AUTH] DTLS Auth Debug:\n") - fmt.Printf("[AUTH] ENR: %s\n", c.enr) - fmt.Printf("[AUTH] MAC: %s\n", c.mac) - fmt.Printf("[AUTH] UID: %s\n", c.uid) - fmt.Printf("[AUTH] AuthKey: %x\n", authKey) - fmt.Printf("[AUTH] HMAC Key (UID+AuthKey): %x\n", key) - fmt.Printf("[AUTH] Hash Input (Header 28 bytes): %x\n", pkt[:NewProtoHeaderSize]) - fmt.Printf("[AUTH] Auth Bytes: %x\n", authBytes) - } - - return pkt -} - -func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { - const frameInfoSize = 16 - const headerSize = 36 - - c.audioTxSeq++ - c.audioTxFrameNo++ - - totalPayload := len(payload) + frameInfoSize - frame := make([]byte, headerSize+totalPayload) - - // Calculate prev_frame_no (0 for first frame, otherwise frame_no - 1) - prevFrameNo := uint32(0) - if c.audioTxFrameNo > 1 { - prevFrameNo = c.audioTxFrameNo - 1 - } - - // Type 0x09 "Single" - 36-byte header with full timestamp - frame[0] = ChannelAudio // 0x03 - frame[1] = FrameTypeStartAlt // 0x09 - binary.LittleEndian.PutUint16(frame[2:], ProtocolVersion) // 0x000c - - binary.LittleEndian.PutUint32(frame[4:], c.audioTxSeq) - binary.LittleEndian.PutUint32(frame[8:], timestampUS) // Timestamp in header - - // Flags at [12-15]: first frame uses 0x00000001, subsequent use 0x00100001 - if c.audioTxFrameNo == 1 { - binary.LittleEndian.PutUint32(frame[12:], 0x00000001) - } else { - binary.LittleEndian.PutUint32(frame[12:], 0x00100001) - } - - // Inner header - frame[16] = ChannelAudio // 0x03 - frame[17] = FrameTypeEndSingle // 0x01 - binary.LittleEndian.PutUint16(frame[18:], uint16(prevFrameNo)) // prev_frame_no (16-bit) - - binary.LittleEndian.PutUint16(frame[20:], 0x0001) // pkt_total = 1 - binary.LittleEndian.PutUint16(frame[22:], 0x0010) // flags - - binary.LittleEndian.PutUint32(frame[24:], uint32(totalPayload)) // payload size - binary.LittleEndian.PutUint32(frame[28:], prevFrameNo) // prev_frame_no again (32-bit) - binary.LittleEndian.PutUint32(frame[32:], c.audioTxFrameNo) // frame_no - - // Audio payload - copy(frame[headerSize:], payload) - - // FrameInfo (16 bytes) at end of payload - samplesPerFrame := GetSamplesPerFrame(codec) - frameDurationMs := samplesPerFrame * 1000 / sampleRate - - fi := frame[headerSize+len(payload):] - binary.LittleEndian.PutUint16(fi[:], codec) // codec_id - fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) // flags - fi[3] = 0 // cam_index - fi[4] = 1 // onlineNum = 1 - fi[5] = 0 // tags - // fi[6:12] = reserved (already 0) - binary.LittleEndian.PutUint32(fi[12:], (c.audioTxFrameNo-1)*frameDurationMs) - - if c.verbose { - fmt.Printf("[AUDIO TX] FrameInfo: codec=0x%04x flags=0x%02x online=%d ts=%d\n", - codec, fi[2], fi[4], binary.LittleEndian.Uint32(fi[12:])) - } - - return frame -} - func (c *Conn) buildDisco(stage byte) []byte { - frame := make([]byte, OldProtoDiscoPacketSize) - - // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x02 // [3] Mode = Disco - binary.LittleEndian.PutUint16(frame[4:], OldProtoDiscoBodySize) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[8:], CmdDiscoReq) // [8-9] Command = 0x0601 - binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags - - // Body [16-87] - body := frame[OldProtoHeaderSize:] - copy(body[:UIDSize], c.uid) // [0-19] UID (20 bytes) - - body[36] = 0x01 // [36] Unknown1 - body[37] = 0x01 // [37] Unknown2 - body[38] = 0x02 // [38] Unknown3 - body[39] = 0x04 // [39] Unknown4 - - copy(body[40:], c.randomID) // [40-47] RandomID - body[48] = stage // [48] Stage (1=broadcast, 2=direct) + b := make([]byte, OldDiscoSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], OldDiscoBodySize) // body size + binary.LittleEndian.PutUint16(b[8:], CmdDiscoReq) // 0x0601 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + body := b[OldHeaderSize:] + copy(body[:UIDSize], c.uid) + copy(body[36:], "\x01\x01\x02\x04") // unknown + copy(body[40:], c.rid) + body[48] = stage if stage == 1 && len(c.authKey) > 0 { - copy(body[58:], c.authKey) // [58-65] AuthKey + copy(body[58:], c.authKey) } + return b +} - return frame +func (c *Conn) buildNewDisco(seq, ticket uint16, isResponse bool) []byte { + b := make([]byte, NewPacketSize) + binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 + binary.LittleEndian.PutUint16(b[4:], CmdNewDisco) // 0x1002 + binary.LittleEndian.PutUint16(b[6:], NewPayloadSize) // 40 bytes + if isResponse { + binary.LittleEndian.PutUint16(b[8:], 0xFFFF) // response + } + binary.LittleEndian.PutUint16(b[12:], seq) + binary.LittleEndian.PutUint16(b[14:], ticket) + copy(b[16:24], c.sid) + copy(b[24:32], "\x00\x08\x03\x04\x1d\x00\x00\x00") // SDK 4.3.8.0 + + // HMAC-SHA1(UID+AuthKey, header) + authKey := crypto.CalculateAuthKey(c.enr, c.mac) + h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) + h.Write(b[:32]) + copy(b[32:52], h.Sum(nil)) + return b } func (c *Conn) buildSession() []byte { - frame := make([]byte, OldProtoSessionPacketSize) + b := make([]byte, OldSessionSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], OldSessionBody) // body size + binary.LittleEndian.PutUint16(b[8:], CmdSessionReq) // 0x0402 + binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags - // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x02 // [3] Mode - binary.LittleEndian.PutUint16(frame[4:], OldProtoSessionBodySize) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[8:], CmdSessionReq) // [8-9] Command = 0x0402 - binary.LittleEndian.PutUint16(frame[10:], 0x0033) // [10-11] Flags - - // Body [16-51] - body := frame[OldProtoHeaderSize:] - copy(body[:UIDSize], c.uid) // [0-19] UID (20 bytes) - copy(body[UIDSize:], c.randomID) // [20-27] RandomID - - ts := uint32(time.Now().Unix()) - binary.LittleEndian.PutUint32(body[32:], ts) // [32-35] Timestamp - - return frame + body := b[OldHeaderSize:] + copy(body[:UIDSize], c.uid) + copy(body[UIDSize:], c.rid) + binary.LittleEndian.PutUint32(body[32:], uint32(time.Now().Unix())) + return b } -func (c *Conn) buildDTLSConfig(isServer bool) *dtls.Config { - config := &dtls.Config{ - PSK: func(hint []byte) ([]byte, error) { - if c.verbose { - fmt.Printf("[DTLS] PSK callback, hint: %s\n", string(hint)) - } - return c.psk, nil - }, - PSKIdentityHint: []byte(PSKIdentity), - InsecureSkipVerify: true, - InsecureSkipVerifyHello: true, - MTU: 1200, - FlightInterval: 300 * time.Millisecond, - ExtendedMasterSecret: dtls.DisableExtendedMasterSecret, +func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID []byte) []byte { + b := make([]byte, size) + binary.LittleEndian.PutUint16(b, magic) + binary.LittleEndian.PutUint16(b[2:], ProtoVersion) + binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size + binary.LittleEndian.PutUint16(b[18:], flags) + copy(b[20:], randomID[:4]) + copy(b[24:], DefaultUser) // username + copy(b[280:], c.enr) // password (ENR) + binary.LittleEndian.PutUint32(b[540:], 2) // security_mode=AV_SECURITY_AUTO + binary.LittleEndian.PutUint32(b[552:], DefaultCaps) // capabilities + return b +} + +func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { + b := make([]byte, 60) + binary.LittleEndian.PutUint16(b, 0x2100) // magic + binary.LittleEndian.PutUint16(b[2:], 0x000c) // version + b[4] = 0x10 // success + binary.LittleEndian.PutUint32(b[16:], 0x24) // payload size + binary.LittleEndian.PutUint32(b[20:], checksum) // echo checksum + b[29] = 0x01 // enable flag + b[31] = 0x01 // two-way streaming + binary.LittleEndian.PutUint32(b[36:], 0x04) // buffer config + binary.LittleEndian.PutUint32(b[40:], DefaultCaps) + binary.LittleEndian.PutUint16(b[54:], 0x0003) // channel info + binary.LittleEndian.PutUint16(b[56:], 0x0002) + return b +} + +func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { + c.audioSeq++ + c.audioFrame++ + prevFrame := uint32(0) + if c.audioFrame > 1 { + prevFrame = c.audioFrame - 1 } - // Use custom cipher suites for client, standard for server - if isServer { - config.CipherSuites = []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_CBC_SHA256} + totalPayload := len(payload) + 16 // payload + frameinfo + b := make([]byte, 36+totalPayload) + + // Outer header (36 bytes) + b[0] = ChannelAudio // 0x03 + b[1] = FrameTypeStartAlt // 0x09 + binary.LittleEndian.PutUint16(b[2:], ProtoVersion) + binary.LittleEndian.PutUint32(b[4:], c.audioSeq) + binary.LittleEndian.PutUint32(b[8:], timestampUS) + if c.audioFrame == 1 { + binary.LittleEndian.PutUint32(b[12:], 0x00000001) } else { - config.CustomCipherSuites = CustomCipherSuites + binary.LittleEndian.PutUint32(b[12:], 0x00100001) } - if c.verbose { - fmt.Printf("[DTLS] Config: isServer=%v, MTU=%d, FlightInterval=%v\n", - isServer, config.MTU, config.FlightInterval) - } + // Inner header + b[16] = ChannelAudio + b[17] = FrameTypeEndSingle + binary.LittleEndian.PutUint16(b[18:], uint16(prevFrame)) + binary.LittleEndian.PutUint16(b[20:], 0x0001) // pkt_total + binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags + binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload)) + binary.LittleEndian.PutUint32(b[28:], prevFrame) + binary.LittleEndian.PutUint32(b[32:], c.audioFrame) - return config + // Payload + FrameInfo + copy(b[36:], payload) + fi := b[36+len(payload):] + binary.LittleEndian.PutUint16(fi, codec) + fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) + fi[4] = 1 // online + binary.LittleEndian.PutUint32(fi[12:], (c.audioFrame-1)*GetSamplesPerFrame(codec)*1000/sampleRate) + return b } -func (c *Conn) buildDataTXChannel(payload []byte, channel byte) []byte { - const subHeaderSize = 12 - bodySize := subHeaderSize + len(payload) - frameSize := 16 + bodySize - frame := make([]byte, frameSize) +func (c *Conn) buildTxData(payload []byte, channel byte) []byte { + bodySize := 12 + len(payload) + b := make([]byte, 16+bodySize) + copy(b, "\x04\x02\x1a\x0b") // marker + mode=data + binary.LittleEndian.PutUint16(b[4:], uint16(bodySize)) // body size + binary.LittleEndian.PutUint16(b[6:], c.seq) // sequence + c.seq++ + binary.LittleEndian.PutUint16(b[8:], CmdDataTX) // 0x0407 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + copy(b[12:], c.rid[:2]) // rid[0:2] + b[14] = channel // channel + b[15] = 0x01 // marker + binary.LittleEndian.PutUint32(b[16:], 0x0000000c) // const + copy(b[20:], c.rid[:8]) // rid + copy(b[28:], payload) + return b +} - // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x0b // [3] Mode = Data - binary.LittleEndian.PutUint16(frame[4:], uint16(bodySize)) // [4-5] BodySize - binary.LittleEndian.PutUint16(frame[6:], c.iotcTxSeq) // [6-7] Sequence - c.iotcTxSeq++ - binary.LittleEndian.PutUint16(frame[8:], CmdDataTX) // [8-9] Command = 0x0407 - binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags - copy(frame[12:], c.randomID[:2]) // [12-13] RandomID[0:2] - frame[14] = channel // [14] Channel (0=Main, 1=Back) - frame[15] = 0x01 // [15] Marker +func (c *Conn) buildNewTxData(payload []byte, channel byte) []byte { + payloadSize := uint16(16 + len(payload) + NewAuthSize) + b := make([]byte, NewHeaderSize+len(payload)+NewAuthSize) + binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 + binary.LittleEndian.PutUint16(b[4:], CmdNewDTLS) // 0x1502 + binary.LittleEndian.PutUint16(b[6:], payloadSize) + binary.LittleEndian.PutUint16(b[12:], uint16(0x0010)|(uint16(channel)<<8)) // channel in high byte + binary.LittleEndian.PutUint16(b[14:], c.ticket) + copy(b[16:24], c.sid) + binary.LittleEndian.PutUint32(b[24:], 1) // const + copy(b[NewHeaderSize:], payload) - // Sub-Header [16-27] - binary.LittleEndian.PutUint32(frame[16:], 0x0000000c) // [16-19] Const - copy(frame[20:], c.randomID[:8]) // [20-27] RandomID - - // Payload [28+] - copy(frame[28:], payload) - - return frame + // HMAC-SHA1(UID+AuthKey, header) + authKey := crypto.CalculateAuthKey(c.enr, c.mac) + h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) + h.Write(b[:NewHeaderSize]) + copy(b[NewHeaderSize+len(payload):], h.Sum(nil)) + return b } func (c *Conn) buildACK() []byte { @@ -1586,187 +860,44 @@ func (c *Conn) buildACK() []byte { } else if c.ackFlags < 0x0007 { c.ackFlags++ } - - ack := make([]byte, 24) - binary.LittleEndian.PutUint16(ack[0:], MagicACK) // [0-1] Magic = 0x0009 - binary.LittleEndian.PutUint16(ack[2:], ProtocolVersion) // [2-3] Version = 0x000C - binary.LittleEndian.PutUint32(ack[4:], c.avTxSeq) // [4-7] TxSeq - c.avTxSeq++ - binary.LittleEndian.PutUint32(ack[8:], 0xffffffff) // [8-11] RxSeq (not used) - binary.LittleEndian.PutUint16(ack[12:], c.ackFlags) // [12-13] AckFlags - binary.LittleEndian.PutUint32(ack[16:], uint32(c.ackFlags)<<16) // [16-19] AckCounter - - return ack + b := make([]byte, 24) + binary.LittleEndian.PutUint16(b[0:], MagicACK) // 0x0009 + binary.LittleEndian.PutUint16(b[2:], ProtoVersion) // 0x000c + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // tx seq + c.avSeq++ + binary.LittleEndian.PutUint32(b[8:], 0xffffffff) // rx seq + binary.LittleEndian.PutUint16(b[12:], c.ackFlags) // ack flags + binary.LittleEndian.PutUint32(b[16:], uint32(c.ackFlags)<<16) // ack counter + return b } -func (c *Conn) buildKeepaliveResponse(incomingPayload []byte) []byte { - frame := make([]byte, 24) - - // IOTC Frame Header [0-15] - frame[0] = 0x04 // [0] Marker1 - frame[1] = 0x02 // [1] Marker2 - frame[2] = 0x1a // [2] Marker3 - frame[3] = 0x0a // [3] Mode - binary.LittleEndian.PutUint16(frame[4:], 8) // [4-5] BodySize = 8 - binary.LittleEndian.PutUint16(frame[8:], CmdKeepaliveReq) // [8-9] Command = 0x0427 - binary.LittleEndian.PutUint16(frame[10:], 0x0021) // [10-11] Flags - - // Body [16-23]: Echo back incoming payload - if len(incomingPayload) >= 8 { - copy(frame[16:], incomingPayload[:8]) // [16-23] EchoPayload +func (c *Conn) buildKeepAlive(incoming []byte) []byte { + b := make([]byte, 24) + copy(b, "\x04\x02\x1a\x0a") // marker + mode + binary.LittleEndian.PutUint16(b[4:], 8) // body size + binary.LittleEndian.PutUint16(b[8:], CmdKeepaliveReq) // 0x0427 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + if len(incoming) >= 8 { + copy(b[16:], incoming[:8]) // echo payload } - - return frame -} - -func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID []byte) []byte { - pkt := make([]byte, size) - - // Header - binary.LittleEndian.PutUint16(pkt, magic) - binary.LittleEndian.PutUint16(pkt[2:], ProtocolVersion) - // bytes 4-15: reserved (zeros) - - // Payload info at offset 16 - payloadSize := uint16(size - 24) // total - header(16) - random(4) - padding(4) - binary.LittleEndian.PutUint16(pkt[16:], payloadSize) - binary.LittleEndian.PutUint16(pkt[18:], flags) - copy(pkt[20:], randomID[:4]) - - // Credentials (each field is 256 bytes) - copy(pkt[24:], DefaultUser) // username at offset 24 (payload byte 0) - copy(pkt[280:], c.enr) // password (ENR) at offset 280 (payload byte 256) - - // Config section (AVClientStartInConfig) starts at offset 536 (= 24 + 256 + 256) - // Layout: resend(4) + security_mode(4) + auth_type(4) + sync_recv_data(4) + ... - binary.LittleEndian.PutUint32(pkt[536:], 0) // resend=0 - binary.LittleEndian.PutUint32(pkt[540:], 2) // security_mode=2 (AV_SECURITY_AUTO) - binary.LittleEndian.PutUint32(pkt[544:], 0) // auth_type=0 (AV_AUTH_PASSWORD) - binary.LittleEndian.PutUint32(pkt[548:], 0) // sync_recv_data=0 - binary.LittleEndian.PutUint32(pkt[552:], DefaultCapabilities) // capabilities - binary.LittleEndian.PutUint16(pkt[556:], 0) // request_video_on_connect=0 - binary.LittleEndian.PutUint16(pkt[558:], 0) // request_audio_on_connect=0 - - return pkt -} - -func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { - resp := make([]byte, 60) - - // Header - binary.LittleEndian.PutUint16(resp, 0x2100) // Magic - binary.LittleEndian.PutUint16(resp[2:], 0x000c) // Version - resp[4] = 0x10 // Response type (success) - - // Payload info - binary.LittleEndian.PutUint32(resp[16:], 0x24) // Payload size = 36 - binary.LittleEndian.PutUint32(resp[20:], checksum) // Echo checksum from request! - - // Payload (36 bytes starting at offset 24) - resp[29] = 0x01 // EnableFlag - resp[31] = 0x01 // TwoWayStreaming - - binary.LittleEndian.PutUint32(resp[36:], 0x04) // BufferConfig - binary.LittleEndian.PutUint32(resp[40:], 0x001f07fb) // Capabilities - - binary.LittleEndian.PutUint16(resp[54:], 0x0003) // ChannelInfo1 - binary.LittleEndian.PutUint16(resp[56:], 0x0002) // ChannelInfo2 - - return resp + return b } func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { - const headerSize = 40 - frame := make([]byte, headerSize+len(payload)) - - // Magic (same as protocol version for IOCtrl frames) - binary.LittleEndian.PutUint16(frame, ProtocolVersion) - - // Version - binary.LittleEndian.PutUint16(frame[2:], ProtocolVersion) - - // AVSeq (4-7) - seq := c.avTxSeq - c.avTxSeq++ - binary.LittleEndian.PutUint32(frame[4:], seq) - - // Bytes 8-15: reserved - - // Channel: MagicIOCtrl (0x7000) for IOCtrl frames - binary.LittleEndian.PutUint16(frame[16:], MagicIOCtrl) - - // SubChannel (18-19): increments with each IOCtrl command sent - binary.LittleEndian.PutUint16(frame[18:], c.ioctrlSeq) - - // IOCTLSeq (20-23): always 1 - binary.LittleEndian.PutUint32(frame[20:], 1) - - // PayloadSize (24-27): payload + 4 bytes padding - binary.LittleEndian.PutUint32(frame[24:], uint32(len(payload)+4)) - - // Flag (28-31): matches subChannel in SDK - binary.LittleEndian.PutUint32(frame[28:], uint32(c.ioctrlSeq)) - - // Bytes 32-36: reserved - // Byte 37: 0x01 - frame[37] = 0x01 - - // Bytes 38-39: reserved - - // Payload at offset 40 - copy(frame[headerSize:], payload) - - c.ioctrlSeq++ - - return frame -} - -func (c *Conn) logAVPacket(channel, frameType byte, payload []byte, fi *FrameInfo) { - fmt.Printf("[Conn] AV: ch=0x%02x type=0x%02x len=%d", channel, frameType, len(payload)) - if fi != nil { - fmt.Printf(" fi={codec=0x%04x flags=0x%02x ts=%d}", fi.CodecID, fi.Flags, fi.Timestamp) - } - fmt.Printf("\n") -} - -func (c *Conn) logAudioTX(frame []byte, codec uint16, payloadLen int, timestampUS uint32, sampleRate uint32, channels uint8) { - chStr := "mono" - if channels == 2 { - chStr = "stereo" - } - - // Determine header size based on frame type - headerSize := 28 - frameType := "P-Start" - if len(frame) >= 2 && frame[1] == FrameTypeStartAlt { - headerSize = 36 - frameType = "Single" - } - - fmt.Printf("[AUDIO TX] %s codec=0x%04x (%s) payload=%d ts=%d rate=%d %s total=%d\n", - frameType, codec, AudioCodecName(codec), payloadLen, timestampUS, sampleRate, chStr, len(frame)) - - // Dump frame header for comparison with SDK - if len(frame) >= headerSize { - fmt.Printf(" HEADER[0..%d]: ", headerSize-1) - for i := 0; i < headerSize; i++ { - fmt.Printf("%02x ", frame[i]) - } - fmt.Printf("\n") - } - - // First few payload bytes (for comparison with SDK) - if payloadLen > 0 && len(frame) > headerSize { - maxShow := min(16, payloadLen) - fmt.Printf(" PAYLOAD[%d..%d]: ", headerSize, headerSize+maxShow-1) - for i := 0; i < maxShow; i++ { - fmt.Printf("%02x ", frame[headerSize+i]) - } - if payloadLen > maxShow { - fmt.Printf("...") - } - fmt.Printf("\n") - } + b := make([]byte, 40+len(payload)) + binary.LittleEndian.PutUint16(b, ProtoVersion) // magic + binary.LittleEndian.PutUint16(b[2:], ProtoVersion) // version + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // av seq + c.avSeq++ + binary.LittleEndian.PutUint16(b[16:], MagicIOCtrl) // 0x7000 + binary.LittleEndian.PutUint16(b[18:], c.seqCmd) // sub channel + binary.LittleEndian.PutUint32(b[20:], 1) // ioctl seq + binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)+4)) // payload size + binary.LittleEndian.PutUint32(b[28:], uint32(c.seqCmd)) // flag + b[37] = 0x01 + copy(b[40:], payload) + c.seqCmd++ + return b } func derivePSK(enr string) []byte { @@ -1776,7 +907,6 @@ func derivePSK(enr string) []byte { hash := sha256.Sum256([]byte(enr)) - // Find first NULL byte - TUTK uses strlen() on binary PSK pskLen := 32 for i := range 32 { if hash[i] == 0x00 { @@ -1785,7 +915,7 @@ func derivePSK(enr string) []byte { } } - // Create PSK: bytes up to first 0x00, rest padded with zeros + // bytes up to first 0x00, rest padded with zeros psk := make([]byte, 32) copy(psk[:pskLen], hash[:pskLen]) return psk @@ -1796,19 +926,3 @@ func genRandomID() []byte { _, _ = rand.Read(b) return b } - -func hexDump(data []byte) string { - var result string - for i := 0; i < len(data); i += 16 { - end := i + 16 - if end > len(data) { - end = len(data) - } - line := fmt.Sprintf(" %04x:", i) - for j := i; j < end; j++ { - line += fmt.Sprintf(" %02x", data[j]) - } - result += line + "\n" - } - return result -} diff --git a/pkg/wyze/tutk/constants.go b/pkg/wyze/tutk/constants.go deleted file mode 100644 index 5645f969..00000000 --- a/pkg/wyze/tutk/constants.go +++ /dev/null @@ -1,306 +0,0 @@ -package tutk - -const ( - CodecUnknown uint16 = 0x00 // Unknown codec - CodecMPEG4 uint16 = 0x4C // 76 - MPEG4 - CodecH263 uint16 = 0x4D // 77 - H.263 - CodecH264 uint16 = 0x4E // 78 - H.264/AVC - CodecMJPEG uint16 = 0x4F // 79 - MJPEG - CodecH265 uint16 = 0x50 // 80 - H.265/HEVC -) - -const ( - AudioCodecAACRaw uint16 = 0x86 // 134 - AAC raw format - AudioCodecAACADTS uint16 = 0x87 // 135 - AAC with ADTS header - AudioCodecAACLATM uint16 = 0x88 // 136 - AAC with LATM format - AudioCodecG711U uint16 = 0x89 // 137 - G.711 μ-law (PCMU) - AudioCodecG711A uint16 = 0x8A // 138 - G.711 A-law (PCMA) - AudioCodecADPCM uint16 = 0x8B // 139 - ADPCM - AudioCodecPCM uint16 = 0x8C // 140 - PCM 16-bit signed LE - AudioCodecSPEEX uint16 = 0x8D // 141 - Speex - AudioCodecMP3 uint16 = 0x8E // 142 - MP3 - AudioCodecG726 uint16 = 0x8F // 143 - G.726 - AudioCodecAACWyze uint16 = 0x90 // 144 - Wyze AAC - AudioCodecOpus uint16 = 0x92 // 146 - Opus codec -) - -const ( - SampleRate8K uint8 = 0x00 // 8000 Hz - SampleRate11K uint8 = 0x01 // 11025 Hz - SampleRate12K uint8 = 0x02 // 12000 Hz - SampleRate16K uint8 = 0x03 // 16000 Hz - SampleRate22K uint8 = 0x04 // 22050 Hz - SampleRate24K uint8 = 0x05 // 24000 Hz - SampleRate32K uint8 = 0x06 // 32000 Hz - SampleRate44K uint8 = 0x07 // 44100 Hz - SampleRate48K uint8 = 0x08 // 48000 Hz -) - -var SampleRates = map[uint8]int{ - SampleRate8K: 8000, - SampleRate11K: 11025, - SampleRate12K: 12000, - SampleRate16K: 16000, - SampleRate22K: 22050, - SampleRate24K: 24000, - SampleRate32K: 32000, - SampleRate44K: 44100, - SampleRate48K: 48000, -} - -var SamplesPerFrame = map[uint16]uint32{ - AudioCodecAACRaw: 1024, // AAC frame = 1024 samples - AudioCodecAACADTS: 1024, - AudioCodecAACLATM: 1024, - AudioCodecAACWyze: 1024, - AudioCodecG711U: 160, // G.711 typically 20ms = 160 samples at 8kHz - AudioCodecG711A: 160, - AudioCodecPCM: 160, - AudioCodecADPCM: 160, - AudioCodecSPEEX: 160, - AudioCodecMP3: 1152, // MP3 frame = 1152 samples - AudioCodecG726: 160, - AudioCodecOpus: 960, // Opus typically 20ms = 960 samples at 48kHz -} - -const ( - IOTypeVideoStart = 0x01FF - IOTypeVideoStop = 0x02FF - IOTypeAudioStart = 0x0300 - IOTypeAudioStop = 0x0301 - IOTypeSpeakerStart = 0x0350 - IOTypeSpeakerStop = 0x0351 - IOTypeGetAudioOutFormatReq = 0x032A - IOTypeGetAudioOutFormatRes = 0x032B - IOTypeSetStreamCtrlReq = 0x0320 - IOTypeSetStreamCtrlRes = 0x0321 - IOTypeGetStreamCtrlReq = 0x0322 - IOTypeGetStreamCtrlRes = 0x0323 - IOTypeDevInfoReq = 0x0340 - IOTypeDevInfoRes = 0x0341 - IOTypeGetSupportStreamReq = 0x0344 - IOTypeGetSupportStreamRes = 0x0345 - IOTypeSetRecordReq = 0x0310 - IOTypeSetRecordRes = 0x0311 - IOTypeGetRecordReq = 0x0312 - IOTypeGetRecordRes = 0x0313 - IOTypePTZCommand = 0x1001 - IOTypeReceiveFirstFrame = 0x1002 - IOTypeGetEnvironmentReq = 0x030A - IOTypeGetEnvironmentRes = 0x030B - IOTypeSetVideoModeReq = 0x030C - IOTypeSetVideoModeRes = 0x030D - IOTypeGetVideoModeReq = 0x030E - IOTypeGetVideoModeRes = 0x030F - IOTypeSetTimeReq = 0x0316 - IOTypeSetTimeRes = 0x0317 - IOTypeGetTimeReq = 0x0318 - IOTypeGetTimeRes = 0x0319 - IOTypeSetWifiReq = 0x0102 - IOTypeSetWifiRes = 0x0103 - IOTypeGetWifiReq = 0x0104 - IOTypeGetWifiRes = 0x0105 - IOTypeListWifiAPReq = 0x0106 - IOTypeListWifiAPRes = 0x0107 - IOTypeSetMotionDetectReq = 0x0306 - IOTypeSetMotionDetectRes = 0x0307 - IOTypeGetMotionDetectReq = 0x0308 - IOTypeGetMotionDetectRes = 0x0309 -) - -// OLD DTLS Protocol (IOTC/TransCode) commands and sizes -const ( - CmdDiscoReq uint16 = 0x0601 - CmdDiscoRes uint16 = 0x0602 - CmdSessionReq uint16 = 0x0402 - CmdSessionRes uint16 = 0x0404 - CmdDataTX uint16 = 0x0407 - CmdDataRX uint16 = 0x0408 - CmdKeepaliveReq uint16 = 0x0427 - CmdKeepaliveRes uint16 = 0x0428 - OldProtoHeaderSize = 16 - OldProtoMinPacketSize = 16 - OldProtoDiscoBodySize = 72 - OldProtoDiscoPacketSize = OldProtoHeaderSize + OldProtoDiscoBodySize - OldProtoSessionBodySize = 36 - OldProtoSessionPacketSize = OldProtoHeaderSize + OldProtoSessionBodySize -) - -// NEW DTLS Protocol (0xCC51) commands and sizes -const ( - MagicNewProto uint16 = 0xCC51 - CmdNewProtoDiscovery uint16 = 0x1002 - CmdNewProtoDTLS uint16 = 0x1502 - NewProtoPayloadSize uint16 = 0x0028 - NewProtoPacketSize = 52 - NewProtoHeaderSize = 28 - NewProtoAuthSize = 20 -) - -const ( - UIDSize = 20 - RandomIDSize = 8 -) - -const ( - MagicAVLoginResp uint16 = 0x2100 - MagicIOCtrl uint16 = 0x7000 - MagicChannelMsg uint16 = 0x1000 - MagicACK uint16 = 0x0009 - MagicAVLogin1 uint16 = 0x0000 - MagicAVLogin2 uint16 = 0x2000 -) - -const ( - ProtocolVersion uint16 = 0x000c // Version 12 -) - -const ( - DefaultCapabilities uint32 = 0x001f07fb -) - -const ( - KCmdAuth = 10000 - KCmdChallenge = 10001 - KCmdChallengeResp = 10002 - KCmdAuthResult = 10003 - KCmdAuthWithPayload = 10008 - KCmdAuthSuccess = 10009 - KCmdControlChannel = 10010 - KCmdControlChannelResp = 10011 - KCmdSetResolution = 10056 - KCmdSetResolutionResp = 10057 -) - -const ( - MediaTypeVideo = 1 - MediaTypeAudio = 2 - MediaTypeReturnAudio = 3 - MediaTypeRDT = 4 -) - -const ( - IOTCChannelMain = 0 // Main AV channel (we = DTLS Client, camera = Server) - IOTCChannelBack = 1 // Backchannel for Return Audio (we = DTLS Server, camera = Client) -) - -const ( - BitrateMax uint16 = 0xF0 // 240 KB/s - BitrateSD uint16 = 0x3C // 60 KB/s -) - -const ( - FrameSize1080P = 0 - FrameSize360P = 1 - FrameSize720P = 2 - FrameSize2K = 3 -) - -const ( - QualityUnknown = 0 - QualityMax = 1 - QualityHigh = 2 - QualityMiddle = 3 - QualityLow = 4 - QualityMin = 5 -) - -func CodecName(id uint16) string { - switch id { - case CodecH264: - return "H264" - case CodecH265: - return "H265" - case CodecMPEG4: - return "MPEG4" - case CodecH263: - return "H263" - case CodecMJPEG: - return "MJPEG" - default: - return "Unknown" - } -} - -func AudioCodecName(id uint16) string { - switch id { - case AudioCodecG711U: - return "PCMU" - case AudioCodecG711A: - return "PCMA" - case AudioCodecPCM: - return "PCM" - case AudioCodecAACLATM, AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACWyze: - return "AAC" - case AudioCodecOpus: - return "Opus" - case AudioCodecSPEEX: - return "Speex" - case AudioCodecMP3: - return "MP3" - case AudioCodecG726: - return "G726" - case AudioCodecADPCM: - return "ADPCM" - default: - return "Unknown" - } -} - -func SampleRateValue(enum uint8) int { - if rate, ok := SampleRates[enum]; ok { - return rate - } - return 16000 // Default -} - -func SampleRateIndex(hz uint32) uint8 { - switch hz { - case 8000: - return SampleRate8K - case 11025: - return SampleRate11K - case 12000: - return SampleRate12K - case 16000: - return SampleRate16K - case 22050: - return SampleRate22K - case 24000: - return SampleRate24K - case 32000: - return SampleRate32K - case 44100: - return SampleRate44K - case 48000: - return SampleRate48K - default: - return SampleRate16K // Default - } -} - -func BuildAudioFlags(sampleRate uint32, bits16 bool, stereo bool) uint8 { - flags := SampleRateIndex(sampleRate) << 2 - if bits16 { - flags |= 0x02 - } - if stereo { - flags |= 0x01 - } - return flags -} - -func IsVideoCodec(id uint16) bool { - return id >= CodecMPEG4 && id <= CodecH265 -} - -func IsAudioCodec(id uint16) bool { - return id >= AudioCodecAACRaw && id <= AudioCodecOpus -} - -func GetSamplesPerFrame(codecID uint16) uint32 { - if samples, ok := SamplesPerFrame[codecID]; ok { - return samples - } - return 1024 // Default to AAC -} diff --git a/pkg/wyze/tutk/dtls.go b/pkg/wyze/tutk/dtls.go new file mode 100644 index 00000000..e24425bd --- /dev/null +++ b/pkg/wyze/tutk/dtls.go @@ -0,0 +1,74 @@ +package tutk + +import ( + "net" + "time" + + "github.com/pion/dtls/v3" +) + +func NewDtlsClient(c *Conn, channel uint8, psk []byte) (*dtls.Conn, error) { + adapter := &ChannelAdapter{conn: c, channel: channel} + return dtls.Client(adapter, c.addr, buildDtlsConfig(psk, false)) +} + +func NewDtlsServer(c *Conn, channel uint8, psk []byte) (*dtls.Conn, error) { + adapter := &ChannelAdapter{conn: c, channel: channel} + return dtls.Server(adapter, c.addr, buildDtlsConfig(psk, true)) +} + +func buildDtlsConfig(psk []byte, isServer bool) *dtls.Config { + config := &dtls.Config{ + PSK: func(hint []byte) ([]byte, error) { + return psk, nil + }, + PSKIdentityHint: []byte(PSKIdentity), + InsecureSkipVerify: true, + InsecureSkipVerifyHello: true, + MTU: 1200, + FlightInterval: 300 * time.Millisecond, + ExtendedMasterSecret: dtls.DisableExtendedMasterSecret, + } + + if isServer { + config.CipherSuites = []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_CBC_SHA256} + } else { + config.CustomCipherSuites = CustomCipherSuites + } + + return config +} + +type ChannelAdapter struct { + conn *Conn + channel uint8 +} + +func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + var buf chan []byte + if a.channel == IOTCChannelMain { + buf = a.conn.mainBuf + } else { + buf = a.conn.speakBuf + } + + select { + case data := <-buf: + return copy(p, data), a.conn.addr, nil + case <-a.conn.ctx.Done(): + return 0, nil, net.ErrClosed + } +} + +func (a *ChannelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { + if err := a.conn.WriteDTLS(p, a.channel); err != nil { + return 0, err + } + return len(p), nil +} + +func (a *ChannelAdapter) Close() error { return nil } +func (a *ChannelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } +func (a *ChannelAdapter) SetDeadline(time.Time) error { return nil } +func (a *ChannelAdapter) SetReadDeadline(time.Time) error { return nil } +func (a *ChannelAdapter) SetWriteDeadline(time.Time) error { return nil } diff --git a/pkg/wyze/tutk/frame.go b/pkg/wyze/tutk/frame.go new file mode 100644 index 00000000..3777f9fd --- /dev/null +++ b/pkg/wyze/tutk/frame.go @@ -0,0 +1,505 @@ +package tutk + +import ( + "encoding/binary" + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/aac" +) + +const ( + FrameTypeStart uint8 = 0x08 // Extended start (36-byte header) + FrameTypeStartAlt uint8 = 0x09 // StartAlt (36-byte header) + FrameTypeCont uint8 = 0x00 // Continuation (28-byte header) + FrameTypeContAlt uint8 = 0x04 // Continuation alt + FrameTypeEndSingle uint8 = 0x01 // Single-packet frame (28-byte) + FrameTypeEndMulti uint8 = 0x05 // Multi-packet end (28-byte) + FrameTypeEndExt uint8 = 0x0d // Extended end (36-byte) +) + +const ( + ChannelIVideo uint8 = 0x05 + ChannelAudio uint8 = 0x03 + ChannelPVideo uint8 = 0x07 +) + +// Resolution constants +const ( + ResolutionUnknown = 0 + ResolutionSD = 1 + Resolution360P = 2 + Resolution2K = 4 +) + +const FrameInfoSize = 40 + +// FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet) +type FrameInfo struct { + CodecID uint16 + Flags uint8 + CamIndex uint8 + OnlineNum uint8 + Framerate uint8 + FrameSize uint8 + Bitrate uint8 + TimestampUS uint32 + Timestamp uint32 + PayloadSize uint32 + FrameNo uint32 +} + +func (fi *FrameInfo) IsKeyframe() bool { + return fi.Flags == 0x01 +} + +func (fi *FrameInfo) Resolution() string { + switch fi.FrameSize { + case ResolutionSD: + return "SD" + case Resolution360P: + return "360P" + case Resolution2K: + return "2K" + default: + return "unknown" + } +} + +func (fi *FrameInfo) SampleRate() uint32 { + idx := (fi.Flags >> 2) & 0x0F + return uint32(SampleRateValue(idx)) +} + +func (fi *FrameInfo) Channels() uint8 { + if fi.Flags&0x01 == 1 { + return 2 + } + return 1 +} + +func (fi *FrameInfo) IsVideo() bool { + return IsVideoCodec(fi.CodecID) +} + +func (fi *FrameInfo) IsAudio() bool { + return IsAudioCodec(fi.CodecID) +} + +func ParseFrameInfo(data []byte) *FrameInfo { + if len(data) < FrameInfoSize { + return nil + } + + offset := len(data) - FrameInfoSize + fi := data[offset:] + + return &FrameInfo{ + CodecID: binary.LittleEndian.Uint16(fi), + Flags: fi[2], + CamIndex: fi[3], + OnlineNum: fi[4], + Framerate: fi[5], + FrameSize: fi[6], + Bitrate: fi[7], + TimestampUS: binary.LittleEndian.Uint32(fi[8:]), + Timestamp: binary.LittleEndian.Uint32(fi[12:]), + PayloadSize: binary.LittleEndian.Uint32(fi[16:]), + FrameNo: binary.LittleEndian.Uint32(fi[20:]), + } +} + +type Packet struct { + Channel uint8 + Codec uint16 + Timestamp uint32 + Payload []byte + IsKeyframe bool + FrameNo uint32 + SampleRate uint32 + Channels uint8 +} + +func (p *Packet) IsVideo() bool { + return p.Channel == ChannelIVideo || p.Channel == ChannelPVideo +} + +func (p *Packet) IsAudio() bool { + return p.Channel == ChannelAudio +} + +type PacketHeader struct { + Channel byte + FrameType byte + HeaderSize int + FrameNo uint32 + PktIdx uint16 + PktTotal uint16 + PayloadSize uint16 + HasFrameInfo bool +} + +func ParsePacketHeader(data []byte) *PacketHeader { + if len(data) < 28 { + return nil + } + + frameType := data[1] + hdr := &PacketHeader{ + Channel: data[0], + FrameType: frameType, + } + + switch frameType { + case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt: + hdr.HeaderSize = 36 + default: + hdr.HeaderSize = 28 + } + + if len(data) < hdr.HeaderSize { + return nil + } + + if hdr.HeaderSize == 28 { + hdr.PktTotal = binary.LittleEndian.Uint16(data[12:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[14:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[24:]) + + if IsEndFrame(frameType) && pktIdxOrMarker == 0x0028 { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } else { + hdr.PktTotal = binary.LittleEndian.Uint16(data[20:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[22:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[32:]) + + if IsEndFrame(frameType) && pktIdxOrMarker == 0x0028 { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } + + return hdr +} + +func IsStartFrame(frameType uint8) bool { + return frameType == FrameTypeStart || frameType == FrameTypeStartAlt +} + +func IsEndFrame(frameType uint8) bool { + return frameType == FrameTypeEndSingle || + frameType == FrameTypeEndMulti || + frameType == FrameTypeEndExt +} + +func IsContinuationFrame(frameType uint8) bool { + return frameType == FrameTypeCont || frameType == FrameTypeContAlt +} + +type FrameAssembler struct { + FrameNo uint32 + PktTotal uint16 + Packets map[uint16][]byte + FrameInfo *FrameInfo +} + +func ParseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { + if aac.IsADTS(payload) { + codec := aac.ADTSToCodec(payload) + if codec != nil { + return codec.ClockRate, codec.Channels + } + } + + if fi != nil { + return fi.SampleRate(), fi.Channels() + } + + return 16000, 1 +} + +type FrameHandler struct { + assemblers map[byte]*FrameAssembler + baseTS uint64 + output chan *Packet + verbose bool +} + +func NewFrameHandler(verbose bool) *FrameHandler { + return &FrameHandler{ + assemblers: make(map[byte]*FrameAssembler), + output: make(chan *Packet, 128), + verbose: verbose, + } +} + +func (h *FrameHandler) Recv() <-chan *Packet { + return h.output +} + +func (h *FrameHandler) Close() { + close(h.output) +} + +func (h *FrameHandler) Handle(data []byte) { + hdr := ParsePacketHeader(data) + if hdr == nil { + return + } + + if h.verbose { + h.logWireHeader(data, hdr) + } + + payload, fi := h.extractPayload(data, hdr.Channel) + if payload == nil { + return + } + + if h.verbose { + h.logAVPacket(hdr.Channel, hdr.FrameType, payload, fi) + } + + switch hdr.Channel { + case ChannelAudio: + h.handleAudio(payload, fi) + case ChannelIVideo, ChannelPVideo: + h.handleVideo(hdr.Channel, hdr, payload, fi) + } +} + +func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { + if len(data) < 2 { + return nil, nil + } + + frameType := data[1] + + headerSize := 28 + frameInfoSize := 0 + + switch frameType { + case FrameTypeStart: + headerSize = 36 + case FrameTypeStartAlt: + headerSize = 36 + if len(data) >= 22 { + pktTotal := binary.LittleEndian.Uint16(data[20:]) + if pktTotal == 1 { + frameInfoSize = FrameInfoSize + } + } + case FrameTypeCont, FrameTypeContAlt: + headerSize = 28 + case FrameTypeEndSingle, FrameTypeEndMulti: + headerSize = 28 + frameInfoSize = FrameInfoSize + case FrameTypeEndExt: + headerSize = 36 + frameInfoSize = FrameInfoSize + default: + headerSize = 28 + } + + if len(data) < headerSize { + return nil, nil + } + + if frameInfoSize == 0 { + return data[headerSize:], nil + } + + if len(data) < headerSize+frameInfoSize { + return data[headerSize:], nil + } + + fi := ParseFrameInfo(data) + + validCodec := false + switch channel { + case ChannelIVideo, ChannelPVideo: + validCodec = IsVideoCodec(fi.CodecID) + case ChannelAudio: + validCodec = IsAudioCodec(fi.CodecID) + } + + if validCodec { + payload := data[headerSize : len(data)-frameInfoSize] + return payload, fi + } + + return data[headerSize:], nil +} + +func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) { + asm := h.assemblers[channel] + + // Frame transition: new frame number = previous frame complete + if asm != nil && hdr.FrameNo != asm.FrameNo { + gotAll := uint16(len(asm.Packets)) == asm.PktTotal + if gotAll && asm.FrameInfo != nil { + h.assembleAndQueue(channel, asm) + } + asm = nil + } + + // Create new assembler if needed + if asm == nil { + asm = &FrameAssembler{ + FrameNo: hdr.FrameNo, + PktTotal: hdr.PktTotal, + Packets: make(map[uint16][]byte, hdr.PktTotal), + } + h.assemblers[channel] = asm + } + + // Store packet (copy payload - buffer is reused by worker) + payloadCopy := make([]byte, len(payload)) + copy(payloadCopy, payload) + asm.Packets[hdr.PktIdx] = payloadCopy + + if fi != nil { + asm.FrameInfo = fi + } + + // Check if frame is complete + if uint16(len(asm.Packets)) == asm.PktTotal && asm.FrameInfo != nil { + h.assembleAndQueue(channel, asm) + delete(h.assemblers, channel) + } +} + +func (h *FrameHandler) assembleAndQueue(channel byte, asm *FrameAssembler) { + fi := asm.FrameInfo + + // Assemble packets in correct order + var payload []byte + for i := uint16(0); i < asm.PktTotal; i++ { + if pkt, ok := asm.Packets[i]; ok { + payload = append(payload, pkt...) + } + } + + // Size validation + if fi.PayloadSize > 0 && len(payload) != int(fi.PayloadSize) { + return + } + + if len(payload) == 0 { + return + } + + // Calculate RTP timestamp (90kHz for video) using relative timestamps + absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) + if h.baseTS == 0 { + h.baseTS = absoluteTS + } + relativeUS := absoluteTS - h.baseTS + const clockRate uint64 = 90000 + rtpTS := uint32(relativeUS * clockRate / 1000000) + + pkt := &Packet{ + Channel: channel, + Payload: payload, + Codec: fi.CodecID, + Timestamp: rtpTS, + IsKeyframe: fi.IsKeyframe(), + FrameNo: fi.FrameNo, + } + + if h.verbose { + frameType := "P" + if fi.IsKeyframe() { + frameType = "I" + } + fmt.Printf("[VIDEO] #%d %s %s size=%d rtp=%d\n", + fi.FrameNo, CodecName(fi.CodecID), frameType, len(payload), rtpTS) + } + + h.queue(pkt) +} + +func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { + if len(payload) == 0 || fi == nil { + return + } + + var sampleRate uint32 + var channels uint8 + + switch fi.CodecID { + case AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACLATM, AudioCodecAACWyze: + sampleRate, channels = ParseAudioParams(payload, fi) + default: + sampleRate = fi.SampleRate() + channels = fi.Channels() + } + + // Calculate RTP timestamp using relative timestamps (shared baseTS for A/V sync) + absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) + if h.baseTS == 0 { + h.baseTS = absoluteTS + } + relativeUS := absoluteTS - h.baseTS + clockRate := uint64(sampleRate) + rtpTS := uint32(relativeUS * clockRate / 1000000) + + pkt := &Packet{ + Channel: ChannelAudio, + Payload: payload, + Codec: fi.CodecID, + Timestamp: rtpTS, + SampleRate: sampleRate, + Channels: channels, + FrameNo: fi.FrameNo, + } + + if h.verbose { + fmt.Printf("[AUDIO] #%d %s size=%d rate=%d ch=%d rtp=%d\n", + fi.FrameNo, AudioCodecName(fi.CodecID), len(payload), sampleRate, channels, rtpTS) + } + + h.queue(pkt) +} + +func (h *FrameHandler) queue(pkt *Packet) { + select { + case h.output <- pkt: + default: + // Queue full - drop oldest + select { + case <-h.output: + default: + } + h.output <- pkt + } +} + +func (h *FrameHandler) logWireHeader(data []byte, hdr *PacketHeader) { + fmt.Printf("[WIRE] ch=0x%02x type=0x%02x len=%d pkt=%d/%d frame=%d\n", + hdr.Channel, hdr.FrameType, len(data), hdr.PktIdx, hdr.PktTotal, hdr.FrameNo) + fmt.Printf(" RAW[0..35]: ") + for i := 0; i < 36 && i < len(data); i++ { + fmt.Printf("%02x ", data[i]) + } + fmt.Printf("\n") +} + +func (h *FrameHandler) logAVPacket(channel, frameType byte, payload []byte, fi *FrameInfo) { + fmt.Printf("[AV] ch=0x%02x type=0x%02x len=%d", channel, frameType, len(payload)) + if fi != nil { + fmt.Printf(" fi={codec=0x%04x flags=0x%02x ts=%d}", fi.CodecID, fi.Flags, fi.Timestamp) + } + fmt.Printf("\n") +} diff --git a/pkg/wyze/tutk/proto.go b/pkg/wyze/tutk/proto.go new file mode 100644 index 00000000..01bd7cd5 --- /dev/null +++ b/pkg/wyze/tutk/proto.go @@ -0,0 +1,278 @@ +package tutk + +type AVLoginResponse struct { + ServerType uint32 + Resend int32 + TwoWayStreaming int32 + SyncRecvData int32 + SecurityMode uint32 + VideoOnConnect int32 + AudioOnConnect int32 +} + +const ( + CodecUnknown uint16 = 0x00 + CodecMPEG4 uint16 = 0x4C // 76 + CodecH263 uint16 = 0x4D // 77 + CodecH264 uint16 = 0x4E // 78 + CodecMJPEG uint16 = 0x4F // 79 + CodecH265 uint16 = 0x50 // 80 +) + +const ( + AudioCodecAACRaw uint16 = 0x86 // 134 + AudioCodecAACADTS uint16 = 0x87 // 135 + AudioCodecAACLATM uint16 = 0x88 // 136 + AudioCodecG711U uint16 = 0x89 // 137 + AudioCodecG711A uint16 = 0x8A // 138 + AudioCodecADPCM uint16 = 0x8B // 139 + AudioCodecPCM uint16 = 0x8C // 140 + AudioCodecSPEEX uint16 = 0x8D // 141 + AudioCodecMP3 uint16 = 0x8E // 142 + AudioCodecG726 uint16 = 0x8F // 143 + AudioCodecAACWyze uint16 = 0x90 // 144 + AudioCodecOpus uint16 = 0x92 // 146 +) + +const ( + SampleRate8K uint8 = 0x00 + SampleRate11K uint8 = 0x01 + SampleRate12K uint8 = 0x02 + SampleRate16K uint8 = 0x03 + SampleRate22K uint8 = 0x04 + SampleRate24K uint8 = 0x05 + SampleRate32K uint8 = 0x06 + SampleRate44K uint8 = 0x07 + SampleRate48K uint8 = 0x08 +) + +var sampleRates = map[uint8]int{ + SampleRate8K: 8000, + SampleRate11K: 11025, + SampleRate12K: 12000, + SampleRate16K: 16000, + SampleRate22K: 22050, + SampleRate24K: 24000, + SampleRate32K: 32000, + SampleRate44K: 44100, + SampleRate48K: 48000, +} + +var samplesPerFrame = map[uint16]uint32{ + AudioCodecAACRaw: 1024, + AudioCodecAACADTS: 1024, + AudioCodecAACLATM: 1024, + AudioCodecAACWyze: 1024, + AudioCodecG711U: 160, + AudioCodecG711A: 160, + AudioCodecPCM: 160, + AudioCodecADPCM: 160, + AudioCodecSPEEX: 160, + AudioCodecMP3: 1152, + AudioCodecG726: 160, + AudioCodecOpus: 960, +} + +const ( + IOTypeVideoStart = 0x01FF + IOTypeVideoStop = 0x02FF + IOTypeAudioStart = 0x0300 + IOTypeAudioStop = 0x0301 + IOTypeSpeakerStart = 0x0350 + IOTypeSpeakerStop = 0x0351 + IOTypeGetAudioOutFormatReq = 0x032A + IOTypeGetAudioOutFormatRes = 0x032B + IOTypeSetStreamCtrlReq = 0x0320 + IOTypeSetStreamCtrlRes = 0x0321 + IOTypeGetStreamCtrlReq = 0x0322 + IOTypeGetStreamCtrlRes = 0x0323 + IOTypeDevInfoReq = 0x0340 + IOTypeDevInfoRes = 0x0341 + IOTypeGetSupportStreamReq = 0x0344 + IOTypeGetSupportStreamRes = 0x0345 + IOTypeSetRecordReq = 0x0310 + IOTypeSetRecordRes = 0x0311 + IOTypeGetRecordReq = 0x0312 + IOTypeGetRecordRes = 0x0313 + IOTypePTZCommand = 0x1001 + IOTypeReceiveFirstFrame = 0x1002 + IOTypeGetEnvironmentReq = 0x030A + IOTypeGetEnvironmentRes = 0x030B + IOTypeSetVideoModeReq = 0x030C + IOTypeSetVideoModeRes = 0x030D + IOTypeGetVideoModeReq = 0x030E + IOTypeGetVideoModeRes = 0x030F + IOTypeSetTimeReq = 0x0316 + IOTypeSetTimeRes = 0x0317 + IOTypeGetTimeReq = 0x0318 + IOTypeGetTimeRes = 0x0319 + IOTypeSetWifiReq = 0x0102 + IOTypeSetWifiRes = 0x0103 + IOTypeGetWifiReq = 0x0104 + IOTypeGetWifiRes = 0x0105 + IOTypeListWifiAPReq = 0x0106 + IOTypeListWifiAPRes = 0x0107 + IOTypeSetMotionDetectReq = 0x0306 + IOTypeSetMotionDetectRes = 0x0307 + IOTypeGetMotionDetectReq = 0x0308 + IOTypeGetMotionDetectRes = 0x0309 +) + +// OLD Protocol (IOTC/TransCode) +const ( + CmdDiscoReq uint16 = 0x0601 + CmdDiscoRes uint16 = 0x0602 + CmdSessionReq uint16 = 0x0402 + CmdSessionRes uint16 = 0x0404 + CmdDataTX uint16 = 0x0407 + CmdDataRX uint16 = 0x0408 + CmdKeepaliveReq uint16 = 0x0427 + CmdKeepaliveRes uint16 = 0x0428 + + OldHeaderSize = 16 + OldDiscoBodySize = 72 + OldDiscoSize = OldHeaderSize + OldDiscoBodySize + OldSessionBody = 36 + OldSessionSize = OldHeaderSize + OldSessionBody +) + +// NEW Protocol (0xCC51) +const ( + MagicNewProto uint16 = 0xCC51 + CmdNewDisco uint16 = 0x1002 + CmdNewDTLS uint16 = 0x1502 + NewPayloadSize uint16 = 0x0028 + NewPacketSize = 52 + NewHeaderSize = 28 + NewAuthSize = 20 +) + +const ( + UIDSize = 20 + RandIDSize = 8 +) + +const ( + MagicAVLoginResp uint16 = 0x2100 + MagicIOCtrl uint16 = 0x7000 + MagicChannelMsg uint16 = 0x1000 + MagicACK uint16 = 0x0009 + MagicAVLogin1 uint16 = 0x0000 + MagicAVLogin2 uint16 = 0x2000 +) + +const ( + ProtoVersion uint16 = 0x000c + DefaultCaps uint32 = 0x001f07fb +) + +const ( + IOTCChannelMain = 0 // Main AV (we = DTLS Client) + IOTCChannelBack = 1 // Backchannel (we = DTLS Server) +) + +const ( + PSKIdentity = "AUTHPWD_admin" + DefaultUser = "admin" + DefaultPort = 32761 +) + +func CodecName(id uint16) string { + switch id { + case CodecH264: + return "H264" + case CodecH265: + return "H265" + case CodecMPEG4: + return "MPEG4" + case CodecH263: + return "H263" + case CodecMJPEG: + return "MJPEG" + default: + return "Unknown" + } +} + +func AudioCodecName(id uint16) string { + switch id { + case AudioCodecG711U: + return "PCMU" + case AudioCodecG711A: + return "PCMA" + case AudioCodecPCM: + return "PCM" + case AudioCodecAACLATM, AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACWyze: + return "AAC" + case AudioCodecOpus: + return "Opus" + case AudioCodecSPEEX: + return "Speex" + case AudioCodecMP3: + return "MP3" + case AudioCodecG726: + return "G726" + case AudioCodecADPCM: + return "ADPCM" + default: + return "Unknown" + } +} + +func SampleRateValue(idx uint8) int { + if rate, ok := sampleRates[idx]; ok { + return rate + } + return 16000 +} + +func SampleRateIndex(hz uint32) uint8 { + switch hz { + case 8000: + return SampleRate8K + case 11025: + return SampleRate11K + case 12000: + return SampleRate12K + case 16000: + return SampleRate16K + case 22050: + return SampleRate22K + case 24000: + return SampleRate24K + case 32000: + return SampleRate32K + case 44100: + return SampleRate44K + case 48000: + return SampleRate48K + default: + return SampleRate16K + } +} + +func BuildAudioFlags(sampleRate uint32, bits16, stereo bool) uint8 { + flags := SampleRateIndex(sampleRate) << 2 + if bits16 { + flags |= 0x02 + } + if stereo { + flags |= 0x01 + } + return flags +} + +func IsVideoCodec(id uint16) bool { + return id >= CodecMPEG4 && id <= CodecH265 +} + +func IsAudioCodec(id uint16) bool { + return id >= AudioCodecAACRaw && id <= AudioCodecOpus +} + +func GetSamplesPerFrame(codecID uint16) uint32 { + if samples, ok := samplesPerFrame[codecID]; ok { + return samples + } + return 1024 +} diff --git a/pkg/wyze/tutk/types.go b/pkg/wyze/tutk/types.go deleted file mode 100644 index 4ba95f01..00000000 --- a/pkg/wyze/tutk/types.go +++ /dev/null @@ -1,157 +0,0 @@ -package tutk - -import "encoding/binary" - -const ( - // Start packets - first fragment of a frame - // 0x08: Extended start (36-byte header, no FrameInfo) - // 0x09: StartAlt (36-byte header, FrameInfo only if pkt_total==1) - FrameTypeStart uint8 = 0x08 - FrameTypeStartAlt uint8 = 0x09 - - // Continuation packets - middle fragment (28-byte header, no FrameInfo) - FrameTypeCont uint8 = 0x00 - FrameTypeContAlt uint8 = 0x04 - - // End packets - last fragment (with 40-byte FrameInfo) - // 0x01: Single-packet frame (28-byte header) - // 0x05: Multi-packet end (28-byte header) - // 0x0d: Extended end (36-byte header) - FrameTypeEndSingle uint8 = 0x01 - FrameTypeEndMulti uint8 = 0x05 - FrameTypeEndExt uint8 = 0x0d -) - -const ( - ChannelIVideo uint8 = 0x05 - ChannelAudio uint8 = 0x03 - ChannelPVideo uint8 = 0x07 -) - -type Packet struct { - Channel uint8 - Codec uint16 - Timestamp uint32 - Payload []byte - IsKeyframe bool - FrameNo uint32 - SampleRate uint32 - Channels uint8 -} - -func (p *Packet) IsVideo() bool { - return p.Channel == ChannelIVideo || p.Channel == ChannelPVideo -} - -func (p *Packet) IsAudio() bool { - return p.Channel == ChannelAudio -} - -type AuthResponse struct { - ConnectionRes string `json:"connectionRes"` - CameraInfo map[string]any `json:"cameraInfo"` -} - -type AVLoginResponse struct { - ServerType uint32 - Resend int32 - TwoWayStreaming int32 - SyncRecvData int32 - SecurityMode uint32 - VideoOnConnect int32 - AudioOnConnect int32 -} - -func IsStartFrame(frameType uint8) bool { - return frameType == FrameTypeStart || frameType == FrameTypeStartAlt -} - -func IsEndFrame(frameType uint8) bool { - return frameType == FrameTypeEndSingle || - frameType == FrameTypeEndMulti || - frameType == FrameTypeEndExt -} - -func IsContinuationFrame(frameType uint8) bool { - return frameType == FrameTypeCont || frameType == FrameTypeContAlt -} - -type PacketHeader struct { - Channel byte - FrameType byte - HeaderSize int // 28 or 36 - FrameNo uint32 // Frame number (from [24-27] for 28-byte, [32-35] for 36-byte) - PktIdx uint16 // Packet index within frame (0-based) - PktTotal uint16 // Total packets in this frame - PayloadSize uint16 - HasFrameInfo bool // true if [14-15] or [22-23] == 0x0028 -} - -func ParsePacketHeader(data []byte) *PacketHeader { - if len(data) < 28 { - return nil - } - - frameType := data[1] - hdr := &PacketHeader{ - Channel: data[0], - FrameType: frameType, - } - - // Header size based on FrameType (NOT magic bytes!) - switch frameType { - case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt: // 0x08, 0x09, 0x0d - hdr.HeaderSize = 36 - default: // 0x00, 0x01, 0x04, 0x05 - hdr.HeaderSize = 28 - } - - if len(data) < hdr.HeaderSize { - return nil - } - - if hdr.HeaderSize == 28 { - // 28-Byte Header Layout: - // [12-13] pkt_total - // [14-15] pkt_idx OR 0x0028 (FrameInfo marker) - ONLY 0x0028 in End packets! - // [16-17] payload_size - // [24-27] frame_no (uint32) - hdr.PktTotal = binary.LittleEndian.Uint16(data[12:]) - pktIdxOrMarker := binary.LittleEndian.Uint16(data[14:]) - hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:]) - hdr.FrameNo = binary.LittleEndian.Uint32(data[24:]) - - // 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40 - if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 { - hdr.HasFrameInfo = true - if hdr.PktTotal > 0 { - hdr.PktIdx = hdr.PktTotal - 1 // Last packet - } - } else { - hdr.PktIdx = pktIdxOrMarker - } - } else { - // 36-Byte Header Layout: - // [20-21] pkt_total - // [22-23] pkt_idx OR 0x0028 (FrameInfo marker) - ONLY 0x0028 in End packets! - // [24-25] payload_size - // [32-35] frame_no (uint32) - GLOBAL frame counter, matches 28-byte [24-27] - // NOTE: [18-19] is channel-specific frame index, NOT used for reassembly! - hdr.PktTotal = binary.LittleEndian.Uint16(data[20:]) - pktIdxOrMarker := binary.LittleEndian.Uint16(data[22:]) - hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:]) - hdr.FrameNo = binary.LittleEndian.Uint32(data[32:]) - - // 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40 - if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 { - hdr.HasFrameInfo = true - if hdr.PktTotal > 0 { - hdr.PktIdx = hdr.PktTotal - 1 - } - } else { - hdr.PktIdx = pktIdxOrMarker - } - } - - return hdr -} From c5311cdd94e725c685f2943f1dcf50bf5bd6f949 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 10:34:05 +0100 Subject: [PATCH 190/241] Add keepalive command and sequence handling to new protocol --- pkg/wyze/tutk/conn.go | 54 ++++++++++++++++++++++++++---------------- pkg/wyze/tutk/proto.go | 17 +++++++------ 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 962f9166..f524ba9d 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -49,6 +49,7 @@ type Conn struct { seq uint16 seqCmd uint16 avSeq uint32 + kaSeq uint32 // DTLS main *dtls.Conn @@ -317,8 +318,6 @@ func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byt func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, timeout time.Duration) ([]byte, error) { frame := c.buildIOCtrlFrame(payload) - - // Retry send every second var t *time.Timer t = time.AfterFunc(1, func() { if _, err := c.main.Write(frame); err == nil && t != nil { @@ -588,15 +587,23 @@ func (c *Conn) reader() { } // NEW protocol (0xCC51) - if c.newProto && n >= NewHeaderSize+NewAuthSize && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { - if binary.LittleEndian.Uint16(buf[4:]) == CmdNewDTLS { - ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) - dtls := buf[NewHeaderSize : n-NewAuthSize] - switch ch { - case IOTCChannelMain: - c.queue(c.mainBuf, dtls) - case IOTCChannelBack: - c.queue(c.speakBuf, dtls) + if c.newProto && n >= 12 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + cmd := binary.LittleEndian.Uint16(buf[4:]) + switch cmd { + case CmdNewKeepalive: + if n >= NewKeepaliveSize { + _ = c.Write(c.buildNewKeepalive()) + } + case CmdNewDTLS: + if n >= NewHeaderSize+NewAuthSize { + ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) + dtls := buf[NewHeaderSize : n-NewAuthSize] + switch ch { + case IOTCChannelMain: + c.queue(c.mainBuf, dtls) + case IOTCChannelBack: + c.queue(c.speakBuf, dtls) + } } } continue @@ -696,7 +703,6 @@ func (c *Conn) buildDisco(stage byte) []byte { binary.LittleEndian.PutUint16(b[4:], OldDiscoBodySize) // body size binary.LittleEndian.PutUint16(b[8:], CmdDiscoReq) // 0x0601 binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags - body := b[OldHeaderSize:] copy(body[:UIDSize], c.uid) copy(body[36:], "\x01\x01\x02\x04") // unknown @@ -720,8 +726,6 @@ func (c *Conn) buildNewDisco(seq, ticket uint16, isResponse bool) []byte { binary.LittleEndian.PutUint16(b[14:], ticket) copy(b[16:24], c.sid) copy(b[24:32], "\x00\x08\x03\x04\x1d\x00\x00\x00") // SDK 4.3.8.0 - - // HMAC-SHA1(UID+AuthKey, header) authKey := crypto.CalculateAuthKey(c.enr, c.mac) h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) h.Write(b[:32]) @@ -729,13 +733,27 @@ func (c *Conn) buildNewDisco(seq, ticket uint16, isResponse bool) []byte { return b } +func (c *Conn) buildNewKeepalive() []byte { + c.kaSeq += 2 + b := make([]byte, NewKeepaliveSize) + binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 + binary.LittleEndian.PutUint16(b[4:], CmdNewKeepalive) // 0x1202 + binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload + binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter + copy(b[20:28], c.sid) // session ID + authKey := crypto.CalculateAuthKey(c.enr, c.mac) + h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) + h.Write(b[:28]) + copy(b[28:48], h.Sum(nil)) + return b +} + func (c *Conn) buildSession() []byte { b := make([]byte, OldSessionSize) copy(b, "\x04\x02\x1a\x02") // marker + mode binary.LittleEndian.PutUint16(b[4:], OldSessionBody) // body size binary.LittleEndian.PutUint16(b[8:], CmdSessionReq) // 0x0402 binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags - body := b[OldHeaderSize:] copy(body[:UIDSize], c.uid) copy(body[UIDSize:], c.rid) @@ -805,9 +823,7 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload)) binary.LittleEndian.PutUint32(b[28:], prevFrame) binary.LittleEndian.PutUint32(b[32:], c.audioFrame) - - // Payload + FrameInfo - copy(b[36:], payload) + copy(b[36:], payload) // Payload + FrameInfo fi := b[36+len(payload):] binary.LittleEndian.PutUint16(fi, codec) fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) @@ -845,8 +861,6 @@ func (c *Conn) buildNewTxData(payload []byte, channel byte) []byte { copy(b[16:24], c.sid) binary.LittleEndian.PutUint32(b[24:], 1) // const copy(b[NewHeaderSize:], payload) - - // HMAC-SHA1(UID+AuthKey, header) authKey := crypto.CalculateAuthKey(c.enr, c.mac) h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) h.Write(b[:NewHeaderSize]) diff --git a/pkg/wyze/tutk/proto.go b/pkg/wyze/tutk/proto.go index 01bd7cd5..5614d643 100644 --- a/pkg/wyze/tutk/proto.go +++ b/pkg/wyze/tutk/proto.go @@ -138,13 +138,16 @@ const ( // NEW Protocol (0xCC51) const ( - MagicNewProto uint16 = 0xCC51 - CmdNewDisco uint16 = 0x1002 - CmdNewDTLS uint16 = 0x1502 - NewPayloadSize uint16 = 0x0028 - NewPacketSize = 52 - NewHeaderSize = 28 - NewAuthSize = 20 + MagicNewProto uint16 = 0xCC51 + CmdNewDisco uint16 = 0x1002 + CmdNewKeepalive uint16 = 0x1202 + CmdNewClose uint16 = 0x1302 + CmdNewDTLS uint16 = 0x1502 + NewPayloadSize uint16 = 0x0028 + NewPacketSize = 52 + NewHeaderSize = 28 + NewAuthSize = 20 + NewKeepaliveSize = 48 ) const ( From 5fcb33c0cd39baf6edd10901d34583e2c6270c85 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 11:00:28 +0100 Subject: [PATCH 191/241] Enhance video resolution handling by adding model-specific logic and updating subtype parsing --- internal/wyze/wyze.go | 1 + pkg/wyze/client.go | 97 +++++++++++++++++++++++++++++++++++++------ pkg/wyze/producer.go | 17 ++++++-- 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go index 85d4c19c..d8e53b4d 100644 --- a/internal/wyze/wyze.go +++ b/internal/wyze/wyze.go @@ -188,6 +188,7 @@ func buildStreamURL(cam *wyze.Camera) string { query.Set("uid", cam.P2PID) query.Set("enr", cam.ENR) query.Set("mac", cam.MAC) + query.Set("model", cam.ProductModel) if cam.DTLS == 1 { query.Set("dtls", "true") diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index ab1394b8..5c531b5e 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -15,10 +15,11 @@ import ( ) const ( - FrameSize1080P = 0 - FrameSize360P = 1 - FrameSize720P = 2 - FrameSize2K = 3 + FrameSize1080P = 0 + FrameSize360P = 1 + FrameSize720P = 2 + FrameSize2K = 3 + FrameSizeFloodlight = 4 ) const ( @@ -51,6 +52,8 @@ const ( KCmdAuthSuccess = 10009 KCmdControlChannel = 10010 KCmdControlChannelResp = 10011 + KCmdSetResolutionDB = 10052 + KCmdSetResolutionDBRes = 10053 KCmdSetResolution = 10056 KCmdSetResolutionResp = 10057 ) @@ -58,10 +61,11 @@ const ( type Client struct { conn *tutk.Conn - host string - uid string - enr string - mac string + host string + uid string + enr string + mac string + model string authKey string verbose bool @@ -99,6 +103,7 @@ func Dial(rawURL string) (*Client, error) { uid: query.Get("uid"), enr: query.Get("enr"), mac: query.Get("mac"), + model: query.Get("model"), verbose: query.Get("verbose") == "true", } @@ -148,20 +153,44 @@ func (c *Client) GetBackchannelCodec() (codecID uint16, sampleRate uint32, chann return c.audioCodecID, c.audioSampleRate, c.audioChannels } -func (c *Client) SetResolution(sd bool) error { +func (c *Client) SetResolution(quality byte) error { var frameSize uint8 var bitrate uint16 - if sd { + switch quality { + case 0: // Auto/HD - use model's best + frameSize = c.hdFrameSize() + bitrate = BitrateMax + case FrameSize360P: // 1 = SD/360P frameSize = FrameSize360P bitrate = BitrateSD - } else { - frameSize = FrameSize2K + case FrameSize720P: // 2 = 720P + frameSize = FrameSize720P + bitrate = BitrateMax + case FrameSize2K: // 3 = 2K + if c.is2K() { + frameSize = FrameSize2K + } else { + frameSize = c.hdFrameSize() + } + bitrate = BitrateMax + case FrameSizeFloodlight: // 4 = Floodlight + frameSize = c.hdFrameSize() + bitrate = BitrateMax + default: + frameSize = quality bitrate = BitrateMax } if c.verbose { - fmt.Printf("[Wyze] SetResolution: sd=%v frameSize=%d bitrate=%d\n", sd, frameSize, bitrate) + fmt.Printf("[Wyze] SetResolution: quality=%d frameSize=%d bitrate=%d model=%s\n", quality, frameSize, bitrate, c.model) + } + + // Use K10052 (doorbell format) for certain models + if c.useDoorbellResolution() { + k10052 := c.buildK10052(frameSize, bitrate) + _, err := c.conn.WriteAndWaitIOCtrl(KCmdSetResolutionDB, k10052, KCmdSetResolutionDBRes, 5*time.Second) + return err } k10056 := c.buildK10056(frameSize, bitrate) @@ -379,6 +408,18 @@ func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { return b } +func (c *Client) buildK10052(frameSize uint8, bitrate uint16) []byte { + b := make([]byte, 22) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdSetResolutionDB) // 10052 + binary.LittleEndian.PutUint16(b[6:], 6) // payload len + binary.LittleEndian.PutUint16(b[16:], bitrate) // bitrate (2 bytes) + b[18] = frameSize + 1 // frame size (1 byte) + // b[19] = fps, b[20:22] = zeros + return b +} + func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { b := make([]byte, 21) copy(b, "HL") // magic @@ -493,3 +534,33 @@ func (c *Client) parseK10009(data []byte) (*AuthResponse, error) { return &AuthResponse{}, nil } + +func (c *Client) useDoorbellResolution() bool { + switch c.model { + case "WYZEDB3", "WVOD1", "HL_WCO2", "WYZEC1": + return true + } + return false +} + +func (c *Client) hdFrameSize() uint8 { + if c.isFloodlight() { + return FrameSizeFloodlight + } + if c.is2K() { + return FrameSize2K + } + return FrameSize1080P +} + +func (c *Client) is2K() bool { + switch c.model { + case "HL_CAM3P", "HL_PANP", "HL_CAM4", "HL_DB2", "HL_CFL2": + return true + } + return false +} + +func (c *Client) isFloodlight() bool { + return c.model == "HL_CFL2" +} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 7526115f..400002d9 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -29,9 +29,18 @@ func NewProducer(rawURL string) (*Producer, error) { u, _ := url.Parse(rawURL) query := u.Query() - sd := query.Get("subtype") == "sd" + // 0 = HD (default), 1 = SD/360P, 2 = 720P, 3 = 2K, 4 = Floodlight + var quality byte + switch s := query.Get("subtype"); s { + case "", "hd": + quality = 0 + case "sd": + quality = FrameSize360P + default: + quality = core.ParseByte(s) + } - medias, err := probe(client, sd) + medias, err := probe(client, quality) if err != nil { _ = client.Close() return nil, err @@ -132,8 +141,8 @@ func (p *Producer) Start() error { } } -func probe(client *Client, sd bool) ([]*core.Media, error) { - _ = client.SetResolution(sd) +func probe(client *Client, quality byte) ([]*core.Media, error) { + _ = client.SetResolution(quality) _ = client.SetDeadline(time.Now().Add(core.ProbeTimeout)) var vcodec, acodec *core.Codec From 439dccf4bd17f4dbfc44649ac108e1cc51ddf495 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 11:03:53 +0100 Subject: [PATCH 192/241] cleanup --- pkg/wyze/client.go | 59 ---------------------------------------------- 1 file changed, 59 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 5c531b5e..e1414e53 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -48,8 +48,6 @@ const ( KCmdChallenge = 10001 KCmdChallengeResp = 10002 KCmdAuthResult = 10003 - KCmdAuthWithPayload = 10008 - KCmdAuthSuccess = 10009 KCmdControlChannel = 10010 KCmdControlChannelResp = 10011 KCmdSetResolutionDB = 10052 @@ -376,24 +374,6 @@ func (c *Client) buildK10002(challenge []byte, status byte) []byte { return b } -func (c *Client) buildK10008(challenge []byte, status byte) []byte { - resp := crypto.GenerateChallengeResponse(challenge, c.enr, status) - userID := []byte(c.enr) - payloadLen := 16 + 4 + 1 + 1 + 1 + len(userID) - b := make([]byte, 16+payloadLen) - copy(b, "HL") // magic - b[2] = 5 // version - binary.LittleEndian.PutUint16(b[4:], KCmdAuthWithPayload) // 10008 - binary.LittleEndian.PutUint16(b[6:], uint16(payloadLen)) // payload len - copy(b[16:], resp[:16]) // challenge response - copy(b[32:], c.uid[:4]) // UID prefix - b[36] = 1 // video enabled - b[37] = 1 // audio enabled - b[38] = byte(len(userID)) // userID len - copy(b[39:], userID) // userID - return b -} - func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { b := make([]byte, 18) copy(b, "HL") // magic @@ -496,45 +476,6 @@ func (c *Client) parseK10003(data []byte) (*AuthResponse, error) { return &AuthResponse{}, nil } -func (c *Client) parseK10009(data []byte) (*AuthResponse, error) { - if c.verbose { - fmt.Printf("[Wyze] parseK10009: received %d bytes\n", len(data)) - } - - if len(data) < 16 { - return &AuthResponse{}, nil - } - - if data[0] != 'H' || data[1] != 'L' { - return &AuthResponse{}, nil - } - - cmdID := binary.LittleEndian.Uint16(data[4:]) - textLen := binary.LittleEndian.Uint16(data[6:]) - - if cmdID != KCmdAuthSuccess { - return &AuthResponse{}, nil - } - - if len(data) > 16 && textLen > 0 { - jsonData := data[16:] - for i := range jsonData { - if jsonData[i] == '{' { - var resp AuthResponse - if err := json.Unmarshal(jsonData[i:], &resp); err == nil { - if c.verbose { - fmt.Printf("[Wyze] parseK10009: parsed JSON\n") - } - return &resp, nil - } - break - } - } - } - - return &AuthResponse{}, nil -} - func (c *Client) useDoorbellResolution() bool { switch c.model { case "WYZEDB3", "WVOD1", "HL_WCO2", "WYZEC1": From 25e3125a8999b4cf81330af6a7eb16972ed95f0e Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 11:22:25 +0100 Subject: [PATCH 193/241] Skip unsupported cameras (gwell based) --- internal/wyze/wyze.go | 8 ++------ pkg/wyze/cloud.go | 3 +++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go index d8e53b4d..982a16ed 100644 --- a/internal/wyze/wyze.go +++ b/internal/wyze/wyze.go @@ -93,12 +93,10 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) { var items []*api.Source for _, cam := range cameras { - streamURL := buildStreamURL(cam) - items = append(items, &api.Source{ Name: cam.Nickname, Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), - URL: streamURL, + URL: buildStreamURL(cam), }) } @@ -171,12 +169,10 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { var items []*api.Source for _, cam := range cameras { - streamURL := buildStreamURL(cam) - items = append(items, &api.Source{ Name: cam.Nickname, Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), - URL: streamURL, + URL: buildStreamURL(cam), }) } diff --git a/pkg/wyze/cloud.go b/pkg/wyze/cloud.go index 7034b141..17f914a0 100644 --- a/pkg/wyze/cloud.go +++ b/pkg/wyze/cloud.go @@ -196,6 +196,9 @@ func (c *Cloud) GetCameraList() ([]*Camera, error) { if dev.ProductType != "Camera" { continue } + if dev.DeviceParams.IP == "" { + continue // skip cameras without IP (gwell protocol) + } c.cameras = append(c.cameras, &Camera{ MAC: dev.MAC, From 3a587c9ceef5ab2bc2ca2c298f4a2d9b1b345ccd Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 13:56:52 +0100 Subject: [PATCH 194/241] simplify SID generation --- pkg/wyze/tutk/conn.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index f524ba9d..4c38973c 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -403,8 +403,7 @@ func (c *Conn) Error() error { func (c *Conn) discovery() error { c.sid = make([]byte, 8) - rand.Read(c.sid[:2]) - copy(c.sid[2:], []byte{0x76, 0x0a, 0x9d, 0x24, 0x88, 0xba}) + rand.Read(c.sid) oldPkt := crypto.TransCodeBlob(c.buildDisco(1)) newPkt := c.buildNewDisco(0, 0, false) From 039e9160308765b3077f58cf20dd2127922fd588 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 19:57:38 +0100 Subject: [PATCH 195/241] cleanup --- pkg/wyze/README.md | 65 +++++++------- pkg/wyze/client.go | 2 +- pkg/wyze/tutk/conn.go | 189 ++++++++++++++++++++++++----------------- pkg/wyze/tutk/dtls.go | 4 +- pkg/wyze/tutk/frame.go | 26 ------ 5 files changed, 145 insertions(+), 141 deletions(-) diff --git a/pkg/wyze/README.md b/pkg/wyze/README.md index fbbc0bc3..654ce2d9 100644 --- a/pkg/wyze/README.md +++ b/pkg/wyze/README.md @@ -33,7 +33,7 @@ wyze: password: "yourpassword" # or MD5 triple-hash with "md5:" prefix streams: - wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&dtls=true + wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&model=HL_CAM4&dtls=true ``` ## Stream URL Format @@ -41,7 +41,7 @@ streams: The stream URL is automatically generated when you add cameras via the WebUI: ``` -wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&dtls=true +wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&model=[MODEL]&subtype=[hd|sd]&dtls=true ``` | Parameter | Description | @@ -50,18 +50,20 @@ wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&dtls=true | `uid` | P2P identifier (20 chars) | | `enr` | Encryption key for DTLS | | `mac` | Device MAC address | +| `model` | Camera model (e.g., HL_CAM4) | | `dtls` | Enable DTLS encryption (default: true) | +| `subtype` | Camera resolution: `hd` or `sd` (default: `hd`) | ## Configuration ### Resolution -You can change the camera's resolution using the `quality` parameter: +You can change the camera's resolution using the `subtype` parameter: ```yaml streams: - wyze_hd: wyze://...&quality=hd - wyze_sd: wyze://...&quality=sd + wyze_hd: wyze://...&subtype=hd + wyze_sd: wyze://...&subtype=sd ``` ### Two-Way Audio @@ -74,30 +76,29 @@ Two-way audio (intercom) is supported automatically. When a consumer sends audio |------|-------|----------|----------|------------|--------| | Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | hevc, aac | | | | 4.52.9.5332 | TUTK | HMAC-SHA1 | hevc, aac | -| Wyze Cam v3 Pro | | | | | | -| Wyze Cam v3 | | | | | | -| Wyze Cam v2 | | | | | | -| Wyze Cam v1 | | | | | | -| Wyze Cam Pan v4 | | | | | | -| Wyze Cam Pan v3 | | | | | | -| Wyze Cam Pan v2 | | | | | | -| Wyze Cam Pan v1 | | | | | | -| Wyze Cam OG | | | | | | -| Wyze Cam OG Telephoto | | | | | | -| Wyze Cam OG (2025) | | | | | | -| Wyze Cam Outdoor v2 | | | | | | -| Wyze Cam Outdoor v1 | | | | | | -| Wyze Cam Outdoor Base Station | | | | | | -| Wyze Cam Floodlight Pro | | | | | | -| Wyze Cam Floodlight v2 | | | | | | -| Wyze Cam Floodlight | | | | | | -| Wyze Video Doorbell v2 | | | | | | -| Wyze Video Doorbell v1 | | | | | | -| Wyze Video Doorbell Pro | | | | | | -| Wyze Battery Video Doorbell | | | | | | -| Wyze Duo Cam Doorbell | | | | | | -| Wyze Battery Cam Pro | | | | | | -| Wyze Solar Cam Pan | | | | | | -| Wyze Duo Cam Pan | | | | | | -| Wyze Window Cam | | | | | | -| Wyze Bulb Cam | | | | | | \ No newline at end of file +| Wyze Cam v3 Pro | | | TUTK | | | +| Wyze Cam v3 | | | TUTK | | | +| Wyze Cam v2 | | | TUTK | | | +| Wyze Cam v1 | | | TUTK | | | +| Wyze Cam Pan v4 | | | Gwell | | | +| Wyze Cam Pan v3 | | | TUTK | | | +| Wyze Cam Pan v2 | | | TUTK | | | +| Wyze Cam Pan v1 | | | TUTK | | | +| Wyze Cam OG | | | Gwell | | | +| Wyze Cam OG Telephoto | | | Gwell | | | +| Wyze Cam OG (2025) | | | Gwell | | | +| Wyze Cam Outdoor v2 | | | TUTK | | | +| Wyze Cam Outdoor v1 | | | TUTK | | | +| Wyze Cam Floodlight Pro | | | ? | | | +| Wyze Cam Floodlight v2 | | | TUTK | | | +| Wyze Cam Floodlight | | | TUTK | | | +| Wyze Video Doorbell v2 | | | TUTK | | | +| Wyze Video Doorbell v1 | | | TUTK | | | +| Wyze Video Doorbell Pro | | | ? | | | +| Wyze Battery Video Doorbell | | | ? | | | +| Wyze Duo Cam Doorbell | | | ? | | | +| Wyze Battery Cam Pro | | | ? | | | +| Wyze Solar Cam Pan | | | ? | | | +| Wyze Duo Cam Pan | | | ? | | | +| Wyze Window Cam | | | ? | | | +| Wyze Bulb Cam | | | ? | | | \ No newline at end of file diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index e1414e53..6ead2372 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -300,7 +300,7 @@ func (c *Client) doAVLogin() error { } if err := c.conn.AVClientStart(5 * time.Second); err != nil { - return fmt.Errorf("wyze: AV login failed: %w", err) + return fmt.Errorf("wyze: av login failed: %w", err) } if c.verbose { diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 4c38973c..539a5a66 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -31,6 +31,12 @@ type Conn struct { conn *net.UDPConn addr *net.UDPAddr + // DTLS + clientConn *dtls.Conn + serverConn *dtls.Conn + clientBuf chan []byte + serverBuf chan []byte + // Identity uid string authKey string @@ -45,25 +51,17 @@ type Conn struct { avResp *AVLoginResponse // Protocol - newProto bool - seq uint16 - seqCmd uint16 - avSeq uint32 - kaSeq uint32 - - // DTLS - main *dtls.Conn - speaker *dtls.Conn - mainBuf chan []byte - speakBuf chan []byte + newProto bool + seq uint16 + seqCmd uint16 + avSeq uint32 + kaSeq uint32 + audioSeq uint32 + audioFrameNo uint32 // Channels rawCmd chan []byte - // Audio TX - audioSeq uint32 - audioFrame uint32 - // Frame assembly frames *FrameHandler ackFlags uint16 @@ -91,12 +89,6 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { ctx, cancel := context.WithCancel(context.Background()) psk := derivePSK(enr) - if verbose { - hash := sha256.Sum256([]byte(enr)) - fmt.Printf("[PSK] ENR: %q → SHA256: %x\n", enr, hash) - fmt.Printf("[PSK] PSK: %x\n", psk) - } - c := &Conn{ conn: udp, addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, @@ -116,8 +108,8 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { return nil, err } - c.mainBuf = make(chan []byte, 64) - c.speakBuf = make(chan []byte, 64) + c.clientBuf = make(chan []byte, 64) + c.serverBuf = make(chan []byte, 64) c.rawCmd = make(chan []byte, 16) c.frames = NewFrameHandler(c.verbose) @@ -141,14 +133,14 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { pkt2 := c.buildAVLoginPacket(MagicAVLogin2, 572, 0x0000, randomID) pkt2[20]++ // pkt2 has randomID incremented by 1 - if _, err := c.main.Write(pkt1); err != nil { - return fmt.Errorf("AV login 1 failed: %w", err) + if _, err := c.clientConn.Write(pkt1); err != nil { + return fmt.Errorf("av login 1 failed: %w", err) } time.Sleep(50 * time.Millisecond) - if _, err := c.main.Write(pkt2); err != nil { - return fmt.Errorf("AV login 2 failed: %w", err) + if _, err := c.clientConn.Write(pkt2); err != nil { + return fmt.Errorf("av login 2 failed: %w", err) } // Wait for response @@ -167,12 +159,8 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { TwoWayStreaming: int32(data[31]), } - if c.verbose { - fmt.Printf("[TUTK] AV Login Response: two_way_streaming=%d\n", c.avResp.TwoWayStreaming) - } - ack := c.buildACK() - c.main.Write(ack) + c.clientConn.Write(ack) return nil } @@ -195,7 +183,7 @@ func (c *Conn) AVServStart() error { } c.mu.Lock() - c.speaker = conn + c.serverConn = conn c.mu.Unlock() if c.verbose { @@ -204,7 +192,7 @@ func (c *Conn) AVServStart() error { // Wait for and respond to AV Login request from camera if err := c.handleSpeakerAVLogin(); err != nil { - return fmt.Errorf("speaker AV login failed: %w", err) + return fmt.Errorf("speaker av login failed: %w", err) } return nil @@ -216,11 +204,11 @@ func (c *Conn) AVServStop() error { // Reset audio TX state c.audioSeq = 0 - c.audioFrame = 0 + c.audioFrameNo = 0 - if c.speaker != nil { - err := c.speaker.Close() - c.speaker = nil + if c.serverConn != nil { + err := c.serverConn.Close() + c.serverConn = nil return err } return nil @@ -240,7 +228,7 @@ func (c *Conn) AVRecvFrameData() (*Packet, error) { func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { c.mu.Lock() - conn := c.speaker + conn := c.serverConn if conn == nil { c.mu.Unlock() return fmt.Errorf("speaker channel not connected") @@ -253,15 +241,19 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, n, err := conn.Write(frame) if c.verbose { if err != nil { - fmt.Printf("[AUDIO TX] DTLS Write ERROR: %v\n", err) + fmt.Printf("[SPEAKER TX] DTLS Write ERROR: %v\n", err) } else { - fmt.Printf("[AUDIO TX] DTLS Write OK: %d bytes\n", n) + fmt.Printf("[SPEAKER TX] len=%d, data:\n%s", n, hexDump(frame)) } } return err } func (c *Conn) Write(data []byte) error { + if c.verbose { + fmt.Printf("[UDP TX] to=%s, len=%d, data:\n%s", c.addr.String(), len(data), hexDump(data)) + } + if c.newProto { _, err := c.conn.WriteToUDP(data, c.addr) return err @@ -277,6 +269,11 @@ func (c *Conn) WriteDTLS(payload []byte, channel byte) error { } else { frame = c.buildTxData(payload, channel) } + + if c.verbose { + fmt.Printf("[DTLS TX] to=%s, len=%d, channel=%d, data:\n%s", c.addr.String(), len(frame), channel, hexDump(frame)) + } + return c.Write(frame) } @@ -320,7 +317,7 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, frame := c.buildIOCtrlFrame(payload) var t *time.Timer t = time.AfterFunc(1, func() { - if _, err := c.main.Write(frame); err == nil && t != nil { + if _, err := c.clientConn.Write(frame); err == nil && t != nil { t.Reset(time.Second) } }) @@ -337,7 +334,7 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, } ack := c.buildACK() - c.main.Write(ack) + c.clientConn.Write(ack) if len(data) >= 6 { if binary.LittleEndian.Uint16(data[4:]) == expectCmd { @@ -357,7 +354,7 @@ func (c *Conn) GetAVLoginResponse() *AVLoginResponse { func (c *Conn) IsBackchannelReady() bool { c.mu.RLock() defer c.mu.RUnlock() - return c.speaker != nil + return c.serverConn != nil } func (c *Conn) RemoteAddr() *net.UDPAddr { @@ -376,13 +373,13 @@ func (c *Conn) Close() error { c.cancel() c.mu.Lock() - if c.main != nil { - c.main.Close() - c.main = nil + if c.clientConn != nil { + c.clientConn.Close() + c.clientConn = nil } - if c.speaker != nil { - c.speaker.Close() - c.speaker = nil + if c.serverConn != nil { + c.serverConn.Close() + c.serverConn = nil } if c.frames != nil { c.frames.Close() @@ -449,7 +446,6 @@ func (c *Conn) discovery() error { func (c *Conn) oldDiscoDone() error { c.Write(c.buildDisco(2)) time.Sleep(100 * time.Millisecond) - _, err := c.WriteAndWait(c.buildSession(), SessionTimeout, func(res []byte) bool { return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == CmdSessionRes }) @@ -482,7 +478,7 @@ func (c *Conn) connect() error { } c.mu.Lock() - c.main = conn + c.clientConn = conn c.mu.Unlock() if c.verbose { @@ -504,7 +500,7 @@ func (c *Conn) worker() { default: } - n, err := c.main.Read(buf) + n, err := c.clientConn.Read(buf) if err != nil { c.err = err return @@ -517,6 +513,10 @@ func (c *Conn) worker() { data := buf[:n] magic := binary.LittleEndian.Uint16(data) + if c.verbose { + fmt.Printf("[DTLS RX] from=%s, len=%d, data:\n%s", c.addr.String(), n, hexDump(data)) + } + switch magic { case MagicAVLoginResp: c.queue(c.rawCmd, data) @@ -578,7 +578,14 @@ func (c *Conn) reader() { return } + if c.verbose { + fmt.Printf("[UDP RX] from=%s, len=%d, data:\n%s", addr.String(), n, hexDump(buf[:n])) + } + if !addr.IP.Equal(c.addr.IP) { + if c.verbose { + fmt.Printf("Ignored packet from unknown IP: %s\n", addr.IP.String()) + } continue } if addr.Port != c.addr.Port { @@ -599,9 +606,9 @@ func (c *Conn) reader() { dtls := buf[NewHeaderSize : n-NewAuthSize] switch ch { case IOTCChannelMain: - c.queue(c.mainBuf, dtls) + c.queue(c.clientBuf, dtls) case IOTCChannelBack: - c.queue(c.speakBuf, dtls) + c.queue(c.serverBuf, dtls) } } } @@ -624,9 +631,9 @@ func (c *Conn) reader() { ch := data[14] switch ch { case IOTCChannelMain: - c.queue(c.mainBuf, data[28:]) + c.queue(c.clientBuf, data[28:]) case IOTCChannelBack: - c.queue(c.speakBuf, data[28:]) + c.queue(c.serverBuf, data[28:]) } } } @@ -653,18 +660,18 @@ func (c *Conn) handleSpeakerAVLogin() error { } buf := make([]byte, 1024) - c.speaker.SetReadDeadline(time.Now().Add(5 * time.Second)) - n, err := c.speaker.Read(buf) + c.serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, err := c.serverConn.Read(buf) if err != nil { - return fmt.Errorf("read AV login: %w", err) + return fmt.Errorf("read av login: %w", err) } if c.verbose { - fmt.Printf("[SPEAK] Received AV Login request: %d bytes\n", n) + fmt.Printf("[SPEAK] AV Login request len=%d data:\n%s", n, hexDump(buf[:n])) } if n < 24 { - return fmt.Errorf("AV login too short: %d bytes", n) + return fmt.Errorf("av login too short: %d bytes", n) } checksum := binary.LittleEndian.Uint32(buf[20:]) @@ -674,20 +681,20 @@ func (c *Conn) handleSpeakerAVLogin() error { fmt.Printf("[SPEAK] Sending AV Login response: %d bytes\n", len(resp)) } - if _, err = c.speaker.Write(resp); err != nil { + if _, err = c.serverConn.Write(resp); err != nil { return fmt.Errorf("write AV login response: %w", err) } // Camera may resend, respond again - c.speaker.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - if n, _ = c.speaker.Read(buf); n > 0 { + c.serverConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if n, _ = c.serverConn.Read(buf); n > 0 { if c.verbose { fmt.Printf("[SPEAK] Received AV Login resend: %d bytes\n", n) } - c.speaker.Write(resp) + c.serverConn.Write(resp) } - c.speaker.SetReadDeadline(time.Time{}) + c.serverConn.SetReadDeadline(time.Time{}) if c.verbose { fmt.Printf("[SPEAK] AV Login complete, ready for audio\n") @@ -767,9 +774,10 @@ func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size binary.LittleEndian.PutUint16(b[18:], flags) copy(b[20:], randomID[:4]) - copy(b[24:], DefaultUser) // username - copy(b[280:], c.enr) // password (ENR) - binary.LittleEndian.PutUint32(b[540:], 2) // security_mode=AV_SECURITY_AUTO + copy(b[24:], DefaultUser) // username + copy(b[280:], c.enr) // password/ENR + // binary.LittleEndian.PutUint32(b[536:], 1) // resend + binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? binary.LittleEndian.PutUint32(b[552:], DefaultCaps) // capabilities return b } @@ -792,10 +800,10 @@ func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { c.audioSeq++ - c.audioFrame++ + c.audioFrameNo++ prevFrame := uint32(0) - if c.audioFrame > 1 { - prevFrame = c.audioFrame - 1 + if c.audioFrameNo > 1 { + prevFrame = c.audioFrameNo - 1 } totalPayload := len(payload) + 16 // payload + frameinfo @@ -807,7 +815,7 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, binary.LittleEndian.PutUint16(b[2:], ProtoVersion) binary.LittleEndian.PutUint32(b[4:], c.audioSeq) binary.LittleEndian.PutUint32(b[8:], timestampUS) - if c.audioFrame == 1 { + if c.audioFrameNo == 1 { binary.LittleEndian.PutUint32(b[12:], 0x00000001) } else { binary.LittleEndian.PutUint32(b[12:], 0x00100001) @@ -821,13 +829,13 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload)) binary.LittleEndian.PutUint32(b[28:], prevFrame) - binary.LittleEndian.PutUint32(b[32:], c.audioFrame) + binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo) copy(b[36:], payload) // Payload + FrameInfo fi := b[36+len(payload):] binary.LittleEndian.PutUint16(fi, codec) fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) fi[4] = 1 // online - binary.LittleEndian.PutUint32(fi[12:], (c.audioFrame-1)*GetSamplesPerFrame(codec)*1000/sampleRate) + binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*GetSamplesPerFrame(codec)*1000/sampleRate) return b } @@ -916,10 +924,8 @@ func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { func derivePSK(enr string) []byte { // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) // contains a 0x00 byte, the PSK is truncated at that position. - // This matches iOS Wyze app behavior discovered via Frida instrumentation. - + // bytes after the first 0x00 are padded with zeros to make a 32-byte key. hash := sha256.Sum256([]byte(enr)) - pskLen := 32 for i := range 32 { if hash[i] == 0x00 { @@ -928,7 +934,6 @@ func derivePSK(enr string) []byte { } } - // bytes up to first 0x00, rest padded with zeros psk := make([]byte, 32) copy(psk[:pskLen], hash[:pskLen]) return psk @@ -939,3 +944,27 @@ func genRandomID() []byte { _, _ = rand.Read(b) return b } + +func hexDump(data []byte) string { + const maxBytes = 650 + totalLen := len(data) + truncated := totalLen > maxBytes + if truncated { + data = data[:maxBytes] + } + + var result string + for i := 0; i < len(data); i += 16 { + end := min(i+16, len(data)) + line := fmt.Sprintf(" %04x:", i) + for j := i; j < end; j++ { + line += fmt.Sprintf(" %02x", data[j]) + } + result += line + "\n" + } + + if truncated { + result += fmt.Sprintf(" ... (truncated, showing %d of %d bytes)\n", maxBytes, totalLen) + } + return result +} diff --git a/pkg/wyze/tutk/dtls.go b/pkg/wyze/tutk/dtls.go index e24425bd..e4e2b3ea 100644 --- a/pkg/wyze/tutk/dtls.go +++ b/pkg/wyze/tutk/dtls.go @@ -47,9 +47,9 @@ type ChannelAdapter struct { func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { var buf chan []byte if a.channel == IOTCChannelMain { - buf = a.conn.mainBuf + buf = a.conn.clientBuf } else { - buf = a.conn.speakBuf + buf = a.conn.serverBuf } select { diff --git a/pkg/wyze/tutk/frame.go b/pkg/wyze/tutk/frame.go index 3777f9fd..f3191b1f 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/wyze/tutk/frame.go @@ -258,19 +258,11 @@ func (h *FrameHandler) Handle(data []byte) { return } - if h.verbose { - h.logWireHeader(data, hdr) - } - payload, fi := h.extractPayload(data, hdr.Channel) if payload == nil { return } - if h.verbose { - h.logAVPacket(hdr.Channel, hdr.FrameType, payload, fi) - } - switch hdr.Channel { case ChannelAudio: h.handleAudio(payload, fi) @@ -485,21 +477,3 @@ func (h *FrameHandler) queue(pkt *Packet) { h.output <- pkt } } - -func (h *FrameHandler) logWireHeader(data []byte, hdr *PacketHeader) { - fmt.Printf("[WIRE] ch=0x%02x type=0x%02x len=%d pkt=%d/%d frame=%d\n", - hdr.Channel, hdr.FrameType, len(data), hdr.PktIdx, hdr.PktTotal, hdr.FrameNo) - fmt.Printf(" RAW[0..35]: ") - for i := 0; i < 36 && i < len(data); i++ { - fmt.Printf("%02x ", data[i]) - } - fmt.Printf("\n") -} - -func (h *FrameHandler) logAVPacket(channel, frameType byte, payload []byte, fi *FrameInfo) { - fmt.Printf("[AV] ch=0x%02x type=0x%02x len=%d", channel, frameType, len(payload)) - if fi != nil { - fmt.Printf(" fi={codec=0x%04x flags=0x%02x ts=%d}", fi.CodecID, fi.Flags, fi.Timestamp) - } - fmt.Printf("\n") -} From b19c081642a2ce5247c7553846a0c102ee44b740 Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 13 Jan 2026 12:13:58 +0300 Subject: [PATCH 196/241] Improve cs2+udp proto for xiaomi source --- pkg/xiaomi/cs2/conn.go | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/pkg/xiaomi/cs2/conn.go b/pkg/xiaomi/cs2/conn.go index cde09ab5..43103321 100644 --- a/pkg/xiaomi/cs2/conn.go +++ b/pkg/xiaomi/cs2/conn.go @@ -2,6 +2,7 @@ package cs2 import ( "bufio" + "bytes" "encoding/binary" "fmt" "io" @@ -46,6 +47,7 @@ type Conn struct { const ( magic = 0xF1 magicDrw = 0xD1 + magicTCP = 0x68 msgLanSearch = 0x30 msgPunchPkt = 0x41 msgP2PRdyUDP = 0x42 @@ -121,6 +123,11 @@ func (c *Conn) worker() { return } + // 0 f1d0 magic + // 2 005d size = total size + 4 + // 4 d1 magic + // 5 00 channel + // 6 0000 seq switch buf[1] { case msgDrw: ch := buf[5] @@ -134,22 +141,27 @@ func (c *Conn) worker() { } } else { // For UDP we should using ack. - seqHI := buf[6] - seqLO := buf[7] + seq := binary.BigEndian.Uint16(buf[6:]) + diff := int16(seq - chAck[ch]) - if chAck[ch] != uint16(seqHI)<<8|uint16(seqLO) { - continue + if diff > 0 { + continue // new seq - skip before ack } - chAck[ch]++ - ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO} + ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, buf[6], buf[7]} _, _ = c.conn.Write(ack) + + if diff < 0 { + continue // old seq - skip after ack + } + + chAck[ch]++ // expected seq - OK } switch ch { case 0: select { - case c.rawCh0 <- buf[12:]: + case c.rawCh0 <- bytes.Clone(buf[12:n]): default: } continue @@ -343,23 +355,24 @@ type udpConn struct { addr *net.UDPAddr } -func (c *udpConn) Read(p []byte) (n int, err error) { +func (c *udpConn) Read(b []byte) (n int, err error) { var addr *net.UDPAddr for { - n, addr, err = c.UDPConn.ReadFromUDP(p) + n, addr, err = c.UDPConn.ReadFromUDP(b) if err != nil { return 0, err } if string(addr.IP) == string(c.addr.IP) || n >= 8 { + //log.Printf("<- %x", b[:n]) return } } } -func (c *udpConn) Write(req []byte) (n int, err error) { - //log.Printf("-> %x", req) - return c.UDPConn.WriteToUDP(req, c.addr) +func (c *udpConn) Write(b []byte) (n int, err error) { + //log.Printf("-> %x", b) + return c.UDPConn.WriteToUDP(b, c.addr) } func (c *udpConn) RemoteAddr() net.Addr { @@ -425,7 +438,7 @@ func (c *tcpConn) Write(req []byte) (n int, err error) { n = len(req) buf := make([]byte, 8+n) binary.BigEndian.PutUint16(buf, uint16(n)) - buf[2] = 0x68 + buf[2] = magicTCP copy(buf[8:], req) //log.Printf("-> %x", buf) _, err = c.TCPConn.Write(buf) From fd6810794005acd2970c840b1ad528c634d46a7b Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 13 Jan 2026 12:15:02 +0300 Subject: [PATCH 197/241] Add "bad conn" for debugging UDP --- pkg/debug/conn.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 pkg/debug/conn.go diff --git a/pkg/debug/conn.go b/pkg/debug/conn.go new file mode 100644 index 00000000..6261cb75 --- /dev/null +++ b/pkg/debug/conn.go @@ -0,0 +1,47 @@ +package debug + +import ( + "bytes" + "math/rand" + "net" +) + +type badConn struct { + net.Conn + delay int + buf []byte +} + +func NewBadConn(conn net.Conn) net.Conn { + return &badConn{Conn: conn} +} + +const ( + missChance = 0.05 + delayChance = 0.1 +) + +func (c *badConn) Read(b []byte) (n int, err error) { + if rand.Float32() < missChance { + if _, err = c.Conn.Read(b); err != nil { + return + } + //log.Printf("bad conn: miss") + } + + if c.delay > 0 { + if c.delay--; c.delay == 0 { + n = copy(b, c.buf) + return + } + } else if rand.Float32() < delayChance { + if n, err = c.Conn.Read(b); err != nil { + return + } + c.delay = 1 + rand.Intn(5) + c.buf = bytes.Clone(b[:n]) + //log.Printf("bad conn: delay %d", c.delay) + } + + return c.Conn.Read(b) +} From bc0c8d5577a0dbe928b73b258764ce18cbd2be5a Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 Jan 2026 16:48:41 +0100 Subject: [PATCH 198/241] use random session id in auth process --- pkg/wyze/client.go | 45 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 6ead2372..4f81a395 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -1,6 +1,7 @@ package wyze import ( + "crypto/rand" "encoding/binary" "encoding/json" "fmt" @@ -310,7 +311,7 @@ func (c *Client) doAVLogin() error { } func (c *Client) doKAuth() error { - // Step 1: K10000 -> K10001 + // Step 1: K10000 -> K10001 (Challenge) data, err := c.conn.WriteAndWaitIOCtrl(KCmdAuth, c.buildK10000(), KCmdChallenge, 10*time.Second) if err != nil { return fmt.Errorf("wyze: K10001 failed: %w", err) @@ -321,16 +322,36 @@ func (c *Client) doKAuth() error { return fmt.Errorf("wyze: K10001 parse failed: %w", err) } - // Step 2: K10002 -> K10009 - data, err = c.conn.WriteAndWaitIOCtrl(KCmdChallengeResp, c.buildK10002(challenge, status), KCmdAuthResult, 10*time.Second) - if err != nil { - return fmt.Errorf("wyze: K10009 failed: %w", err) + if c.verbose { + fmt.Printf("[Wyze] K10001 challenge received, status=%d\n", status) } - authResp, _ := c.parseK10003(data) + // Step 2: K10002 -> K10003 (Auth) + data, err = c.conn.WriteAndWaitIOCtrl(KCmdChallengeResp, c.buildK10002(challenge, status), KCmdAuthResult, 10*time.Second) + if err != nil { + return fmt.Errorf("wyze: K10002 failed: %w", err) + } + + // Parse K10003 response + authResp, err := c.parseK10003(data) + if err != nil { + return fmt.Errorf("wyze: K10003 parse failed: %w", err) + } + + if c.verbose && authResp != nil { + if jsonBytes, err := json.MarshalIndent(authResp, "", " "); err == nil { + fmt.Printf("[Wyze] K10003 response:\n%s\n", jsonBytes) + } + } + + // Extract audio capability from cameraInfo if authResp != nil && authResp.CameraInfo != nil { - if audio, ok := authResp.CameraInfo["audio"].(bool); ok { - c.hasAudio = audio + if channelResult, ok := authResp.CameraInfo["channelRequestResult"].(map[string]any); ok { + if audio, ok := channelResult["audio"].(string); ok { + c.hasAudio = audio == "1" + } else { + c.hasAudio = true + } } else { c.hasAudio = true } @@ -338,6 +359,10 @@ func (c *Client) doKAuth() error { c.hasAudio = true } + if c.verbose { + fmt.Printf("[Wyze] K10003 auth success\n") + } + if avResp := c.conn.GetAVLoginResponse(); avResp != nil { c.hasIntercom = avResp.TwoWayStreaming == 1 } @@ -362,13 +387,15 @@ func (c *Client) buildK10000() []byte { func (c *Client) buildK10002(challenge []byte, status byte) []byte { resp := crypto.GenerateChallengeResponse(challenge, c.enr, status) + sessionID := make([]byte, 4) + rand.Read(sessionID) b := make([]byte, 38) copy(b, "HL") // magic b[2] = 5 // version binary.LittleEndian.PutUint16(b[4:], KCmdChallengeResp) // 10002 b[6] = 22 // payload len copy(b[16:], resp[:16]) // challenge response - copy(b[32:], c.uid[:4]) // UID prefix + copy(b[32:], sessionID) // random session ID b[36] = 1 // video enabled b[37] = 1 // audio enabled return b From 9ca9f96ea22cac17f4e5852344f07ef25c4a40ea Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 Jan 2026 17:24:36 +0100 Subject: [PATCH 199/241] cleanup and update readme --- README.md | 10 ++++++---- {pkg => internal}/wyze/README.md | 0 2 files changed, 6 insertions(+), 4 deletions(-) rename {pkg => internal}/wyze/README.md (100%) diff --git a/README.md b/README.md index 22f4a052..3e4a4668 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - devices: `alsa` (Linux audio), `v4l2` (Linux video) - files: `adts`, `flv`, `h264`, `hevc`, `hls`, `mjpeg`, `mpegts`, `mp4`, `wav` - network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `y2m` (yuv4mpegpipe) -- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `xiaomi` (Mi Home) +- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `wyze`, `xiaomi` (Mi Home) - webrtc related: `creality`, `kinesis` (Amazon), `openipc`, `switchbot`, `whep`, `whip`, `wyze` - other: `ascii`, `echo`, `exec`, `expr`, `ffmpeg` @@ -235,6 +235,7 @@ Available source types: - [doorbird](#source-doorbird) - Doorbird cameras with [two way audio](#two-way-audio) support - [webrtc](#source-webrtc) - WebRTC/WHEP sources - [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc +- [wyze](#source-wyze) - Wyze cameras with [two way audio](#two-way-audio) support Read more about [incoming sources](#incoming-sources) @@ -251,6 +252,7 @@ Supported sources: - [Exec](#source-exec) audio on server - [Ring](#source-ring) cameras - [Tuya](#source-tuya) cameras +- [Wyze](#source-wyze) cameras - [Xiaomi](#source-xiaomi) cameras - [Any Browser](#incoming-browser) as IP-camera @@ -627,7 +629,7 @@ This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi #### Source: Wyze -This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/pkg/wyze/README.md). +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol - no docker-wyze-bridge required. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/wyze/README.md). #### Source: GoPro @@ -763,9 +765,9 @@ This format is only supported in go2rtc. Unlike WHEP, it supports asynchronous W Support connection to [OpenIPC](https://openipc.org/) cameras. -**wyze** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) +**wyze (via docker-wyze-bridge)** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) -Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use the [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials. +Legacy method to connect to [Wyze](https://www.wyze.com/) cameras using WebRTC protocol via [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge). For native P2P support without docker-wyze-bridge, see [Source: Wyze](#source-wyze). **kinesis** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) diff --git a/pkg/wyze/README.md b/internal/wyze/README.md similarity index 100% rename from pkg/wyze/README.md rename to internal/wyze/README.md From 59161fcef2f6b144145f81ba0adb6a38b9c45a83 Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 13 Jan 2026 21:25:30 +0300 Subject: [PATCH 200/241] Improve cs2+udp proto for xiaomi source #2026 #2030 --- pkg/xiaomi/cs2/conn.go | 194 +++++++++++++++++++++++++++-------------- 1 file changed, 129 insertions(+), 65 deletions(-) diff --git a/pkg/xiaomi/cs2/conn.go b/pkg/xiaomi/cs2/conn.go index 43103321..ff5aead6 100644 --- a/pkg/xiaomi/cs2/conn.go +++ b/pkg/xiaomi/cs2/conn.go @@ -21,10 +21,11 @@ func Dial(host, transport string) (*Conn, error) { _, isTCP := conn.(*tcpConn) c := &Conn{ - conn: conn, - isTCP: isTCP, - rawCh0: make(chan []byte, 10), - rawCh2: make(chan []byte, 100), + conn: conn, + isTCP: isTCP, + channels: [4]*dataChannel{ + newDataChannel(0, 10), nil, newDataChannel(250, 100), nil, + }, } go c.worker() return c, nil @@ -37,8 +38,8 @@ type Conn struct { err error seqCh0 uint16 seqCh3 uint16 - rawCh0 chan []byte - rawCh2 chan []byte + + channels [4]*dataChannel cmdMu sync.Mutex cmdAck func() @@ -106,15 +107,13 @@ func handshake(host, transport string) (net.Conn, error) { func (c *Conn) worker() { defer func() { - close(c.rawCh0) - close(c.rawCh2) + c.channels[0].Close() + c.channels[2].Close() }() - chAck := make([]uint16, 4) // only for UDP + var keepaliveTS time.Time // only for TCP + buf := make([]byte, 1200) - var ch2WaitSize int - var ch2WaitData []byte - var keepaliveTS time.Time for { n, err := c.conn.Read(buf) @@ -131,6 +130,7 @@ func (c *Conn) worker() { switch buf[1] { case msgDrw: ch := buf[5] + channel := c.channels[ch] if c.isTCP { // For TCP we should send ping every second to keep connection alive. @@ -139,71 +139,37 @@ func (c *Conn) worker() { _, _ = c.conn.Write([]byte{magic, msgPing, 0, 0}) keepaliveTS = now.Add(time.Second) } + + err = channel.Push(buf[8:n]) } else { - // For UDP we should using ack. - seq := binary.BigEndian.Uint16(buf[6:]) - diff := int16(seq - chAck[ch]) + var pushed int - if diff > 0 { - continue // new seq - skip before ack + seqHI, seqLO := buf[6], buf[7] + seq := uint16(seqHI)<<8 | uint16(seqLO) + pushed, err = channel.PushSeq(seq, buf[8:n]) + + if pushed >= 0 { + // For UDP we should send ACK. + ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO} + _, _ = c.conn.Write(ack) } - - ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, buf[6], buf[7]} - _, _ = c.conn.Write(ack) - - if diff < 0 { - continue // old seq - skip after ack - } - - chAck[ch]++ // expected seq - OK } - switch ch { - case 0: - select { - case c.rawCh0 <- bytes.Clone(buf[12:n]): - default: - } - continue - - case 2: - ch2WaitData = append(ch2WaitData, buf[8:n]...) - - for len(ch2WaitData) > 4 { - if ch2WaitSize == 0 { - ch2WaitSize = int(binary.BigEndian.Uint32(ch2WaitData)) - ch2WaitData = ch2WaitData[4:] - } - if ch2WaitSize <= len(ch2WaitData) { - select { - case c.rawCh2 <- ch2WaitData[:ch2WaitSize]: - default: - c.err = fmt.Errorf("%s: media queue is full", "cs2") - return - } - - ch2WaitData = ch2WaitData[ch2WaitSize:] - ch2WaitSize = 0 - } else { - break - } - } - continue + if err != nil { + c.err = fmt.Errorf("%s: %w", "cs2", err) + return } case msgPing: _, _ = c.conn.Write([]byte{magic, msgPong, 0, 0}) - continue - case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose: - continue // skip it + case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose: // skip it case msgDrwAck: // only for UDP if c.cmdAck != nil { c.cmdAck() } - continue + default: + fmt.Printf("%s: unknown msg: %x\n", "cs2", buf[:n]) } - - fmt.Printf("%s: unknown msg: %x\n", "cs2", buf[:n]) } } @@ -234,7 +200,7 @@ func (c *Conn) Error() error { } func (c *Conn) ReadCommand() (cmd uint16, data []byte, err error) { - buf, ok := <-c.rawCh0 + buf, ok := c.channels[0].Pop() if !ok { return 0, nil, c.Error() } @@ -282,7 +248,7 @@ func (c *Conn) WriteCommand(cmd uint16, data []byte) error { } func (c *Conn) ReadPacket() ([]byte, error) { - data, ok := <-c.rawCh2 + data, ok := c.channels[2].Pop() if !ok { return nil, c.Error() } @@ -444,3 +410,101 @@ func (c *tcpConn) Write(req []byte) (n int, err error) { _, err = c.TCPConn.Write(buf) return } + +func newDataChannel(pushSize, popSize int) *dataChannel { + c := &dataChannel{} + if pushSize > 0 { + c.pushBuf = make(map[uint16][]byte, pushSize) + c.pushSize = pushSize + } + if popSize >= 0 { + c.popBuf = make(chan []byte, popSize) + } + return c +} + +type dataChannel struct { + waitSeq uint16 + pushBuf map[uint16][]byte + pushSize int + + waitData []byte + waitSize int + popBuf chan []byte +} + +func (c *dataChannel) Push(b []byte) error { + c.waitData = append(c.waitData, b...) + + for len(c.waitData) > 4 { + // Every new data starts with size. There can be several data inside one packet. + if c.waitSize == 0 { + c.waitSize = int(binary.BigEndian.Uint32(c.waitData)) + c.waitData = c.waitData[4:] + } + if c.waitSize > len(c.waitData) { + break + } + + select { + case c.popBuf <- c.waitData[:c.waitSize]: + default: + return fmt.Errorf("pop buffer is full") + } + + c.waitData = c.waitData[c.waitSize:] + c.waitSize = 0 + } + return nil +} + +func (c *dataChannel) Pop() ([]byte, bool) { + data, ok := <-c.popBuf + return data, ok +} + +func (c *dataChannel) Close() { + close(c.popBuf) +} + +// PushSeq returns how many seq were processed. +// Returns 0 if seq was saved or processed earlier. +// Returns -1 if seq could not be saved (buffer full or disabled). +func (c *dataChannel) PushSeq(seq uint16, data []byte) (int, error) { + diff := int16(seq - c.waitSeq) + // Check if this is seq from the future. + if diff > 0 { + // Support disabled buffer. + if c.pushSize == 0 { + return -1, nil // couldn't save seq + } + // Check if we don't have this seq in the buffer. + if c.pushBuf[seq] == nil { + // Check if there is enough space in the buffer. + if len(c.pushBuf) == c.pushSize { + return -1, nil // couldn't save seq + } + c.pushBuf[seq] = bytes.Clone(data) + //log.Printf("push buf wait=%d seq=%d len=%d", c.waitSeq, seq, len(c.pushBuf)) + } + return 0, nil + } + + // Check if this is seq from the past. + if diff < 0 { + return 0, nil + } + + for i := 1; ; i++ { + if err := c.Push(data); err != nil { + return i, err + } + c.waitSeq++ + // Check if we have next seq in the buffer. + if data = c.pushBuf[c.waitSeq]; data != nil { + delete(c.pushBuf, c.waitSeq) + } else { + return i, nil + } + } +} From 2f43bfe5dc5b19d320d1435a7d5559353d8b1ded Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 13 Jan 2026 21:26:01 +0300 Subject: [PATCH 201/241] Fix two-way audio for cs2+tcp proto for xiaomi source --- pkg/xiaomi/producer.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go index dcd419b8..27955dba 100644 --- a/pkg/xiaomi/producer.go +++ b/pkg/xiaomi/producer.go @@ -140,7 +140,8 @@ func probe(client *miss.Client, channel, quality, audio uint8) ([]*core.Media, e Codecs: []*core.Codec{acodec}, }) - if client.Protocol() == "cs2+udp" { + switch client.Protocol() { + case "cs2+udp", "cs2+tcp": medias = append(medias, &core.Media{ Kind: core.KindAudio, Direction: core.DirectionSendonly, From a99590823b301d930533128ace8868e28e6062a9 Mon Sep 17 00:00:00 2001 From: seydx Date: Wed, 14 Jan 2026 19:11:53 +0100 Subject: [PATCH 202/241] allow to specify custom port --- pkg/wyze/client.go | 8 +++++++- pkg/wyze/tutk/conn.go | 8 ++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 4f81a395..fe150d2c 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "net/url" + "strconv" "strings" "sync" "time" @@ -278,11 +279,16 @@ func (c *Client) Close() error { func (c *Client) connect() error { host := c.host + port := 0 + if idx := strings.Index(host, ":"); idx > 0 { + if p, err := strconv.Atoi(host[idx+1:]); err == nil { + port = p + } host = host[:idx] } - conn, err := tutk.Dial(host, c.uid, c.authKey, c.enr, c.mac, c.verbose) + conn, err := tutk.Dial(host, port, c.uid, c.authKey, c.enr, c.mac, c.verbose) if err != nil { return fmt.Errorf("wyze: connect failed: %w", err) } diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 539a5a66..6aaa0ad2 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -78,7 +78,7 @@ type Conn struct { cmdAck func() } -func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { +func Dial(host string, port int, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { udp, err := net.ListenUDP("udp", nil) if err != nil { return nil, err @@ -89,9 +89,13 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { ctx, cancel := context.WithCancel(context.Background()) psk := derivePSK(enr) + if port == 0 { + port = DefaultPort + } + c := &Conn{ conn: udp, - addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, rid: genRandomID(), uid: uid, authKey: authKey, From b067c408c045b026762ea5cb598048eaf3f419ab Mon Sep 17 00:00:00 2001 From: seydx Date: Wed, 14 Jan 2026 19:12:04 +0100 Subject: [PATCH 203/241] add missing codecs --- pkg/wyze/producer.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 400002d9..bb2b6e76 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -128,6 +128,27 @@ func (p *Producer) Start() error { Payload: pkt.Payload, } + case tutk.AudioCodecPCM: + name = core.CodecPCM + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.AudioCodecMP3: + name = core.CodecMP3 + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecMJPEG: + name = core.CodecJPEG + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + default: continue } @@ -202,6 +223,15 @@ func probe(client *Client, quality byte) ([]*core.Media, error) { acodec = &core.Codec{Name: core.CodecPCM, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } + case tutk.AudioCodecMP3: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecMP3, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecMJPEG: + if vcodec == nil { + vcodec = &core.Codec{Name: core.CodecJPEG, ClockRate: 90000, PayloadType: core.PayloadTypeRAW} + } } if vcodec != nil && (acodec != nil || !client.SupportsAudio()) { From 7241759feacf8a21ee714b719acaf594690dd4a6 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 01:10:41 +0100 Subject: [PATCH 204/241] disable video and audio by default in buildK10002; start them later in probe --- pkg/wyze/client.go | 4 ++-- pkg/wyze/producer.go | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index fe150d2c..6e9eb200 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -402,8 +402,8 @@ func (c *Client) buildK10002(challenge []byte, status byte) []byte { b[6] = 22 // payload len copy(b[16:], resp[:16]) // challenge response copy(b[32:], sessionID) // random session ID - b[36] = 1 // video enabled - b[37] = 1 // audio enabled + b[36] = 0 // video disabled (start with K10010 later) + b[37] = 0 // audio disabled (start with K10010 later) return b } diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index bb2b6e76..2db9d2f5 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -163,8 +163,11 @@ func (p *Producer) Start() error { } func probe(client *Client, quality byte) ([]*core.Media, error) { - _ = client.SetResolution(quality) - _ = client.SetDeadline(time.Now().Add(core.ProbeTimeout)) + client.SetResolution(quality) + client.StartVideo() + client.StartAudio() + + client.SetDeadline(time.Now().Add(core.ProbeTimeout)) var vcodec, acodec *core.Codec var tutkAudioCodec uint16 From 0d035e5bcea34f7a541f81b41386af173fbd7166 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 01:11:06 +0100 Subject: [PATCH 205/241] update ack hangling to improve streaming --- pkg/wyze/tutk/conn.go | 147 +++++++++++++++++++++++++++--------------- 1 file changed, 95 insertions(+), 52 deletions(-) diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 6aaa0ad2..22b72afd 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -28,14 +28,22 @@ const ( ) type Conn struct { - conn *net.UDPConn - addr *net.UDPAddr + conn *net.UDPConn + addr *net.UDPAddr + frames *FrameHandler + err error + verbose bool + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex // DTLS clientConn *dtls.Conn serverConn *dtls.Conn clientBuf chan []byte serverBuf chan []byte + rawCmd chan []byte // Identity uid string @@ -59,23 +67,12 @@ type Conn struct { audioSeq uint32 audioFrameNo uint32 - // Channels - rawCmd chan []byte - - // Frame assembly - frames *FrameHandler - ackFlags uint16 - - // State - err error - verbose bool - - // Sync - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - mu sync.RWMutex - cmdAck func() + // Ack + ackFlags uint16 + rxSeqStart uint16 + rxSeqEnd uint16 + rxSeqInit bool + cmdAck func() } func Dial(host string, port int, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { @@ -94,17 +91,19 @@ func Dial(host string, port int, uid, authKey, enr, mac string, verbose bool) (* } c := &Conn{ - conn: udp, - addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, - rid: genRandomID(), - uid: uid, - authKey: authKey, - enr: enr, - mac: mac, - psk: psk, - verbose: verbose, - ctx: ctx, - cancel: cancel, + conn: udp, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, + rid: genRandomID(), + uid: uid, + authKey: authKey, + enr: enr, + mac: mac, + psk: psk, + verbose: verbose, + ctx: ctx, + cancel: cancel, + rxSeqStart: 0xffff, // Initialize RX seq for ACK + rxSeqEnd: 0xffff, } if err = c.discovery(); err != nil { @@ -166,6 +165,25 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { ack := c.buildACK() c.clientConn.Write(ack) + c.wg.Add(1) + go func() { + defer c.wg.Done() + ackTicker := time.NewTicker(100 * time.Millisecond) + defer ackTicker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ackTicker.C: + if c.clientConn != nil { + ack := c.buildACK() + c.clientConn.Write(ack) + } + } + } + }() + return nil } case <-timer.C: @@ -254,9 +272,9 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, } func (c *Conn) Write(data []byte) error { - if c.verbose { - fmt.Printf("[UDP TX] to=%s, len=%d, data:\n%s", c.addr.String(), len(data), hexDump(data)) - } + // if c.verbose { + // fmt.Printf("[UDP TX] to=%s, len=%d, data:\n%s", c.addr.String(), len(data), hexDump(data)) + // } if c.newProto { _, err := c.conn.WriteToUDP(data, c.addr) @@ -274,9 +292,9 @@ func (c *Conn) WriteDTLS(payload []byte, channel byte) error { frame = c.buildTxData(payload, channel) } - if c.verbose { - fmt.Printf("[DTLS TX] to=%s, len=%d, channel=%d, data:\n%s", c.addr.String(), len(frame), channel, hexDump(frame)) - } + // if c.verbose { + // fmt.Printf("[DTLS TX] to=%s, len=%d, channel=%d, data:\n%s", c.addr.String(), len(frame), channel, hexDump(frame)) + // } return c.Write(frame) } @@ -517,9 +535,9 @@ func (c *Conn) worker() { data := buf[:n] magic := binary.LittleEndian.Uint16(data) - if c.verbose { - fmt.Printf("[DTLS RX] from=%s, len=%d, data:\n%s", c.addr.String(), n, hexDump(data)) - } + // if c.verbose { + // fmt.Printf("[DTLS RX] from=%s, len=%d, data:\n%s", c.addr.String(), n, hexDump(data)) + // } switch magic { case MagicAVLoginResp: @@ -545,6 +563,29 @@ func (c *Conn) worker() { } } + case ProtoVersion: + if len(data) >= 8 { + // Extract seq number at byte 4-5 (uint16 of uint32 AVSeq) + seq := binary.LittleEndian.Uint16(data[4:]) + if !c.rxSeqInit { + c.rxSeqInit = true + } + // Track highest received sequence + if seq > c.rxSeqEnd || c.rxSeqEnd == 0xffff { + c.rxSeqEnd = seq + } + + // Check for HL command response + if len(data) >= 36 { + for i := 32; i+2 < len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + c.queue(c.rawCmd, data[i:]) + break + } + } + } + } + case MagicACK: c.mu.RLock() ack := c.cmdAck @@ -582,9 +623,9 @@ func (c *Conn) reader() { return } - if c.verbose { - fmt.Printf("[UDP RX] from=%s, len=%d, data:\n%s", addr.String(), n, hexDump(buf[:n])) - } + // if c.verbose { + // fmt.Printf("[UDP RX] from=%s, len=%d, data:\n%s", addr.String(), n, hexDump(buf[:n])) + // } if !addr.IP.Equal(c.addr.IP) { if c.verbose { @@ -780,7 +821,7 @@ func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID copy(b[20:], randomID[:4]) copy(b[24:], DefaultUser) // username copy(b[280:], c.enr) // password/ENR - // binary.LittleEndian.PutUint32(b[536:], 1) // resend + // binary.LittleEndian.PutUint32(b[536:], 1) // resend enabled binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? binary.LittleEndian.PutUint32(b[552:], DefaultCaps) // capabilities return b @@ -880,19 +921,21 @@ func (c *Conn) buildNewTxData(payload []byte, channel byte) []byte { } func (c *Conn) buildACK() []byte { - if c.ackFlags == 0 { - c.ackFlags = 0x0001 - } else if c.ackFlags < 0x0007 { - c.ackFlags++ - } + c.ackFlags++ b := make([]byte, 24) binary.LittleEndian.PutUint16(b[0:], MagicACK) // 0x0009 binary.LittleEndian.PutUint16(b[2:], ProtoVersion) // 0x000c - binary.LittleEndian.PutUint32(b[4:], c.avSeq) // tx seq + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // TX seq c.avSeq++ - binary.LittleEndian.PutUint32(b[8:], 0xffffffff) // rx seq - binary.LittleEndian.PutUint16(b[12:], c.ackFlags) // ack flags - binary.LittleEndian.PutUint32(b[16:], uint32(c.ackFlags)<<16) // ack counter + binary.LittleEndian.PutUint16(b[8:], c.rxSeqStart) // RX start (last acked) + binary.LittleEndian.PutUint16(b[10:], c.rxSeqEnd) // RX end (highest received) + if c.rxSeqInit { + c.rxSeqStart = c.rxSeqEnd + } + binary.LittleEndian.PutUint16(b[12:], c.ackFlags) // AckFlags + binary.LittleEndian.PutUint32(b[16:], uint32(c.ackFlags)<<16) // AckCounter + ts := uint32(time.Now().UnixMilli() & 0xFFFF) + binary.LittleEndian.PutUint16(b[20:], uint16(ts)) // Timestamp return b } From dbd04cb9727372dd666d81ac5de5add2d0c33352 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 01:11:16 +0100 Subject: [PATCH 206/241] refactor frame handling --- pkg/wyze/tutk/frame.go | 299 ++++++++++++++++++++++++++++------------- 1 file changed, 203 insertions(+), 96 deletions(-) diff --git a/pkg/wyze/tutk/frame.go b/pkg/wyze/tutk/frame.go index f3191b1f..2b919880 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/wyze/tutk/frame.go @@ -2,6 +2,7 @@ package tutk import ( "encoding/binary" + "encoding/hex" "fmt" "github.com/AlexxIT/go2rtc/pkg/aac" @@ -23,29 +24,48 @@ const ( ChannelPVideo uint8 = 0x07 ) -// Resolution constants const ( - ResolutionUnknown = 0 - ResolutionSD = 1 - Resolution360P = 2 - Resolution2K = 4 + ResTierLow uint8 = 1 // 360P/SD + ResTierHigh uint8 = 4 // HD/2K +) + +const ( + Bitrate360P uint8 = 30 + BitrateHD uint8 = 100 + Bitrate2K uint8 = 200 ) const FrameInfoSize = 40 // FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet) +// Video: 40 bytes, Audio: 16 bytes (uses same struct, fields 16+ are zero) +// +// Offset Size Field +// 0-1 2 CodecID - 0x4E=H264, 0x7B=H265, 0x90=AAC_WYZE +// 2 1 Flags - Video: 1=Keyframe, 0=P-frame | Audio: sample rate/bits/channels +// 3 1 CamIndex - Camera index +// 4 1 OnlineNum - Online number +// 5 1 FPS - Framerate (e.g. 20) +// 6 1 ResTier - Video: 1=Low(360P), 4=High(HD/2K) | Audio: 0 +// 7 1 Bitrate - Video: 30=360P, 100=HD, 200=2K | Audio: 1 +// 8-11 4 Timestamp - Timestamp (increases ~50000/frame for 20fps video) +// 12-15 4 SessionID - Session marker (constant per stream) +// 16-19 4 PayloadSize - Frame payload size in bytes +// 20-23 4 FrameNo - Global frame number +// 24-35 12 DeviceID - MAC address (ASCII) - video only +// 36-39 4 Padding - Always 0 - video only type FrameInfo struct { - CodecID uint16 - Flags uint8 - CamIndex uint8 - OnlineNum uint8 - Framerate uint8 - FrameSize uint8 - Bitrate uint8 - TimestampUS uint32 - Timestamp uint32 - PayloadSize uint32 - FrameNo uint32 + CodecID uint16 // 0-1 + Flags uint8 // 2 + CamIndex uint8 // 3 + OnlineNum uint8 // 4 + FPS uint8 // 5: Framerate + ResTier uint8 // 6: Resolution tier (1=Low, 4=High) + Bitrate uint8 // 7: Bitrate index (30=360P, 100=HD, 200=2K) + Timestamp uint32 // 8-11: Timestamp + SessionID uint32 // 12-15: Session marker (constant) + PayloadSize uint32 // 16-19: Payload size + FrameNo uint32 // 20-23: Frame number } func (fi *FrameInfo) IsKeyframe() bool { @@ -53,12 +73,12 @@ func (fi *FrameInfo) IsKeyframe() bool { } func (fi *FrameInfo) Resolution() string { - switch fi.FrameSize { - case ResolutionSD: - return "SD" - case Resolution360P: + switch fi.Bitrate { + case Bitrate360P: return "360P" - case Resolution2K: + case BitrateHD: + return "HD" + case Bitrate2K: return "2K" default: return "unknown" @@ -98,11 +118,11 @@ func ParseFrameInfo(data []byte) *FrameInfo { Flags: fi[2], CamIndex: fi[3], OnlineNum: fi[4], - Framerate: fi[5], - FrameSize: fi[6], + FPS: fi[5], + ResTier: fi[6], Bitrate: fi[7], - TimestampUS: binary.LittleEndian.Uint32(fi[8:]), - Timestamp: binary.LittleEndian.Uint32(fi[12:]), + Timestamp: binary.LittleEndian.Uint32(fi[8:]), + SessionID: binary.LittleEndian.Uint32(fi[12:]), PayloadSize: binary.LittleEndian.Uint32(fi[16:]), FrameNo: binary.LittleEndian.Uint32(fi[20:]), } @@ -166,7 +186,7 @@ func ParsePacketHeader(data []byte) *PacketHeader { hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:]) hdr.FrameNo = binary.LittleEndian.Uint32(data[24:]) - if IsEndFrame(frameType) && pktIdxOrMarker == 0x0028 { + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { hdr.HasFrameInfo = true if hdr.PktTotal > 0 { hdr.PktIdx = hdr.PktTotal - 1 @@ -180,7 +200,7 @@ func ParsePacketHeader(data []byte) *PacketHeader { hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:]) hdr.FrameNo = binary.LittleEndian.Uint32(data[32:]) - if IsEndFrame(frameType) && pktIdxOrMarker == 0x0028 { + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { hdr.HasFrameInfo = true if hdr.PktTotal > 0 { hdr.PktIdx = hdr.PktTotal - 1 @@ -207,11 +227,24 @@ func IsContinuationFrame(frameType uint8) bool { return frameType == FrameTypeCont || frameType == FrameTypeContAlt } -type FrameAssembler struct { - FrameNo uint32 - PktTotal uint16 - Packets map[uint16][]byte - FrameInfo *FrameInfo +type channelState struct { + frameNo uint32 // current frame being assembled + pktTotal uint16 // expected total packets + waitSeq uint16 // next expected packet index (0, 1, 2, ...) + waitData []byte // accumulated payload data + frameInfo *FrameInfo // frame info (from end packet) + hasStarted bool // received first packet of frame + lastPktIdx uint16 // last received packet index (for OOO detection) +} + +func (cs *channelState) reset() { + cs.frameNo = 0 + cs.pktTotal = 0 + cs.waitSeq = 0 + cs.waitData = cs.waitData[:0] + cs.frameInfo = nil + cs.hasStarted = false + cs.lastPktIdx = 0 } func ParseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { @@ -229,18 +262,22 @@ func ParseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channel return 16000, 1 } +const tsWrapPeriod uint32 = 1000000 + type FrameHandler struct { - assemblers map[byte]*FrameAssembler - baseTS uint64 - output chan *Packet - verbose bool + channels map[byte]*channelState + lastRawTS uint32 + accumUS uint64 + firstTS bool + output chan *Packet + verbose bool } func NewFrameHandler(verbose bool) *FrameHandler { return &FrameHandler{ - assemblers: make(map[byte]*FrameAssembler), - output: make(chan *Packet, 128), - verbose: verbose, + channels: make(map[byte]*channelState), + output: make(chan *Packet, 128), + verbose: verbose, } } @@ -252,6 +289,27 @@ func (h *FrameHandler) Close() { close(h.output) } +func (h *FrameHandler) updateTimestamp(rawTS uint32) uint64 { + if !h.firstTS { + h.firstTS = true + h.lastRawTS = rawTS + return 0 + } + + var delta uint32 + if rawTS >= h.lastRawTS { + delta = rawTS - h.lastRawTS + } else { + // Wrapped: delta = (wrap - last) + new + delta = (tsWrapPeriod - h.lastRawTS) + rawTS + } + + h.accumUS += uint64(delta) + h.lastRawTS = rawTS + + return h.accumUS +} + func (h *FrameHandler) Handle(data []byte) { hdr := ParsePacketHeader(data) if hdr == nil { @@ -263,6 +321,16 @@ func (h *FrameHandler) Handle(data []byte) { return } + if h.verbose { + fiStr := "" + if hdr.HasFrameInfo { + fiStr = " +FI" + } + fmt.Printf("[RX] ch=0x%02x type=0x%02x #%d pkt=%d/%d data=%dB%s\n", + hdr.Channel, hdr.FrameType, + hdr.FrameNo, hdr.PktIdx, hdr.PktTotal, len(payload), fiStr) + } + switch hdr.Channel { case ChannelAudio: h.handleAudio(payload, fi) @@ -335,71 +403,73 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame } func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) { - asm := h.assemblers[channel] - - // Frame transition: new frame number = previous frame complete - if asm != nil && hdr.FrameNo != asm.FrameNo { - gotAll := uint16(len(asm.Packets)) == asm.PktTotal - if gotAll && asm.FrameInfo != nil { - h.assembleAndQueue(channel, asm) - } - asm = nil + cs := h.channels[channel] + if cs == nil { + cs = &channelState{} + h.channels[channel] = cs } - // Create new assembler if needed - if asm == nil { - asm = &FrameAssembler{ - FrameNo: hdr.FrameNo, - PktTotal: hdr.PktTotal, - Packets: make(map[uint16][]byte, hdr.PktTotal), + // New frame number - reset and start fresh + if hdr.FrameNo != cs.frameNo { + // Check if previous frame was incomplete + if cs.hasStarted && cs.waitSeq < cs.pktTotal { + fmt.Printf("[DROP] ch=0x%02x #%d INCOMPLETE: got %d/%d pkts\n", + channel, cs.frameNo, cs.waitSeq, cs.pktTotal) } - h.assemblers[channel] = asm + cs.reset() + cs.frameNo = hdr.FrameNo + cs.pktTotal = hdr.PktTotal } - // Store packet (copy payload - buffer is reused by worker) - payloadCopy := make([]byte, len(payload)) - copy(payloadCopy, payload) - asm.Packets[hdr.PktIdx] = payloadCopy + // Sequential check: if packet index doesn't match expected, reset (data loss) + if hdr.PktIdx != cs.waitSeq { + fmt.Printf("[OOO] ch=0x%02x #%d frameType=0x%02x pktTotal=%d expected pkt %d, got %d - reset\n", + channel, hdr.FrameNo, hdr.FrameType, hdr.PktTotal, cs.waitSeq, hdr.PktIdx) + cs.reset() + return + } + // First packet - mark as started + if cs.waitSeq == 0 { + cs.hasStarted = true + } + + // Append payload (simple sequential accumulation) + cs.waitData = append(cs.waitData, payload...) + cs.waitSeq++ + + // Store frame info if present if fi != nil { - asm.FrameInfo = fi + cs.frameInfo = fi } // Check if frame is complete - if uint16(len(asm.Packets)) == asm.PktTotal && asm.FrameInfo != nil { - h.assembleAndQueue(channel, asm) - delete(h.assemblers, channel) + if cs.waitSeq == cs.pktTotal && cs.frameInfo != nil { + h.emitVideo(channel, cs) + cs.reset() } } -func (h *FrameHandler) assembleAndQueue(channel byte, asm *FrameAssembler) { - fi := asm.FrameInfo - - // Assemble packets in correct order - var payload []byte - for i := uint16(0); i < asm.PktTotal; i++ { - if pkt, ok := asm.Packets[i]; ok { - payload = append(payload, pkt...) - } - } +func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { + fi := cs.frameInfo // Size validation - if fi.PayloadSize > 0 && len(payload) != int(fi.PayloadSize) { + if fi.PayloadSize > 0 && uint32(len(cs.waitData)) != fi.PayloadSize { + fmt.Printf("[SIZE] ch=0x%02x #%d mismatch: expected %d, got %d\n", + channel, cs.frameNo, fi.PayloadSize, len(cs.waitData)) return } - if len(payload) == 0 { + if len(cs.waitData) == 0 { return } - // Calculate RTP timestamp (90kHz for video) using relative timestamps - absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) - if h.baseTS == 0 { - h.baseTS = absoluteTS - } - relativeUS := absoluteTS - h.baseTS - const clockRate uint64 = 90000 - rtpTS := uint32(relativeUS * clockRate / 1000000) + accumUS := h.updateTimestamp(fi.Timestamp) + rtpTS := uint32(accumUS * 90000 / 1000000) + + // Copy payload (buffer will be reused) + payload := make([]byte, len(cs.waitData)) + copy(payload, cs.waitData) pkt := &Packet{ Channel: channel, @@ -413,10 +483,18 @@ func (h *FrameHandler) assembleAndQueue(channel byte, asm *FrameAssembler) { if h.verbose { frameType := "P" if fi.IsKeyframe() { - frameType = "I" + frameType = "KEY" } - fmt.Printf("[VIDEO] #%d %s %s size=%d rtp=%d\n", - fi.FrameNo, CodecName(fi.CodecID), frameType, len(payload), rtpTS) + fmt.Printf("[OK] ch=0x%02x #%d %s %s size=%d\n", + channel, fi.FrameNo, CodecName(fi.CodecID), frameType, len(payload)) + fmt.Printf(" [0-1]codec=0x%x(%s) [2]flags=0x%x [3]=%d [4]=%d\n", + fi.CodecID, CodecName(fi.CodecID), fi.Flags, fi.CamIndex, fi.OnlineNum) + fmt.Printf(" [5]=%d [6]=%d [7]=%d [8-11]ts=%d\n", + fi.FPS, fi.ResTier, fi.Bitrate, fi.Timestamp) + fmt.Printf(" [12-15]=0x%x [16-19]payload=%d [20-23]frameNo=%d\n", + fi.SessionID, fi.PayloadSize, fi.FrameNo) + fmt.Printf(" rtp_ts=%d accum_us=%d\n", rtpTS, accumUS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) } h.queue(pkt) @@ -438,14 +516,8 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { channels = fi.Channels() } - // Calculate RTP timestamp using relative timestamps (shared baseTS for A/V sync) - absoluteTS := uint64(fi.Timestamp)*1000000 + uint64(fi.TimestampUS) - if h.baseTS == 0 { - h.baseTS = absoluteTS - } - relativeUS := absoluteTS - h.baseTS - clockRate := uint64(sampleRate) - rtpTS := uint32(relativeUS * clockRate / 1000000) + accumUS := h.updateTimestamp(fi.Timestamp) + rtpTS := uint32(accumUS * uint64(sampleRate) / 1000000) pkt := &Packet{ Channel: ChannelAudio, @@ -458,8 +530,17 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { } if h.verbose { - fmt.Printf("[AUDIO] #%d %s size=%d rate=%d ch=%d rtp=%d\n", - fi.FrameNo, AudioCodecName(fi.CodecID), len(payload), sampleRate, channels, rtpTS) + bits := 8 + if fi.Flags&0x02 != 0 { + bits = 16 + } + fmt.Printf("[OK] Audio #%d %s size=%d\n", + fi.FrameNo, AudioCodecName(fi.CodecID), len(payload)) + fmt.Printf(" [0-1]codec=0x%x(%s) [2]flags=0x%x(%dHz/%dbit/%dch)\n", + fi.CodecID, AudioCodecName(fi.CodecID), fi.Flags, sampleRate, bits, channels) + fmt.Printf(" [8-11]ts=%d [12-15]=0x%x rtp_ts=%d\n", + fi.Timestamp, fi.SessionID, rtpTS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) } h.queue(pkt) @@ -477,3 +558,29 @@ func (h *FrameHandler) queue(pkt *Packet) { h.output <- pkt } } + +func dumpHex(fi *FrameInfo) string { + b := make([]byte, FrameInfoSize) + binary.LittleEndian.PutUint16(b[0:], fi.CodecID) + b[2] = fi.Flags + b[3] = fi.CamIndex + b[4] = fi.OnlineNum + b[5] = fi.FPS + b[6] = fi.ResTier + b[7] = fi.Bitrate + binary.LittleEndian.PutUint32(b[8:], fi.Timestamp) + binary.LittleEndian.PutUint32(b[12:], fi.SessionID) + binary.LittleEndian.PutUint32(b[16:], fi.PayloadSize) + binary.LittleEndian.PutUint32(b[20:], fi.FrameNo) + // Bytes 24-39 are DeviceID and Padding (not stored in struct) + + hexStr := hex.EncodeToString(b) + formatted := "" + for i := 0; i < len(hexStr); i += 2 { + if i > 0 { + formatted += " " + } + formatted += hexStr[i : i+2] + } + return formatted +} From f96a074957d840218926abeb153eca55044b0403 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 01:25:35 +0100 Subject: [PATCH 207/241] refactor timestamp handling --- pkg/wyze/tutk/frame.go | 91 ++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/pkg/wyze/tutk/frame.go b/pkg/wyze/tutk/frame.go index 2b919880..a647869b 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/wyze/tutk/frame.go @@ -247,30 +247,41 @@ func (cs *channelState) reset() { cs.lastPktIdx = 0 } -func ParseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { - if aac.IsADTS(payload) { - codec := aac.ADTSToCodec(payload) - if codec != nil { - return codec.ClockRate, codec.Channels - } - } - - if fi != nil { - return fi.SampleRate(), fi.Channels() - } - - return 16000, 1 -} - const tsWrapPeriod uint32 = 1000000 -type FrameHandler struct { - channels map[byte]*channelState +type tsTracker struct { lastRawTS uint32 accumUS uint64 firstTS bool - output chan *Packet - verbose bool +} + +func (t *tsTracker) update(rawTS uint32) uint64 { + if !t.firstTS { + t.firstTS = true + t.lastRawTS = rawTS + return 0 + } + + var delta uint32 + if rawTS >= t.lastRawTS { + delta = rawTS - t.lastRawTS + } else { + // Wrapped: delta = (wrap - last) + new + delta = (tsWrapPeriod - t.lastRawTS) + rawTS + } + + t.accumUS += uint64(delta) + t.lastRawTS = rawTS + + return t.accumUS +} + +type FrameHandler struct { + channels map[byte]*channelState + videoTS tsTracker + audioTS tsTracker + output chan *Packet + verbose bool } func NewFrameHandler(verbose bool) *FrameHandler { @@ -289,27 +300,6 @@ func (h *FrameHandler) Close() { close(h.output) } -func (h *FrameHandler) updateTimestamp(rawTS uint32) uint64 { - if !h.firstTS { - h.firstTS = true - h.lastRawTS = rawTS - return 0 - } - - var delta uint32 - if rawTS >= h.lastRawTS { - delta = rawTS - h.lastRawTS - } else { - // Wrapped: delta = (wrap - last) + new - delta = (tsWrapPeriod - h.lastRawTS) + rawTS - } - - h.accumUS += uint64(delta) - h.lastRawTS = rawTS - - return h.accumUS -} - func (h *FrameHandler) Handle(data []byte) { hdr := ParsePacketHeader(data) if hdr == nil { @@ -464,7 +454,7 @@ func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { return } - accumUS := h.updateTimestamp(fi.Timestamp) + accumUS := h.videoTS.update(fi.Timestamp) rtpTS := uint32(accumUS * 90000 / 1000000) // Copy payload (buffer will be reused) @@ -510,13 +500,13 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { switch fi.CodecID { case AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACLATM, AudioCodecAACWyze: - sampleRate, channels = ParseAudioParams(payload, fi) + sampleRate, channels = parseAudioParams(payload, fi) default: sampleRate = fi.SampleRate() channels = fi.Channels() } - accumUS := h.updateTimestamp(fi.Timestamp) + accumUS := h.audioTS.update(fi.Timestamp) rtpTS := uint32(accumUS * uint64(sampleRate) / 1000000) pkt := &Packet{ @@ -559,6 +549,21 @@ func (h *FrameHandler) queue(pkt *Packet) { } } +func parseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { + if aac.IsADTS(payload) { + codec := aac.ADTSToCodec(payload) + if codec != nil { + return codec.ClockRate, codec.Channels + } + } + + if fi != nil { + return fi.SampleRate(), fi.Channels() + } + + return 16000, 1 +} + func dumpHex(fi *FrameInfo) string { b := make([]byte, FrameInfoSize) binary.LittleEndian.PutUint16(b[0:], fi.CodecID) From 4dbf53122ec3262376ee8c5a3743379887798ed6 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 12:12:08 +0100 Subject: [PATCH 208/241] enable video and audio in buildK10002 again --- pkg/wyze/client.go | 4 ++-- pkg/wyze/producer.go | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 6e9eb200..ab8f7d4e 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -402,8 +402,8 @@ func (c *Client) buildK10002(challenge []byte, status byte) []byte { b[6] = 22 // payload len copy(b[16:], resp[:16]) // challenge response copy(b[32:], sessionID) // random session ID - b[36] = 0 // video disabled (start with K10010 later) - b[37] = 0 // audio disabled (start with K10010 later) + b[36] = 1 // video enabled/disabled + b[37] = 1 // audio enabled/disabled return b } diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 2db9d2f5..4e80b387 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -164,9 +164,6 @@ func (p *Producer) Start() error { func probe(client *Client, quality byte) ([]*core.Media, error) { client.SetResolution(quality) - client.StartVideo() - client.StartAudio() - client.SetDeadline(time.Now().Add(core.ProbeTimeout)) var vcodec, acodec *core.Codec From 0066da94f748b036978a6c02ad6be9d71dd2842a Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 12:13:03 +0100 Subject: [PATCH 209/241] update wyze readme --- internal/wyze/README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/wyze/README.md b/internal/wyze/README.md index 654ce2d9..6e82fffd 100644 --- a/internal/wyze/README.md +++ b/internal/wyze/README.md @@ -74,19 +74,19 @@ Two-way audio (intercom) is supported automatically. When a consumer sends audio | Name | Model | Firmware | Protocol | Encryption | Codecs | |------|-------|----------|----------|------------|--------| -| Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | hevc, aac | -| | | 4.52.9.5332 | TUTK | HMAC-SHA1 | hevc, aac | +| Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | h264, aac | +| | | 4.52.9.5332 | TUTK | HMAC-SHA1 | h264, aac | | Wyze Cam v3 Pro | | | TUTK | | | -| Wyze Cam v3 | | | TUTK | | | -| Wyze Cam v2 | | | TUTK | | | +| Wyze Cam v3 | WYZE_CAKP2JFUS | 4.36.14.3497 | TUTK | TransCode | h264, pcm | +| Wyze Cam v2 | WYZEC1-JZ | 4.9.9.3006 | TUTK | TransCode | h264, pcmu | | Wyze Cam v1 | | | TUTK | | | -| Wyze Cam Pan v4 | | | Gwell | | | +| Wyze Cam Pan v4 | | | Gwell* | | | | Wyze Cam Pan v3 | | | TUTK | | | | Wyze Cam Pan v2 | | | TUTK | | | | Wyze Cam Pan v1 | | | TUTK | | | -| Wyze Cam OG | | | Gwell | | | -| Wyze Cam OG Telephoto | | | Gwell | | | -| Wyze Cam OG (2025) | | | Gwell | | | +| Wyze Cam OG | | | Gwell* | | | +| Wyze Cam OG Telephoto | | | Gwell* | | | +| Wyze Cam OG (2025) | | | Gwell* | | | | Wyze Cam Outdoor v2 | | | TUTK | | | | Wyze Cam Outdoor v1 | | | TUTK | | | | Wyze Cam Floodlight Pro | | | ? | | | @@ -101,4 +101,6 @@ Two-way audio (intercom) is supported automatically. When a consumer sends audio | Wyze Solar Cam Pan | | | ? | | | | Wyze Duo Cam Pan | | | ? | | | | Wyze Window Cam | | | ? | | | -| Wyze Bulb Cam | | | ? | | | \ No newline at end of file +| Wyze Bulb Cam | | | ? | | | + +_* Gwell based protocols are not yet supported._ \ No newline at end of file From 3983ce3f4f20251470b49ce8b97d4a9bbe992509 Mon Sep 17 00:00:00 2001 From: seydx <34152761+seydx@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:50:01 +0100 Subject: [PATCH 210/241] Update Wyze Video Doorbell v2 details in README Added model and version information for Wyze Video Doorbell v2. --- internal/wyze/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/wyze/README.md b/internal/wyze/README.md index 6e82fffd..ca7cf6c4 100644 --- a/internal/wyze/README.md +++ b/internal/wyze/README.md @@ -92,7 +92,7 @@ Two-way audio (intercom) is supported automatically. When a consumer sends audio | Wyze Cam Floodlight Pro | | | ? | | | | Wyze Cam Floodlight v2 | | | TUTK | | | | Wyze Cam Floodlight | | | TUTK | | | -| Wyze Video Doorbell v2 | | | TUTK | | | +| Wyze Video Doorbell v2 | HL_DB2 | 4.51.3.4992 | TUTK | TransCode | h264, pcm | | Wyze Video Doorbell v1 | | | TUTK | | | | Wyze Video Doorbell Pro | | | ? | | | | Wyze Battery Video Doorbell | | | ? | | | @@ -103,4 +103,4 @@ Two-way audio (intercom) is supported automatically. When a consumer sends audio | Wyze Window Cam | | | ? | | | | Wyze Bulb Cam | | | ? | | | -_* Gwell based protocols are not yet supported._ \ No newline at end of file +_* Gwell based protocols are not yet supported._ From 50d9aab0d7c061d15acda8a5f2796e345500184e Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 22:50:46 +0100 Subject: [PATCH 211/241] change pcm to pcml --- pkg/wyze/producer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 4e80b387..4eb70ab3 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -129,7 +129,7 @@ func (p *Producer) Start() error { } case tutk.AudioCodecPCM: - name = core.CodecPCM + name = core.CodecPCML pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, @@ -220,7 +220,7 @@ func probe(client *Client, quality byte) ([]*core.Media, error) { } case tutk.AudioCodecPCM: if acodec == nil { - acodec = &core.Codec{Name: core.CodecPCM, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + acodec = &core.Codec{Name: core.CodecPCML, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } case tutk.AudioCodecMP3: From 7498d0fba51eabdfd2fc92d506b454a6d2f0f363 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 15 Jan 2026 22:51:58 +0100 Subject: [PATCH 212/241] copy audio payload before processing in handleAudio function --- pkg/wyze/tutk/frame.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/wyze/tutk/frame.go b/pkg/wyze/tutk/frame.go index a647869b..cebdc825 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/wyze/tutk/frame.go @@ -509,9 +509,12 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { accumUS := h.audioTS.update(fi.Timestamp) rtpTS := uint32(accumUS * uint64(sampleRate) / 1000000) + payloadCopy := make([]byte, len(payload)) + copy(payloadCopy, payload) + pkt := &Packet{ Channel: ChannelAudio, - Payload: payload, + Payload: payloadCopy, Codec: fi.CodecID, Timestamp: rtpTS, SampleRate: sampleRate, From 6b4eb8ffb6e32d29bc81843b83e2f2ba036d6658 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 4 Jan 2026 00:09:32 +0300 Subject: [PATCH 213/241] Big rewrite tutk proto support --- examples/tutk_decoder/README.md | 5 + examples/tutk_decoder/main.go | 82 ++++++ internal/xiaomi/README.md | 26 +- internal/xiaomi/xiaomi.go | 107 ++++++-- pkg/h265/h265_test.go | 11 + pkg/tutk/conn.go | 264 +++++++++++++++++++ pkg/{xiaomi => }/tutk/crypto.go | 36 +++ pkg/tutk/crypto_test.go | 14 + pkg/tutk/helpers.go | 28 ++ pkg/tutk/session0.go | 163 ++++++++++++ pkg/tutk/session16.go | 378 +++++++++++++++++++++++++++ pkg/tutk/session25.go | 341 ++++++++++++++++++++++++ pkg/xiaomi/crypto/crypto.go | 68 +++++ pkg/xiaomi/legacy/client.go | 227 ++++++++++++++++ pkg/xiaomi/legacy/producer.go | 217 +++++++++++++++ pkg/xiaomi/{ => miss}/backchannel.go | 20 +- pkg/xiaomi/miss/client.go | 358 +++++++++++++------------ pkg/xiaomi/{ => miss}/cs2/conn.go | 27 +- pkg/xiaomi/miss/producer.go | 204 +++++++++++++++ pkg/xiaomi/producer.go | 224 +--------------- pkg/xiaomi/tutk/README.md | 63 ----- pkg/xiaomi/tutk/conn.go | 262 ------------------- pkg/xiaomi/tutk/proto.go | 251 ------------------ pkg/xiaomi/tutk/proto_new.go | 191 -------------- pkg/xiaomi/tutk/proto_old.go | 192 -------------- 25 files changed, 2376 insertions(+), 1383 deletions(-) create mode 100644 examples/tutk_decoder/README.md create mode 100644 examples/tutk_decoder/main.go create mode 100644 pkg/tutk/conn.go rename pkg/{xiaomi => }/tutk/crypto.go (78%) create mode 100644 pkg/tutk/crypto_test.go create mode 100644 pkg/tutk/helpers.go create mode 100644 pkg/tutk/session0.go create mode 100644 pkg/tutk/session16.go create mode 100644 pkg/tutk/session25.go create mode 100644 pkg/xiaomi/crypto/crypto.go create mode 100644 pkg/xiaomi/legacy/client.go create mode 100644 pkg/xiaomi/legacy/producer.go rename pkg/xiaomi/{ => miss}/backchannel.go (76%) rename pkg/xiaomi/{ => miss}/cs2/conn.go (93%) create mode 100644 pkg/xiaomi/miss/producer.go delete mode 100644 pkg/xiaomi/tutk/README.md delete mode 100644 pkg/xiaomi/tutk/conn.go delete mode 100644 pkg/xiaomi/tutk/proto.go delete mode 100644 pkg/xiaomi/tutk/proto_new.go delete mode 100644 pkg/xiaomi/tutk/proto_old.go diff --git a/examples/tutk_decoder/README.md b/examples/tutk_decoder/README.md new file mode 100644 index 00000000..197bd820 --- /dev/null +++ b/examples/tutk_decoder/README.md @@ -0,0 +1,5 @@ +# tutk_decoder + +1. Wireshark > Select any packet > Follow > UDP Stream +2. Wireshark > File > Export Packet Dissections > As JSON > Displayed, Values +3. `tutk_decoder wireshark.json decoded.txt` diff --git a/examples/tutk_decoder/main.go b/examples/tutk_decoder/main.go new file mode 100644 index 00000000..0b6d90a9 --- /dev/null +++ b/examples/tutk_decoder/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/tutk" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("Usage: tutk_decoder wireshark.json decoded.txt") + return + } + + src, err := os.Open(os.Args[1]) + if err != nil { + log.Fatal(err) + } + defer src.Close() + + dst, err := os.Create(os.Args[2]) + if err != nil { + log.Fatal(err) + } + defer dst.Close() + + var items []item + if err = json.NewDecoder(src).Decode(&items); err != nil { + log.Fatal(err) + } + + var b []byte + + for _, v := range items { + if v.Source.Layers.Data.DataData == "" { + continue + } + + s := strings.ReplaceAll(v.Source.Layers.Data.DataData, ":", "") + b, err = hex.DecodeString(s) + if err != nil { + log.Fatal(err) + } + + tutk.ReverseTransCodePartial(b, b) + + ts := v.Source.Layers.Frame.FrameTimeRelative + + _, _ = fmt.Fprintf(dst, "%8s: %s -> %s [%4d] %x\n", + ts[:len(ts)-6], + v.Source.Layers.Ip.IpSrc, v.Source.Layers.Ip.IpDst, + len(b), b) + } +} + +type item struct { + Source struct { + Layers struct { + Frame struct { + FrameTimeRelative string `json:"frame.time_relative"` + FrameNumber string `json:"frame.number"` + } `json:"frame"` + Ip struct { + IpSrc string `json:"ip.src"` + IpDst string `json:"ip.dst"` + } `json:"ip"` + Udp struct { + UdpSrcport string `json:"udp.srcport"` + UdpDstport string `json:"udp.dstport"` + } `json:"udp"` + Data struct { + DataData string `json:"data.data"` + DataLen string `json:"data.len"` + } `json:"data"` + } `json:"layers"` + } `json:"_source"` +} diff --git a/internal/xiaomi/README.md b/internal/xiaomi/README.md index 80d98beb..f46dcdd1 100644 --- a/internal/xiaomi/README.md +++ b/internal/xiaomi/README.md @@ -1,11 +1,21 @@ # Xiaomi +**Added in v1.9.13. Improved in v1.9.14.** + This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. +Since 2020, Xiaomi has introduced a unified protocol for cameras called `miss`. I think it means **Mi Secure Streaming**. Until this point, the camera protocols were in chaos. Almost every model had different authorization, encryption, command lists, and media packet formats. + +Go2rtc support two formats: `xiaomi/mess` and `xiaomi/legacy`. +And multiple P2P protocols: `cs2+udp`, `cs2+tcp`, several versions of `tutk+udp`. + +Almost all cameras in the `xiaomi/mess` format and the `cs2` protocol work well. +Older `xiaomi/legacy` format cameras may have support issues. +The `tutk` protocol is the worst thing that's ever happened to the P2P world. It works terribly. + **Important:** -1. **Not all cameras are supported**. There are several P2P protocol vendors in the Xiaomi ecosystem. -Currently, the **CS2** vendor is supported. However, the **TUTK** vendor is not supported. +1. **Not all cameras are supported**. The list of supported cameras is collected in [this issue](https://github.com/AlexxIT/go2rtc/issues/1982). 2. Each time you connect to the camera, you need internet access to obtain encryption keys. 3. Connection to the camera is local only. @@ -21,7 +31,7 @@ Currently, the **CS2** vendor is supported. However, the **TUTK** vendor is not 1. Goto go2rtc WebUI > Add > Xiaomi > Login with username and password 2. Receive verification code by email or phone if required. 3. Complete the captcha if required. -4. If everything is OK, your account will be added and you can load cameras from it. +4. If everything is OK, your account will be added, and you can load cameras from it. **Example** @@ -35,16 +45,20 @@ streams: ## Configuration -You can change camera's quality: `subtype=hd/sd/auto` +Quality in the `miss` protocol is specified by a number from 0 to 5. Usually 0 means auto, 1 - sd, 2 - hd. +Go2rtc by default sets quality to 2. But some new cameras have HD quality at number 3. +Old cameras may have broken codec settings at number 3, so this number should not be set for all cameras. + +You can change camera's quality: `subtype=hd/sd/auto/0-5`. ```yaml streams: xiaomi1: xiaomi://***&subtype=sd ``` -You can use second channel for Dual cameras: `channel=1` +You can use second channel for Dual cameras: `channel=2`. ```yaml streams: - xiaomi1: xiaomi://***&channel=1 + xiaomi1: xiaomi://***&channel=2 ``` diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index f5f9b5bd..a27cb9f6 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -15,7 +15,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/xiaomi" - "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" ) func Init() { @@ -65,28 +65,96 @@ func getCloud(userID string) (*xiaomi.Cloud, error) { return cloud, nil } +func cloudRequest(userID, region, apiURL, params string) ([]byte, error) { + cloud, err := getCloud(userID) + if err != nil { + return nil, err + } + return cloud.Request(GetBaseURL(region), apiURL, params, nil) +} + +func cloudUserRequest(user *url.Userinfo, apiURL, params string) ([]byte, error) { + userID := user.Username() + region, _ := user.Password() + return cloudRequest(userID, region, apiURL, params) +} + func getCameraURL(url *url.URL) (string, error) { - clientPublic, clientPrivate, err := miss.GenerateKey() + model := url.Query().Get("model") + + // It is not known which models need to be awakened. + // Probably all the doorbells and all the battery cameras. + if strings.Contains(model, ".cateye.") { + _ = wakeUpCamera(url) + } + + // The getMissURL request has a fallback to getP2PURL. + // But for known models we can save one request to the cloud. + if xiaomi.IsLegacy(model) { + return getP2PURL(url) + } + return getMissURL(url) +} + +func getP2PURL(url *url.URL) (string, error) { + query := url.Query() + + clientPublic, clientPrivate, err := crypto.GenerateKey() + if err != nil { + return "", err + } + + params := fmt.Sprintf(`{"did":"%s","toSignAppData":"%x"}`, query.Get("did"), clientPublic) + + userID := url.User.Username() + region, _ := url.User.Password() + res, err := cloudRequest(userID, region, "/device/devicepass", params) + if err != nil { + return "", err + } + + var v struct { + UID string `json:"p2p_id"` + Password string `json:"password"` + PublicKey string `json:"p2p_dev_public_key"` + Sign string `json:"signForAppData"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("uid", v.UID) + + if v.Sign != "" { + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + } else { + query.Set("password", v.Password) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getMissURL(url *url.URL) (string, error) { + clientPublic, clientPrivate, err := crypto.GenerateKey() if err != nil { return "", err } query := url.Query() - params := fmt.Sprintf( - `{"app_pubkey":"%x","did":"%s","support_vendors":"CS2"}`, + `{"app_pubkey":"%x","did":"%s","support_vendors":"TUTK_CS2_MTP"}`, clientPublic, query.Get("did"), ) - cloud, err := getCloud(url.User.Username()) - if err != nil { - return "", err - } - - region, _ := url.User.Password() - - res, err := cloud.Request(GetBaseURL(region), "/v2/device/miss_get_vendor", params, nil) + res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params) if err != nil { + if strings.Contains(err.Error(), "no available vendor support") { + return getP2PURL(url) + } return "", err } @@ -132,6 +200,13 @@ func getVendorName(i byte) string { return fmt.Sprintf("%d", i) } +func wakeUpCamera(url *url.URL) error { + const params = `{"id":1,"method":"wakeup","params":{"video":"1"}}` + did := url.Query().Get("did") + _, err := cloudUserRequest(url.User, "/home/rpc/"+did, params) + return err +} + func apiXiaomi(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": @@ -158,14 +233,8 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) { } err := func() error { - cloud, err := getCloud(user) - if err != nil { - return err - } - region := query.Get("region") - - res, err := cloud.Request(GetBaseURL(region), "/v2/home/device_list_page", "{}", nil) + res, err := cloudRequest(user, region, "/v2/home/device_list_page", "{}") if err != nil { return err } diff --git a/pkg/h265/h265_test.go b/pkg/h265/h265_test.go index 75fa03d7..278e09a3 100644 --- a/pkg/h265/h265_test.go +++ b/pkg/h265/h265_test.go @@ -17,3 +17,14 @@ func TestDecodeSPS(t *testing.T) { require.Equal(t, uint16(5120), sps.Width()) require.Equal(t, uint16(1440), sps.Height()) } + +func TestDecodeSPS2(t *testing.T) { + s := "QgEBIUAAAAMAkAAAAwAAAwCWoAUCAWlnpbkShc1AQIC4QAAAAwBAAAAFFEn/eEAOpgAV+V8IBBA=" + b, err := base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps := DecodeSPS(b) + require.NotNil(t, sps) + require.Equal(t, uint16(640), sps.Width()) + require.Equal(t, uint16(360), sps.Height()) +} diff --git a/pkg/tutk/conn.go b/pkg/tutk/conn.go new file mode 100644 index 00000000..e0610690 --- /dev/null +++ b/pkg/tutk/conn.go @@ -0,0 +1,264 @@ +package tutk + +import ( + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" +) + +func Dial(host, uid, username, password string) (*Conn, error) { + addr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + // Default port for listening incoming LAN connections. + // Important. It's not using for real connection. + addr = &net.UDPAddr{IP: net.ParseIP(host), Port: 32761} + } + + udpConn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + c := &Conn{UDPConn: udpConn, addr: addr} + + sid := GenSessionID() + + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + + if addr.Port != 10001 { + err = c.connectDirect(uid, sid) + } else { + err = c.connectRemote(uid, sid) + } + if err != nil { + _ = c.Close() + return nil, err + } + + if c.ver[0] >= 25 { + c.session = NewSession25(c, sid) + } else { + c.session = NewSession16(c, sid) + } + + if err = c.clientStart(username, password); err != nil { + _ = c.Close() + return nil, err + } + + go c.worker() + + return c, nil +} + +type Conn struct { + *net.UDPConn + addr *net.UDPAddr + session Session + + ver []byte + err error + cmdMu sync.Mutex + cmdAck func() +} + +// Read overwrite net.Conn +func (c *Conn) Read(buf []byte) (n int, err error) { + for { + var addr *net.UDPAddr + if n, addr, err = c.UDPConn.ReadFromUDP(buf); err != nil { + return 0, err + } + + if string(c.addr.IP) != string(addr.IP) || n < 16 { + continue // skip messages from another IP + } + + if c.addr.Port != addr.Port { + c.addr.Port = addr.Port + } + + ReverseTransCodePartial(buf, buf[:n]) + //log.Printf("<- %x", buf[:n]) + return n, nil + } +} + +// Write overwrite net.Conn +func (c *Conn) Write(b []byte) (n int, err error) { + //log.Printf("-> %x", b) + return c.UDPConn.WriteToUDP(TransCodePartial(nil, b), c.addr) +} + +// RemoteAddr overwrite net.Conn +func (c *Conn) RemoteAddr() net.Addr { + return c.addr +} + +func (c *Conn) Protocol() string { + return "tutk+udp" +} + +func (c *Conn) Version() string { + if len(c.ver) == 1 { + return fmt.Sprintf("TUTK/%d", c.ver[0]) + } + return fmt.Sprintf("TUTK/%d SDK %d.%d.%d.%d", c.ver[0], c.ver[1], c.ver[2], c.ver[3], c.ver[4]) +} + +func (c *Conn) ReadCommand() (ctrlType uint32, ctrlData []byte, err error) { + return c.session.RecvIOCtrl() +} + +func (c *Conn) WriteCommand(ctrlType uint32, ctrlData []byte) error { + c.cmdMu.Lock() + defer c.cmdMu.Unlock() + + var repeat atomic.Int32 + repeat.Store(5) + + timeout := time.NewTicker(time.Second) + defer timeout.Stop() + + c.cmdAck = func() { + repeat.Store(0) + timeout.Reset(1) + } + + buf := c.session.SendIOCtrl(ctrlType, ctrlData) + + for { + if err := c.session.SessionWrite(0, buf); err != nil { + return err + } + <-timeout.C + r := repeat.Add(-1) + if r < 0 { + return nil + } + if r == 0 { + return fmt.Errorf("%s: can't send command %d", "tutk", ctrlType) + } + } +} + +func (c *Conn) ReadPacket() (hdr, payload []byte, err error) { + return c.session.RecvFrameData() +} + +func (c *Conn) WritePacket(hdr, payload []byte) error { + buf := c.session.SendFrameData(hdr, payload) + return c.session.SessionWrite(1, buf) +} + +func (c *Conn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *Conn) worker() { + defer c.session.Close() + + buf := make([]byte, 1200) + + for { + n, err := c.Read(buf) + if err != nil { + c.err = fmt.Errorf("%s: %w", "tutk", err) + return + } + + switch c.handleMsg(buf[:n]) { + case msgUnknown: + fmt.Printf("tutk: unknown msg: %x\n", buf[:n]) + case msgError: + return + case msgCommandAck: + if c.cmdAck != nil { + c.cmdAck() + } + } + } +} + +const ( + msgUnknown = iota + msgError + msgPing + msgUnknownPing + msgClientStart + msgClientStart2 + msgClientStartAck2 + msgCommand + msgCommandAck + msgCounters + msgMediaChunk + msgMediaFrame + msgMediaReorder + msgMediaLost + msgCh5 + + msgUnknown0007 // time sync without data? + msgUnknown0008 // time sync with data? + msgUnknown0010 + msgUnknown0013 + msgUnknown0900 + msgUnknown0a08 + msgUnknownCh1c + msgDafang0012 +) + +func (c *Conn) handleMsg(msg []byte) int { + // off sample + // 0 0402 tutk magic + // 2 120a tutk version (120a, 190a...) + // 4 0800 msg size = len(b)-16 + // 6 0000 channel seq + // 8 28041200 msg type + // 14 0100 channel (not all msg) + // 28 0700 msg data (not all msg) + switch msg[8] { + case 0x08: + switch ch := msg[14]; ch { + case 0, 1: + return c.session.SessionRead(ch, msg[28:]) + case 5: + if len(msg) == 48 { + _, _ = c.Write(msgAckCh5(msg)) + return msgCh5 + } + case 0x1c: + return msgUnknownCh1c + } + case 0x18: + return msgUnknownPing + case 0x28: + if len(msg) == 24 { + _, _ = c.Write(msgAckPing(msg)) + return msgPing + } + } + return msgUnknown +} + +func msgAckPing(msg []byte) []byte { + // <- [24] 0402120a 08000000 28041200 000000005b0d4202070aa8c0 + // -> [24] 04021a0a 08000000 27042100 000000005b0d4202070aa8c0 + msg[8] = 0x27 + msg[10] = 0x21 + return msg +} + +func msgAckCh5(msg []byte) []byte { + // <- [48] 0402190a 20000400 07042100 7ecc05000c0000007ecc93c456c2561f 5a97c2f101050000000000000000000000010000 + // -> [48] 0402190a 20000400 08041200 7ecc05000c0000007ecc93c456c2561f 5a97c2f141050000000000000000000000010000 + msg[8] = 0x07 + msg[10] = 0x21 + msg[32] = 0x41 + return msg +} diff --git a/pkg/xiaomi/tutk/crypto.go b/pkg/tutk/crypto.go similarity index 78% rename from pkg/xiaomi/tutk/crypto.go rename to pkg/tutk/crypto.go index feeb31f7..6b306255 100644 --- a/pkg/xiaomi/tutk/crypto.go +++ b/pkg/tutk/crypto.go @@ -139,3 +139,39 @@ func swap(dst, src []byte, n int) { } copy(dst, src[:n]) } + +const delta = 0x9e3779b9 + +func XXTEADecrypt(dst, src, key []byte) { + const n = int8(4) // support only 16 bytes src + + var w, k [n]uint32 + for i := int8(0); i < n; i++ { + w[i] = binary.LittleEndian.Uint32(src) + k[i] = binary.LittleEndian.Uint32(key) + src = src[4:] + key = key[4:] + } + + rounds := 52/n + 6 + sum := uint32(rounds) * delta + for ; rounds > 0; rounds-- { + w0 := w[0] + i2 := int8((sum >> 2) & 3) + for i := n - 1; i >= 0; i-- { + wi := w[(i-1)&3] + ki := k[i^i2] + t1 := (w0 ^ sum) + (wi ^ ki) + t2 := (wi >> 5) ^ (w0 << 2) + t3 := (w0 >> 3) ^ (wi << 4) + w[i] -= t1 ^ (t2 + t3) + w0 = w[i] + } + sum -= delta + } + + for _, i := range w { + binary.LittleEndian.PutUint32(dst, i) + dst = dst[4:] + } +} diff --git a/pkg/tutk/crypto_test.go b/pkg/tutk/crypto_test.go new file mode 100644 index 00000000..1f1be3f2 --- /dev/null +++ b/pkg/tutk/crypto_test.go @@ -0,0 +1,14 @@ +package tutk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestXXTEADecrypt(t *testing.T) { + buf := []byte("WERhJxb87WF3zgPa") + key := []byte("GAgDiwVPg2E4GMke") + XXTEADecrypt(buf, buf, key) + require.Equal(t, "\xc4\xa6\x2c\xa1\x10\x64\x17\xa5\xda\x02\xe1\x62\xa5\xf0\x62\x71", string(buf)) +} diff --git a/pkg/tutk/helpers.go b/pkg/tutk/helpers.go new file mode 100644 index 00000000..118119be --- /dev/null +++ b/pkg/tutk/helpers.go @@ -0,0 +1,28 @@ +package tutk + +import "encoding/binary" + +// https://github.com/seydx/tutk_wyze#11-codec-reference +const ( + CodecH264 = 0x4e + CodecH265 = 0x50 + CodecPCMA = 0x8a + CodecPCML = 0x8c + CodecAAC = 0x88 +) + +func ICAM(cmd uint32, args ...byte) []byte { + // 0 4943414d ICAM + // 4 d807ff00 command + // 8 00000000000000 + // 15 02 args count + // 16 00000000000000 + // 23 0101 args + n := byte(len(args)) + b := make([]byte, 23+n) + copy(b, "ICAM") + binary.LittleEndian.PutUint32(b[4:], cmd) + b[15] = n + copy(b[23:], args) + return b +} diff --git a/pkg/tutk/session0.go b/pkg/tutk/session0.go new file mode 100644 index 00000000..1f1bbc7e --- /dev/null +++ b/pkg/tutk/session0.go @@ -0,0 +1,163 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "net" + "time" +) + +func (c *Conn) connectDirect(uid string, sid []byte) error { + res, err := writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x02\x06\x12\x00")) == 8 }, + ConnectByUID(stageBroadcast, uid, sid), + ) + if err != nil { + return err + } + + n := len(res) // should be 200 + c.ver = []byte{res[2], res[n-13], res[n-14], res[n-15], res[n-16]} + + _, err = c.Write(ConnectByUID(stageDirect, uid, sid)) + return err +} + +func (c *Conn) connectRemote(uid string, sid []byte) error { + res, err := writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x01\x03\x43")) == 8 }, + ConnectByUID(stageGetRemoteIP, uid, sid), + ) + if err != nil { + return err + } + + // Read real IP from cloud server response. + // Important ot use net.IPv4 because slice will be 16 bytes. + c.addr.IP = net.IPv4(res[40], res[41], res[42], res[43]) + c.addr.Port = int(binary.BigEndian.Uint16(res[38:])) + + res, err = writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x04\x04\x33")) == 8 }, + ConnectByUID(stageRemoteAck, uid, sid), + ) + if err != nil { + return err + } + + if len(res) == 52 { + c.ver = []byte{res[2], res[51], res[50], res[49], res[48]} + } else { + c.ver = []byte{res[2]} + } + + _, err = c.Write(ConnectByUID(stageRemoteOK, uid, sid)) + return err +} + +func (c *Conn) clientStart(username, password string) error { + _, err := writeAndWait( + c, func(res []byte) bool { + return len(res) >= 84 && res[28] == 0 && (res[29] == 0x14 || res[29] == 0x21) + }, + c.session.ClientStart(0, username, password), + c.session.ClientStart(1, username, password), + ) + return err +} + +func writeAndWait(conn net.Conn, ok func(res []byte) bool, req ...[]byte) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + for _, b := range req { + if _, err := conn.Write(b); err != nil { + return + } + } + if t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + buf := make([]byte, 1200) + + for { + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + + if ok(buf[:n]) { + return buf[:n], nil + } + } +} + +const ( + magic = "\x04\x02\x19" // include version 0x19 + sdkVersion = "\x06\x00\x03\x03" // 3.3.0.6 +) + +const ( + stageBroadcast = iota + 1 + stageDirect + stageGetPublicIP + stageGetRemoteIP + stageRemoteReq + stageRemoteAck + stageRemoteOK +) + +func ConnectByUID(stage byte, uid string, sid8 []byte) []byte { + var b []byte + + switch stage { + case stageBroadcast, stageDirect: + b = make([]byte, 68) + copy(b[8:], "\x01\x06\x21") + copy(b[52:], sdkVersion) + copy(b[56:], sid8) + b[64] = stage // 1 or 2 + + case stageGetPublicIP: + b = make([]byte, 54) + copy(b[8:], "\x07\x10\x18") + + case stageGetRemoteIP: + b = make([]byte, 112) + copy(b[8:], "\x03\x02\x34") + copy(b[100:], sid8) + b[108] = stageDirect + + case stageRemoteReq: + b = make([]byte, 52) + copy(b[8:], "\x01\x04\x33") + copy(b[36:], sid8) + copy(b[48:], sdkVersion) + + case stageRemoteAck: + b = make([]byte, 44) + copy(b[8:], "\x02\x04\x33") + copy(b[36:], sid8) + + case stageRemoteOK: + b = make([]byte, 52) + copy(b[8:], "\x04\x04\x33") + copy(b[36:], sid8) + copy(b[48:], sdkVersion) + } + + copy(b, magic) + b[3] = 0x02 // connection stage + binary.LittleEndian.PutUint16(b[4:], uint16(len(b))-16) + copy(b[16:], uid) + + return b +} + +func GenSessionID() []byte { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) + return b +} diff --git a/pkg/tutk/session16.go b/pkg/tutk/session16.go new file mode 100644 index 00000000..47110dd3 --- /dev/null +++ b/pkg/tutk/session16.go @@ -0,0 +1,378 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "io" + "net" + "time" +) + +type Session interface { + Close() error + + ClientStart(i byte, username, password string) []byte + + SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte + SendFrameData(frameInfo, frameData []byte) []byte + + RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error) + RecvFrameData() (frameInfo, frameData []byte, err error) + + SessionRead(chID byte, buf []byte) int + SessionWrite(chID byte, buf []byte) error +} + +func NewSession16(conn net.Conn, sid8 []byte) *Session16 { + sid16 := make([]byte, 16) + copy(sid16[8:], sid8) + copy(sid16, sid8[:2]) + sid16[4] = 0x0c + + return &Session16{ + conn: conn, + sid16: sid16, + rawCmd: make(chan []byte, 10), + rawPkt: make(chan [2][]byte, 100), + } +} + +type Session16 struct { + conn net.Conn + sid16 []byte + + rawCmd chan []byte + rawPkt chan [2][]byte + + seqSendCh0 uint16 + seqSendCh1 uint16 + + seqSendCmd1 uint16 + seqSendAud uint16 + + waitSeq uint16 + waitSize int + waitData []byte +} + +func (s *Session16) Close() error { + close(s.rawCmd) + close(s.rawPkt) + return nil +} + +func (s *Session16) Msg(size uint16) []byte { + b := make([]byte, size) + copy(b, magic) + b[3] = 0x0a // connected stage + binary.LittleEndian.PutUint16(b[4:], size-16) + copy(b[8:], "\x07\x04\x21") // client request + copy(b[12:], s.sid16) + return b +} + +const ( + msgHhrSize = 28 + cmdHdrSize = 24 +) + +func (s *Session16) ClientStart(i byte, username, password string) []byte { + const size = 566 + 32 + msg := s.Msg(size) + + // 0 00000b0000000000000000000000000022020000fcfc7284 + // 24 4d69737300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + // 281 636c69656e740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + // 538 0100000004000000fb071f000000000000000000000003000000000001000000 + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x00\x0b\x00") + binary.LittleEndian.PutUint16(cmd[16:], size-52) + if i == 0 { + cmd[18] = 1 + } else { + cmd[1] = 0x20 + } + binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + // important values for some cameras (not for df3) + data := cmd[cmdHdrSize:] + copy(data, username) + copy(data[257:], password) + + // 0100000004000000fb071f000000000000000000000003000000000001000000 + cfg := data[257+257:] + //cfg[0] = 1 // 0 - simple proto, 1 - complex proto with "0Cxx" commands + cfg[4] = 4 + copy(cfg[8:], "\xfb\x07\x1f\x00") + cfg[22] = 3 + //cfg[28] = 1 // unknown + return msg +} + +func (s *Session16) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte { + dataSize := 4 + uint16(len(ctrlData)) + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + + s.seqSendCmd1++ // start from 1, important! + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + + binary.LittleEndian.PutUint16(cmd[16:], dataSize) + binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + data := cmd[cmdHdrSize:] + binary.LittleEndian.PutUint32(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (s *Session16) SendFrameData(frameInfo, frameData []byte) []byte { + // -> 01030b001d0000008802000000002800b0020bf501000000 ... 4f4455412000000088020000030400001d000000000000000bf51f7a9b0100000000000000000000 + + n := uint16(len(frameData)) + dataSize := n + 8 + 32 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + // 0 01030b00 command + version + // 4 1d000000 seq + // 8 8802 media size (648) + // 10 00000000 + // 14 2800 tail (pkt header) size? + // 16 b002 size (648 + 8 + 32) + // 18 0bf5 random msg id (unixms) + // 20 01000000 fixed + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x03\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendAud) + s.seqSendAud++ + binary.LittleEndian.PutUint16(cmd[8:], n) + cmd[14] = 0x28 // important! + binary.LittleEndian.PutUint16(cmd[16:], dataSize) + binary.LittleEndian.PutUint16(cmd[18:], uint16(time.Now().UnixMilli())) + cmd[20] = 1 + + data := cmd[cmdHdrSize:] + copy(data, frameData) + copy(data[n:], "ODUA\x20\x00\x00\x00") + copy(data[n+8:], frameInfo) + + return msg +} + +func (s *Session16) RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error) { + buf, ok := <-s.rawCmd + if !ok { + return 0, nil, io.EOF + } + return binary.LittleEndian.Uint32(buf), buf[4:], nil +} + +func (s *Session16) RecvFrameData() (frameInfo, frameData []byte, err error) { + buf, ok := <-s.rawPkt + if !ok { + return nil, nil, io.EOF + } + return buf[0], buf[1], nil +} + +func (s *Session16) SessionRead(chID byte, cmd []byte) int { + if chID != 0 { + return s.handleCh1(cmd) + } + + // 0 01030800 command + version + // 4 00000000 frame num + // 8 ac880100 total size + // 12 6200 chunk seq + // 14 2000 tail (pkt header) size + // 16 cc00 size + // 18 0000 + // 20 01000000 fixed + + switch cmd[0] { + case 0x01: + var packetData [2][]byte + + switch cmd[1] { + case 0x03: + seq := binary.LittleEndian.Uint16(cmd[12:]) + if seq != s.waitSeq { + s.waitSeq = 0 + return msgMediaLost + } + if seq == 0 { + s.waitData = s.waitData[:0] + payloadSize := binary.LittleEndian.Uint32(cmd[8:]) + hdrSize := binary.LittleEndian.Uint16(cmd[14:]) + s.waitSize = int(hdrSize) + int(payloadSize) + } + + s.waitData = append(s.waitData, cmd[24:]...) + if n := len(s.waitData); n < s.waitSize { + s.waitSeq++ + return msgMediaChunk + } + + s.waitSeq = 0 + + payloadSize := binary.LittleEndian.Uint32(cmd[8:]) + packetData[0] = bytes.Clone(s.waitData[payloadSize:]) + packetData[1] = bytes.Clone(s.waitData[:payloadSize]) + + case 0x04: + data := cmd[24:] + hdrSize := binary.LittleEndian.Uint16(cmd[14:]) + packetData[0] = bytes.Clone(data[:hdrSize]) + packetData[1] = bytes.Clone(data[hdrSize:]) + + default: + return msgUnknown + } + + select { + case s.rawPkt <- packetData: + default: + return msgError + } + return msgMediaFrame + + case 0x00: + switch cmd[1] { + case 0x70: + _ = s.SessionWrite(0, s.msgAck0070(cmd)) + select { + case s.rawCmd <- append([]byte{}, cmd[24:]...): + default: + } + + return msgCommand + case 0x12: + _ = s.SessionWrite(0, s.msgAck0012(cmd)) + return msgDafang0012 + case 0x71: + return msgCommandAck + } + } + + return msgUnknown +} + +func (s *Session16) msgAck0070(msg28 []byte) []byte { + // <- 00700800010000000000000000000000340000007625a02f ... + // -> 00710800010000000000000000000000000000007625a02f + msg := s.Msg(msgHhrSize + cmdHdrSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x71") + copy(cmd[2:], msg28[2:6]) // same version and seq + copy(cmd[20:], msg28[20:24]) // same msg random + + return msg +} + +func (s *Session16) msgAck0012(msg28 []byte) []byte { + // <- 001208000000000000000000000000000c00000000000000 020000000100000001000000 + // -> 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000 + const dataSize = 20 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x13\x0b\x00") + cmd[16] = dataSize + + data := cmd[cmdHdrSize:] + copy(data, msg28[cmdHdrSize:]) + + return msg +} + +func (s *Session16) handleCh1(cmd []byte) int { + // Channel 1 used for two-way audio. It's important: + // - answer on 0000 command with exact config response (can't set simple proto) + // - send 0012 command at start + // - respond on every 0008 command for smooth playback + switch cid := string(cmd[:2]); cid { + case "\x00\x00": // client start + _ = s.SessionWrite(1, s.msgAck0000(cmd)) + _ = s.SessionWrite(1, s.msg0012()) + return msgClientStart + case "\x00\x07": // time sync without data + _ = s.SessionWrite(1, s.msgAck0007(cmd)) + return msgUnknown0007 + case "\x00\x08": // time sync with data + _ = s.SessionWrite(1, s.msgAck0008(cmd)) + return msgUnknown0008 + case "\x00\x13": // ack for 0012 + return msgUnknown0013 + } + return msgUnknown +} + +func (s *Session16) msgAck0000(msg28 []byte) []byte { + // <- 000008000000000000000000000000001a0200004f47c714 ... 00000000000000000100000004000000fb071f00000000000000000000000300 + // -> 00140b00000000000000000000000000200000004f47c714 00000000000000000100000004000000fb071f00000000000000000000000300 + const cmdDataSize = 32 + msg := s.Msg(msgHhrSize + cmdHdrSize + cmdDataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x14\x0b\x00") + cmd[16] = cmdDataSize + copy(cmd[20:], msg28[20:24]) // request id (random) + + // Important to answer with same data. + data := cmd[cmdHdrSize:] + copy(data, msg28[len(msg28)-32:]) + return msg +} + +func (s *Session16) msg0012() []byte { + // -> 00120b000000000000000000000000000c00000000000000020000000100000001000000 + const dataSize = 12 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + cmd := msg[msgHhrSize:] + + copy(cmd, "\x00\x12\x0b\x00") + cmd[16] = dataSize + data := cmd[cmdHdrSize:] + + data[0] = 2 + data[4] = 1 + data[9] = 1 + return msg +} + +func (s *Session16) msgAck0007(msg28 []byte) []byte { + // <- 000708000000000000000000000000000c00000001000000000000001c551f7a00000000 + // -> 010a0b00000000000000000000000000000000000100000000000000 + msg := s.Msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x0a\x0b\x00") + cmd[20] = 1 + return msg +} + +func (s *Session16) msgAck0008(msg28 []byte) []byte { + // <- 000808000000000000000000000000000000f9f0010000000200000050f31f7a + // -> 01090b0000000000000000000000000000000000010000000200000050f31f7a + msg := s.Msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x09\x0b\x00") + copy(cmd[20:], msg28[20:]) + return msg +} + +func (s *Session16) SessionWrite(chID byte, buf []byte) error { + switch chID { + case 0: + binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh0) + s.seqSendCh0++ + case 1: + binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh1) + s.seqSendCh1++ + buf[14] = 1 // channel + } + _, err := s.conn.Write(buf) + return err +} diff --git a/pkg/tutk/session25.go b/pkg/tutk/session25.go new file mode 100644 index 00000000..a12f52f3 --- /dev/null +++ b/pkg/tutk/session25.go @@ -0,0 +1,341 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "net" + "time" +) + +func NewSession25(conn net.Conn, sid []byte) *Session25 { + return &Session25{ + Session16: NewSession16(conn, sid), + rb: NewReorderBuffer(5), + } +} + +type Session25 struct { + *Session16 + + rb *ReorderBuffer + + seqSendCmd2 uint16 + seqSendCnt uint16 + + seqRecvPkt0 uint16 + seqRecvPkt1 uint16 + seqRecvCmd2 uint16 +} + +const cmdHdrSize25 = 28 + +func (s *Session25) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte { + size := msgHhrSize + cmdHdrSize25 + 4 + uint16(len(ctrlData)) + msg := s.Msg(size) + + // 0 0070 command + // 2 0b00 version + // 4 1000 seq + // 6 0076 ??? + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + s.seqSendCmd1++ + + // 8 0070 command (second time) + // 10 0300 seq + // 12 0100 chunks count + // 14 0000 chunk seq (starts from 0) + // 16 5500 size + // 18 0000 random msg id (always 0) + // 20 03000000 seq (second time) + // 24 00000000 + // 28 01010000 ctrlType + cmd[9] = 0x70 + cmd[12] = 1 + binary.LittleEndian.PutUint16(cmd[16:], size-52) + + binary.LittleEndian.PutUint16(cmd[10:], s.seqSendCmd2) + binary.LittleEndian.PutUint16(cmd[20:], s.seqSendCmd2) + s.seqSendCmd2++ + + data := cmd[28:] + binary.LittleEndian.PutUint32(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (s *Session25) SendFrameData(frameInfo, frameData []byte) []byte { + return nil +} + +func (s *Session25) SessionRead(chID byte, cmd []byte) (res int) { + if chID != 0 { + return s.handleCh1(cmd) + } + + switch cmd[0] { + case 0x03, 0x05, 0x07: + for i := 0; cmd != nil; i++ { + res = s.handleChunk(cmd, i == 0) + cmd = s.rb.Pop() + } + return + + case 0x00: + _ = s.SessionWrite(0, s.msgAckCounters()) + s.seqRecvCmd2 = binary.LittleEndian.Uint16(cmd[2:]) + + switch cmd[1] { + case 0x10: + return msgUnknown0010 // unknown + case 0x21: + return msgClientStartAck2 + case 0x70: + select { + case s.rawCmd <- cmd[28:]: + default: + } + return msgCommand // cmd from camera + case 0x71: + return msgCommandAck + } + + case 0x09: + // off sample + // 0 09000b00 cmd1 + // 4 0d000000 seqCmd1 + // 12 0000 seqRecvCmd2 + seq := binary.LittleEndian.Uint16(cmd[12:]) + if s.seqSendCmd1 > seq { + return msgCommandAck + } + return msgCounters + + case 0x0a: + // seq sample + // 0 0a080b00 + // 4 03000000 + // 8 e2043200 + // 12 01000000 + _ = s.SessionWrite(0, s.msgAck0A08(cmd)) + return msgUnknown0a08 + } + + return msgUnknown +} + +func (s *Session25) handleChunk(cmd []byte, checkSeq bool) int { + var cmd2 []byte + + flags := cmd[1] + if flags&0b1000 == 0 { + // off sample + // 0 0700 command + // 2 0b00 version + // 4 2700 seq + // 6 0000 ??? + // 8 0700 command (second time) + // 10 1400 seq + // 12 1300 chunks count per this frame + // 14 1100 chunk seq, starts from 0 (0x20 for last chunk) + // 16 0004 frame data size + // 18 0000 random msg id (always 0) + // 20 02000000 previous frame seq, starts from 0 + // 24 03000000 current frame seq, starts from 1 + cmd2 = cmd[8:] + } else { + // off sample + // 0 070d0b00 + // 4 30000000 + // 8 5c965500 ??? + // 12 ffff0000 ??? + // 16 0701 fixed command + // 18 190001002000a802000006000000070000000 + cmd2 = cmd[16:] + } + + seq := binary.LittleEndian.Uint16(cmd2[2:]) + + if checkSeq { + if s.rb.Check(seq) { + s.rb.Next() + } else { + s.rb.Push(seq, cmd) + return msgMediaReorder + } + } + + // Check if this is first chunk for frame. + // Handle protocol bug "0x20 chunk seq for last chunk" and sometimes + // "0x20 chunk seq for first chunk if only one chunk". + if binary.LittleEndian.Uint16(cmd2[6:]) == 0 || binary.LittleEndian.Uint16(cmd2[4:]) == 1 { + s.waitData = s.waitData[:0] + s.waitSeq = seq + } else if seq != s.waitSeq { + return msgMediaLost + } + + s.waitData = append(s.waitData, cmd2[20:]...) + + if flags&0b0001 == 0 { + s.waitSeq++ + return msgMediaChunk + } + + s.seqRecvPkt1 = seq + _ = s.SessionWrite(0, s.msgAckCounters()) + + n := len(s.waitData) - 32 + packetData := [2][]byte{bytes.Clone(s.waitData[n:]), bytes.Clone(s.waitData[:n])} + + select { + case s.rawPkt <- packetData: + default: + return msgError + } + return msgMediaFrame +} + +func (s *Session25) msgAckCounters() []byte { + msg := s.Msg(msgHhrSize + cmdHdrSize) + + // off sample + // 0 09000b00 cmd1 + // 4 2700 seqCmd1 + // 6 0000 + // 8 1300 seqRecvPkt0 + // 10 2600 seqRecvPkt1 + // 12 0400 seqRecvCmd2 + // 14 00000000 + // 18 1400 seqSendCnt + // 20 d91a random + // 22 0000 + cmd := msg[msgHhrSize:] + copy(cmd, "\x09\x00\x0b\x00") + + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + s.seqSendCmd1++ + + // seqRecvPkt0 stores previous value of seqRecvPkt1 + // don't understand why this needs + binary.LittleEndian.PutUint16(cmd[8:], s.seqRecvPkt0) + s.seqRecvPkt0 = s.seqRecvPkt1 + binary.LittleEndian.PutUint16(cmd[10:], s.seqRecvPkt1) + binary.LittleEndian.PutUint16(cmd[12:], s.seqRecvCmd2) + + binary.LittleEndian.PutUint16(cmd[18:], s.seqSendCnt) + s.seqSendCnt++ + binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixMilli())) + return msg +} + +func (s *Session25) handleCh1(cmd []byte) int { + // Channel 1 used for two-way audio. It's important: + // - answer on 0000 command with exact config response (can't set simple proto) + // - send 0012 command at start + // - respond on every 0008 command for smooth playback + switch cid := string(cmd[:2]); cid { + case "\x00\x00": // client start + return msgClientStart + case "\x00\x07": // time sync without data + _ = s.SessionWrite(1, s.msgAck0007(cmd)) + return msgUnknown0007 + case "\x00\x20": // client start2 + _ = s.SessionWrite(1, s.msgAck0020(cmd)) + return msgClientStart2 + case "\x09\x00": + return msgUnknown0900 + case "\x0a\x08": + return msgUnknown0a08 + } + return msgUnknown +} + +func (s *Session25) msgAck0020(msg28 []byte) []byte { + const cmdDataSize = 36 + + msg := s.Msg(msgHhrSize + cmdHdrSize25 + cmdDataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x21\x0b\x00") + cmd[16] = cmdDataSize + copy(cmd[20:], msg28[20:24]) // request id (random) + + // 0 00000000 + // 4 00010001 + // 8 01000000 + // 12 04000000 + // 16 fb071f00 + // 20 00000000 + // 24 00000000 + // 28 00000300 + // 32 01000000 + data := cmd[cmdHdrSize25:] + data[5] = 1 + data[7] = 1 + data[8] = 1 + data[12] = 4 + copy(data[16:], "\xfb\x07\x1f\x00") + data[30] = 3 + data[32] = 1 + return msg +} + +func (s *Session25) msgAck0A08(msg28 []byte) []byte { + // <- 0a080b005b0000000b51590002000000 + // -> 0b000b00000001000b5103000300000000000000 + msg := s.Msg(msgHhrSize + 20) + cmd := msg[msgHhrSize:] + copy(cmd, "\x0b\x00\x0b\x00") + copy(cmd[8:], msg28[8:10]) + return msg +} + +// ReorderBuffer used for UDP incoming data. Because the order of the packets may be mixed up. +type ReorderBuffer struct { + buf map[uint16][]byte + seq uint16 + size int +} + +func NewReorderBuffer(size int) *ReorderBuffer { + return &ReorderBuffer{buf: make(map[uint16][]byte), size: size} +} + +// Check return OK if this is the seq we are waiting for. +func (r *ReorderBuffer) Check(seq uint16) (ok bool) { + return seq == r.seq +} + +func (r *ReorderBuffer) Next() { + r.seq++ +} + +// Available return how much free slots is in the buffer. +func (r *ReorderBuffer) Available() int { + return r.size - len(r.buf) +} + +// Push new item to buffer. Important! There is no buffer full check here. +func (r *ReorderBuffer) Push(seq uint16, data []byte) { + //log.Printf("push seq=%d wait=%d", seq, r.seq) + r.buf[seq] = bytes.Clone(data) +} + +// Pop latest item from buffer. OK - if items wasn't dropped. +func (r *ReorderBuffer) Pop() []byte { + for { + if data := r.buf[r.seq]; data != nil { + delete(r.buf, r.seq) + r.Next() + //log.Printf("pop seq=%d", r.seq) + return data + } + if r.Available() > 0 { + return nil + } + //log.Printf("drop seq=%d", r.seq) + r.Next() // drop item + } +} diff --git a/pkg/xiaomi/crypto/crypto.go b/pkg/xiaomi/crypto/crypto.go new file mode 100644 index 00000000..16d6d1bc --- /dev/null +++ b/pkg/xiaomi/crypto/crypto.go @@ -0,0 +1,68 @@ +package crypto + +import ( + "crypto/rand" + "encoding/hex" + + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/nacl/box" +) + +func GenerateKey() ([]byte, []byte, error) { + public, private, err := box.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + return public[:], private[:], err +} + +func CalcSharedKey(devicePublicB64, clientPrivateB64 string) ([]byte, error) { + var sharedKey, publicKey, privateKey [32]byte + if _, err := hex.Decode(publicKey[:], []byte(devicePublicB64)); err != nil { + return nil, err + } + if _, err := hex.Decode(privateKey[:], []byte(clientPrivateB64)); err != nil { + return nil, err + } + box.Precompute(&sharedKey, &publicKey, &privateKey) + return sharedKey[:], nil +} + +func Encode(src, key32 []byte) ([]byte, error) { + dst := make([]byte, len(src)+8) + + if _, err := rand.Read(dst[:8]); err != nil { + return nil, err + } + + nonce12 := make([]byte, 12) + copy(nonce12[4:], dst[:8]) + + c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12) + if err != nil { + return nil, err + } + + c.XORKeyStream(dst[8:], src) + + return dst, nil +} + +func Decode(src, key32 []byte) ([]byte, error) { + return DecodeNonce(src[8:], src[:8], key32) +} + +func DecodeNonce(src, nonce8, key32 []byte) ([]byte, error) { + nonce12 := make([]byte, 12) + copy(nonce12[4:], nonce8) + + c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12) + if err != nil { + return nil, err + } + + dst := make([]byte, len(src)) + c.XORKeyStream(dst, src) + + return dst, nil +} diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go new file mode 100644 index 00000000..cf84b6fe --- /dev/null +++ b/pkg/xiaomi/legacy/client.go @@ -0,0 +1,227 @@ +package legacy + +import ( + "encoding/binary" + "errors" + "fmt" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" +) + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + model := query.Get("model") + + var username, password string + var key []byte + + if query.Has("sign") { + // Legacy with encryption + key, err = crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private")) + if err != nil { + return nil, err + } + + username = fmt.Sprintf( + `{"public_key":"%s","sign":"%s","account":"admin"}`, + query.Get("client_public"), query.Get("sign"), + ) + } else if model == ModelXiaobai { + username = "admin" + password = query.Get("password") + } else if model == ModelXiaofang { + username = "admin" + } else { + return nil, fmt.Errorf("xiaomi: unsupported model: %s", model) + } + + conn, err := tutk.Dial(u.Host, query.Get("uid"), username, password) + if err != nil { + return nil, err + } + + if model == ModelXiaofang { + err = xiaofangLogin(conn, query.Get("password")) + if err != nil { + _ = conn.Close() + return nil, err + } + } + + c := &Client{ + Conn: conn, + key: key, + model: model, + } + + return c, nil +} + +func xiaofangLogin(conn *tutk.Conn, password string) error { + data := tutk.ICAM(0x0400be) // ask login + if err := conn.WriteCommand(0x0100, data); err != nil { + return err + } + + _, data, err := conn.ReadCommand() // login request + if err != nil { + return err + } + + enc := data[24:] // data[23] == 3 + tutk.XXTEADecrypt(enc, enc, []byte(password)) + + enc = append(enc, 0, 0, 0, 0, 1, 1, 1) + data = tutk.ICAM(0x0400c0, enc...) // login response + if err = conn.WriteCommand(0x0100, data); err != nil { + return err + } + + _, data, err = conn.ReadCommand() + if err != nil { + return err + } + return nil +} + +type Client struct { + *tutk.Conn + key []byte + model string +} + +func (c *Client) Version() string { + return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model) +} + +func (c *Client) ReadPacket() (hdr, payload []byte, err error) { + hdr, payload, err = c.Conn.ReadPacket() + if err != nil { + return + } + if c.key != nil { + switch hdr[0] { + case tutk.CodecH264, tutk.CodecH265: + payload, err = DecodeVideo(payload, c.key) + if err != nil { + return + } + case tutk.CodecAAC: + payload, err = crypto.Decode(payload, c.key) + if err != nil { + return + } + } + } + return +} + +func (c *Client) StartMedia(video, audio string) error { + switch c.model { + case ModelAqaraG2: + return c.WriteCommand(0x01ff, []byte(`{}`)) + + case ModelXiaobai: + // 00030000 7b7d audio on + // 01030000 7b7d audio off + if err := c.WriteCommand(0x0300, []byte(`{}`)); err != nil { + return err + } + + var b byte + switch video { + case "", "fhd": + b = 1 + case "hd": + b = 2 + case "sd": + b = 4 + case "auto": + b = 0xff + } + // 20030000 0000000001000000 fhd (1920x1080) + // 20030000 0000000002000000 hd (1280x720) + // 20030000 0000000004000000 low (640x360) + // 20030000 00000000ff000000 auto (1920x1080) + if err := c.WriteCommand(0x0320, []byte{0, 0, 0, 0, b, 0, 0, 0}); err != nil { + return err + } + + // ff010000 7b7d video tart + // ff020000 7b7d video stop + return c.WriteCommand(0x01ff, []byte(`{}`)) + + case ModelXiaofang: + // 00010000 4943414d 95010400000000000000000600000000000000d20400005a07 - 90k bitrate + // 00010000 4943414d 95010400000000000000000600000000000000d20400001e07 - 30k bitrate + //var b byte + //switch video { + //case "", "hd": + // b = 0x5a // bitrate 90k + //case "sd": + // b = 0x1e // bitrate 30k + //} + //data := tutk.ICAM(0x040195, 0xd2, 4, 0, 0, b, 7) + //if err := c.WriteCommand(0x100, data); err != nil { + // return err + //} + } + + return nil +} + +func (c *Client) StopMedia() error { + return errors.Join( + c.WriteCommand(0x02ff, []byte(`{}`)), + c.WriteCommand(0x02ff, make([]byte, 8)), + ) +} + +func DecodeVideo(data, key []byte) ([]byte, error) { + if string(data[:4]) == "\x00\x00\x00\x01" || data[8] == 0 { + return data, nil + } + + if data[8] != 1 { + // Support could be added, but I haven't seen such cameras. + return nil, fmt.Errorf("xiaomi: unsupported encryption") + } + + nonce8 := data[:8] + i1 := binary.LittleEndian.Uint16(data[9:]) + i2 := binary.LittleEndian.Uint16(data[13:]) + data = data[17:] + src := data[i1 : i1+i2] + + for i := 32; i+16 < len(src); i += 160 { + dst, err := crypto.DecodeNonce(src[i:i+16], nonce8, key) + if err != nil { + return nil, err + } + copy(src[i:], dst) // copy result in same place + } + + return data, nil +} + +const ( + ModelAqaraG2 = "lumi.camera.gwagl01" + ModelLoockV1 = "loock.cateye.v01" + ModelXiaobai = "chuangmi.camera.xiaobai" + ModelXiaofang = "isa.camera.isc5" +) + +func Supported(model string) bool { + switch model { + case ModelAqaraG2, ModelLoockV1, ModelXiaobai, ModelXiaofang: + return true + } + return false +} diff --git a/pkg/xiaomi/legacy/producer.go b/pkg/xiaomi/legacy/producer.go new file mode 100644 index 00000000..6669d837 --- /dev/null +++ b/pkg/xiaomi/legacy/producer.go @@ -0,0 +1,217 @@ +package legacy + +import ( + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/rtp" +) + +func Dial(rawURL string) (*Producer, error) { + client, err := NewClient(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + err = client.StartMedia(query.Get("subtype"), "") + if err != nil { + _ = client.Close() + return nil, err + } + + medias, err := probe(client) + if err != nil { + _ = client.Close() + return nil, err + } + + c := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "xiaomi/legacy", + Protocol: "tutk+udp", + RemoteAddr: client.RemoteAddr().String(), + UserAgent: client.Version(), + Medias: medias, + Transport: client, + }, + client: client, + } + return c, nil +} + +type Producer struct { + core.Connection + client *Client +} + +const codecXiaobaiPCMA = 1 // chuangmi.camera.xiaobai + +func probe(client *Client) ([]*core.Media, error) { + _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + + var vcodec, acodec *core.Codec + + for { + // 0 5000 + // 2 0000 codec params + // 4 01 active clients + // 5 34 unknown const + // 6 0600 unknown seq(s) + // 8 80026801 unknown fixed + // 12 ed8d5c69 time in sec + // 16 4c03 time in 1/1000 + // 18 0000 + hdr, payload, err := client.ReadPacket() + if err != nil { + return nil, err + } + + switch codec := hdr[0]; codec { + case tutk.CodecH264, tutk.CodecH265: + if vcodec == nil { + avcc := annexb.EncodeToAVCC(payload) + if codec == tutk.CodecH264 { + if h264.NALUType(avcc) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(avcc) + vcodec.FmtpLine = "" + } + } else { + if h265.NALUType(avcc) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(avcc) + } + } + } + case tutk.CodecPCMA, codecXiaobaiPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} + } + case tutk.CodecPCML: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCML, ClockRate: 8000} + } + case tutk.CodecAAC: + if acodec == nil { + acodec = aac.ADTSToCodec(payload) + if acodec != nil { + acodec.PayloadType = core.PayloadTypeRAW + } + } + } + + if vcodec != nil && acodec != nil { + break + } + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }, + } + return medias, nil +} + +func (c *Producer) Protocol() string { + return "tutk+udp" +} + +func (c *Producer) Start() error { + var audioTS uint32 + var videoSeq, audioSeq uint16 + + for { + _ = c.client.SetDeadline(time.Now().Add(5 * time.Second)) + hdr, payload, err := c.client.ReadPacket() + if err != nil { + return err + } + + n := len(payload) + c.Recv += n + + // TODO: rewrite this + var name string + var pkt *core.Packet + + switch codec := hdr[0]; codec { + case tutk.CodecH264, tutk.CodecH265: + pkt = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: videoSeq, + Timestamp: core.Now90000(), + }, + Payload: annexb.EncodeToAVCC(payload), + } + videoSeq++ + + if codec == tutk.CodecH264 { + name = core.CodecH264 + } else { + name = core.CodecH265 + } + + case tutk.CodecPCMA, tutk.CodecPCML, codecXiaobaiPCMA: + pkt = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: audioSeq, + Timestamp: audioTS, + }, + Payload: payload, + } + audioSeq++ + + switch codec { + case tutk.CodecPCMA, codecXiaobaiPCMA: + name = core.CodecPCMA + audioTS += uint32(n) + case tutk.CodecPCML: + name = core.CodecPCML + audioTS += uint32(n / 2) // because 16bit + } + + case tutk.CodecAAC: + pkt = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: audioSeq, + Timestamp: audioTS, + }, + Payload: payload, + } + audioSeq++ + + name = core.CodecAAC + audioTS += 1024 + } + + for _, recv := range c.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt) + break + } + } + } +} + +func (c *Producer) Stop() error { + _ = c.client.StopMedia() + return c.Connection.Stop() +} diff --git a/pkg/xiaomi/backchannel.go b/pkg/xiaomi/miss/backchannel.go similarity index 76% rename from pkg/xiaomi/backchannel.go rename to pkg/xiaomi/miss/backchannel.go index 17242b8e..02ea3bb1 100644 --- a/pkg/xiaomi/backchannel.go +++ b/pkg/xiaomi/miss/backchannel.go @@ -1,4 +1,4 @@ -package xiaomi +package miss import ( "time" @@ -6,12 +6,11 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/opus" "github.com/AlexxIT/go2rtc/pkg/pcm" - "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" "github.com/pion/rtp" ) func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { - if err := p.client.SpeakerStart(); err != nil { + if err := p.client.StartSpeaker(); err != nil { return err } // TODO: check this!!! @@ -23,8 +22,7 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv case core.CodecPCMA: var buf []byte - switch p.model { - case "isa.camera.hlc6", "isa.camera.df3": + if p.client.SpeakerCodec() == codecPCM { dst := &core.Codec{Name: core.CodecPCML, ClockRate: 8000} transcode := pcm.Transcode(dst, track.Codec) @@ -33,23 +31,23 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv const size = 2 * 8000 * 0.040 // 16bit 40ms for len(buf) >= size { p.Send += size - _ = p.client.WriteAudio(miss.CodecPCM, buf[:size]) + _ = p.client.WriteAudio(codecPCM, buf[:size]) buf = buf[size:] } } - default: + } else { sender.Handler = func(pkt *rtp.Packet) { buf = append(buf, pkt.Payload...) const size = 8000 * 0.040 // 8bit 40 ms for len(buf) >= size { p.Send += size - _ = p.client.WriteAudio(miss.CodecPCMA, buf[:size]) + _ = p.client.WriteAudio(codecPCMA, buf[:size]) buf = buf[size:] } } } case core.CodecOpus: - if p.model == "chuangmi.camera.72ac1" { + if p.client.SpeakerCodec() == codecOPUS { var buf []byte sender.Handler = func(pkt *rtp.Packet) { if buf == nil { @@ -58,14 +56,14 @@ func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv // convert two 20ms to one 40ms buf = opus.JoinFrames(buf, pkt.Payload) p.Send += len(buf) - _ = p.client.WriteAudio(miss.CodecOPUS, buf) + _ = p.client.WriteAudio(codecOPUS, buf) buf = nil } } } else { sender.Handler = func(pkt *rtp.Packet) { p.Send += len(pkt.Payload) - _ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload) + _ = p.client.WriteAudio(codecOPUS, pkt.Payload) } } } diff --git a/pkg/xiaomi/miss/client.go b/pkg/xiaomi/miss/client.go index 560f9f49..6eaa06cf 100644 --- a/pkg/xiaomi/miss/client.go +++ b/pkg/xiaomi/miss/client.go @@ -2,96 +2,83 @@ package miss import ( "bytes" - "crypto/rand" "encoding/binary" "encoding/hex" + "errors" "fmt" "net" "net/url" "time" - "github.com/AlexxIT/go2rtc/pkg/xiaomi/cs2" - "github.com/AlexxIT/go2rtc/pkg/xiaomi/tutk" - "golang.org/x/crypto/chacha20" - "golang.org/x/crypto/nacl/box" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss/cs2" ) -func Dial(rawURL string) (*Client, error) { - u, err := url.Parse(rawURL) - if err != nil { - return nil, err - } - - query := u.Query() - - c := &Client{} - - c.key, err = calcSharedKey(query.Get("device_public"), query.Get("client_private")) - if err != nil { - return nil, err - } - - switch s := query.Get("vendor"); s { - case "cs2": - c.conn, err = cs2.Dial(u.Host, query.Get("transport")) - case "tutk": - c.conn, err = tutk.Dial(u.Host, query.Get("uid"), query.Get("model")) - default: - return nil, fmt.Errorf("miss: unsupported vendor %s", s) - } - - if err != nil { - return nil, err - } - - err = c.login(query.Get("client_public"), query.Get("sign")) - if err != nil { - _ = c.conn.Close() - return nil, err - } - - return c, nil -} - const ( - CodecH264 = 4 - CodecH265 = 5 - CodecPCM = 1024 - CodecPCMU = 1026 - CodecPCMA = 1027 - CodecOPUS = 1032 + codecH264 = 4 + codecH265 = 5 + codecPCM = 1024 + codecPCMU = 1026 + codecPCMA = 1027 + codecOPUS = 1032 ) type Conn interface { Protocol() string - ReadCommand() (cmd uint16, data []byte, err error) - WriteCommand(cmd uint16, data []byte) error - ReadPacket() ([]byte, error) - WritePacket(data []byte) error + Version() string + ReadCommand() (cmd uint32, data []byte, err error) + WriteCommand(cmd uint32, data []byte) error + ReadPacket() (hdr, payload []byte, err error) + WritePacket(hdr, payload []byte) error RemoteAddr() net.Addr SetDeadline(t time.Time) error Close() error } +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + // 1. Check if we can create shared key. + query := u.Query() + key, err := crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private")) + if err != nil { + return nil, err + } + + model := query.Get("model") + + // 2. Check if this vendor supported. + var conn Conn + switch s := query.Get("vendor"); s { + case "cs2": + conn, err = cs2.Dial(u.Host, query.Get("transport")) + case "tutk": + conn, err = tutk.Dial(u.Host, query.Get("uid"), "Miss", "client") + default: + err = fmt.Errorf("miss: unsupported vendor %s", s) + } + + if err != nil { + return nil, err + } + + err = login(conn, query.Get("client_public"), query.Get("sign")) + if err != nil { + _ = conn.Close() + return nil, err + } + + return &Client{Conn: conn, key: key, model: model}, nil +} + type Client struct { - conn Conn - key []byte -} - -func (c *Client) Protocol() string { - return c.conn.Protocol() -} - -func (c *Client) RemoteAddr() net.Addr { - return c.conn.RemoteAddr() -} - -func (c *Client) SetDeadline(t time.Time) error { - return c.conn.SetDeadline(t) -} - -func (c *Client) Close() error { - return c.conn.Close() + Conn + key []byte + model string } const ( @@ -117,13 +104,13 @@ const ( cmdEncoded = 0x1001 ) -func (c *Client) login(clientPublic, sign string) error { +func login(conn Conn, clientPublic, sign string) error { s := fmt.Sprintf(`{"public_key":"%s","sign":"%s","uuid":"","support_encrypt":0}`, clientPublic, sign) - if err := c.conn.WriteCommand(cmdAuthReq, []byte(s)); err != nil { + if err := conn.WriteCommand(cmdAuthReq, []byte(s)); err != nil { return err } - _, data, err := c.conn.ReadCommand() + _, data, err := conn.ReadCommand() if err != nil { return err } @@ -135,129 +122,148 @@ func (c *Client) login(clientPublic, sign string) error { return nil } +func (c *Client) Version() string { + return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model) +} + func (c *Client) WriteCommand(data []byte) error { - data, err := encode(c.key, data) + data, err := crypto.Encode(data, c.key) if err != nil { return err } - return c.conn.WriteCommand(cmdEncoded, data) + return c.Conn.WriteCommand(cmdEncoded, data) } -func (c *Client) VideoStart(channel, quality, audio uint8) error { +const ( + ModelDafang = "isa.camera.df3" + ModelLoockV2 = "loock.cateye.v02" + ModelC200 = "chuangmi.camera.046c04" + ModelC300 = "chuangmi.camera.72ac1" +) + +func (c *Client) StartMedia(channel, quality, audio string) error { + switch c.model { + case ModelDafang: + var q, a byte + if quality == "sd" { + q = 1 // 0 - hd, 1 - sd, default - hd + } + if audio != "0" { + a = 1 // 0 - off, 1 - on, default - on + } + + return errors.Join( + c.WriteCommand(dafangVideoQuality(q)), + c.WriteCommand(dafangVideoStart(1, a)), + ) + } + + // 0 - auto, 1 - sd, 2 - hd, default - hd + switch quality { + case "", "hd": + // Some models have broken codec settings in quality 3. + // Some models have low quality in quality 2. + // Different models require different default quality settings. + switch c.model { + case ModelC200, ModelC300: + quality = "3" + default: + quality = "2" + } + case "sd": + quality = "1" + case "auto": + quality = "0" + } + + if audio == "" { + audio = "1" + } + data := binary.BigEndian.AppendUint32(nil, cmdVideoStart) - if channel == 0 { - data = fmt.Appendf(data, `{"videoquality":%d,"enableaudio":%d}`, quality, audio) + if channel == "" { + data = fmt.Appendf(data, `{"videoquality":%s,"enableaudio":%s}`, quality, audio) } else { - data = fmt.Appendf(data, `{"videoquality":-1,"videoquality2":%d,"enableaudio":%d}`, quality, audio) + data = fmt.Appendf(data, `{"videoquality":-1,"videoquality2":%s,"enableaudio":%s}`, quality, audio) } return c.WriteCommand(data) } -func (c *Client) AudioStart() error { +func (c *Client) StopMedia() error { + data := binary.BigEndian.AppendUint32(nil, cmdVideoStop) + return c.WriteCommand(data) +} + +func (c *Client) StartAudio() error { data := binary.BigEndian.AppendUint32(nil, cmdAudioStart) return c.WriteCommand(data) } -func (c *Client) SpeakerStart() error { +func (c *Client) StartSpeaker() error { data := binary.BigEndian.AppendUint32(nil, cmdSpeakerStartReq) return c.WriteCommand(data) } +// SpeakerCodec if the camera model has a non-standard two-way codec. +func (c *Client) SpeakerCodec() uint32 { + switch c.model { + case ModelDafang, "isa.camera.hlc6": + return codecPCM + case "chuangmi.camera.72ac1": + return codecOPUS + } + return 0 +} + +const hdrSize = 32 + func (c *Client) ReadPacket() (*Packet, error) { - data, err := c.conn.ReadPacket() + hdr, payload, err := c.Conn.ReadPacket() if err != nil { return nil, fmt.Errorf("miss: read media: %w", err) } - return unmarshalPacket(c.key, data) -} -func unmarshalPacket(key, b []byte) (*Packet, error) { - n := uint32(len(b)) - - if n < 32 { + if len(hdr) < hdrSize { return nil, fmt.Errorf("miss: packet header too small") } - if l := binary.LittleEndian.Uint32(b); l+32 != n { - return nil, fmt.Errorf("miss: packet payload has wrong length") - } - - payload, err := decode(key, b[32:]) + payload, err = crypto.Decode(payload, c.key) if err != nil { return nil, err } - return &Packet{ - CodecID: binary.LittleEndian.Uint32(b[4:]), - Sequence: binary.LittleEndian.Uint32(b[8:]), - Flags: binary.LittleEndian.Uint32(b[12:]), - Timestamp: binary.LittleEndian.Uint64(b[16:]), - Payload: payload, - }, nil + pkt := &Packet{ + CodecID: binary.LittleEndian.Uint32(hdr[4:]), + Sequence: binary.LittleEndian.Uint32(hdr[8:]), + Flags: binary.LittleEndian.Uint32(hdr[12:]), + Payload: payload, + } + + switch c.model { + case ModelDafang, ModelLoockV2: + // Dafang has ts in sec + // LoockV2 has ts in msec for video, but zero ts for audio + pkt.Timestamp = uint64(time.Now().UnixMilli()) + default: + pkt.Timestamp = binary.LittleEndian.Uint64(hdr[16:]) + } + + return pkt, nil } func (c *Client) WriteAudio(codecID uint32, payload []byte) error { - payload, err := encode(c.key, payload) // new payload will have new size! + payload, err := crypto.Encode(payload, c.key) // new payload will have new size! if err != nil { return err } - const hdrSize = 32 n := uint32(len(payload)) - data := make([]byte, hdrSize+n) - binary.LittleEndian.PutUint32(data, n) - binary.LittleEndian.PutUint32(data[4:], codecID) - binary.LittleEndian.PutUint64(data[16:], uint64(time.Now().UnixMilli())) // not really necessary - copy(data[hdrSize:], payload) - return c.conn.WritePacket(data) -} - -func calcSharedKey(devicePublic, clientPrivate string) ([]byte, error) { - var sharedKey, publicKey, privateKey [32]byte - if _, err := hex.Decode(publicKey[:], []byte(devicePublic)); err != nil { - return nil, err - } - if _, err := hex.Decode(privateKey[:], []byte(clientPrivate)); err != nil { - return nil, err - } - box.Precompute(&sharedKey, &publicKey, &privateKey) - return sharedKey[:], nil -} - -func encode(key, src []byte) ([]byte, error) { - dst := make([]byte, len(src)+8) - - if _, err := rand.Read(dst[:8]); err != nil { - return nil, err - } - - nonce := make([]byte, 12) - copy(nonce[4:], dst[:8]) - - c, err := chacha20.NewUnauthenticatedCipher(key, nonce) - if err != nil { - return nil, err - } - - c.XORKeyStream(dst[8:], src) - - return dst, nil -} - -func decode(key, src []byte) ([]byte, error) { - nonce := make([]byte, 12) - copy(nonce[4:], src[:8]) - - c, err := chacha20.NewUnauthenticatedCipher(key, nonce) - if err != nil { - return nil, err - } - - dst := make([]byte, len(src)-8) - c.XORKeyStream(dst, src[8:]) - - return dst, nil + header := make([]byte, hdrSize) + binary.LittleEndian.PutUint32(header, n) + binary.LittleEndian.PutUint32(header[4:], codecID) + binary.LittleEndian.PutUint64(header[16:], uint64(time.Now().UnixMilli())) // not really necessary + return c.Conn.WritePacket(header, payload) } type Packet struct { @@ -271,10 +277,40 @@ type Packet struct { Payload []byte } -func GenerateKey() ([]byte, []byte, error) { - public, private, err := box.GenerateKey(rand.Reader) - if err != nil { - return nil, nil, err - } - return public[:], private[:], err +func dafangRaw(cmd uint32, args ...byte) []byte { + payload := tutk.ICAM(cmd, args...) + + data := make([]byte, 4+len(payload)*2) + copy(data, "\x7f\xff\xff\xff") + hex.Encode(data[4:], payload) + return data } + +// DafangVideoQuality 0 - hd, 1 - sd +func dafangVideoQuality(quality uint8) []byte { + return dafangRaw(0xff07d5, quality) +} + +func dafangVideoStart(video, audio uint8) []byte { + return dafangRaw(0xff07d8, video, audio) +} + +//func dafangLeft() []byte { +// return dafangRaw(0xff2404, 2, 0, 5) +//} +// +//func dafangRight() []byte { +// return dafangRaw(0xff2404, 1, 0, 5) +//} +// +//func dafangUp() []byte { +// return dafangRaw(0xff2404, 0, 2, 5) +//} +// +//func dafangDown() []byte { +// return dafangRaw(0xff2404, 0, 1, 5) +//} +// +//func dafangStop() []byte { +// return dafangRaw(0xff2404, 0, 0, 5) +//} diff --git a/pkg/xiaomi/cs2/conn.go b/pkg/xiaomi/miss/cs2/conn.go similarity index 93% rename from pkg/xiaomi/cs2/conn.go rename to pkg/xiaomi/miss/cs2/conn.go index 198b3beb..670d2b91 100644 --- a/pkg/xiaomi/cs2/conn.go +++ b/pkg/xiaomi/miss/cs2/conn.go @@ -201,6 +201,10 @@ func (c *Conn) Protocol() string { return "cs2+udp" } +func (c *Conn) Version() string { + return "CS2" +} + func (c *Conn) RemoteAddr() net.Addr { return c.conn.RemoteAddr() } @@ -220,21 +224,19 @@ func (c *Conn) Error() error { return io.EOF } -func (c *Conn) ReadCommand() (cmd uint16, data []byte, err error) { +func (c *Conn) ReadCommand() (cmd uint32, data []byte, err error) { buf, ok := <-c.rawCh0 if !ok { return 0, nil, c.Error() } - cmd = binary.LittleEndian.Uint16(buf[:2]) - data = buf[4:] - return + return binary.LittleEndian.Uint32(buf), buf[4:], nil } -func (c *Conn) WriteCommand(cmd uint16, data []byte) error { +func (c *Conn) WriteCommand(cmd uint32, data []byte) error { c.cmdMu.Lock() defer c.cmdMu.Unlock() - req := marshalCmd(0, c.seqCh0, uint32(cmd), data) + req := marshalCmd(0, c.seqCh0, cmd, data) c.seqCh0++ if c.isTCP { @@ -268,18 +270,18 @@ func (c *Conn) WriteCommand(cmd uint16, data []byte) error { } } -func (c *Conn) ReadPacket() ([]byte, error) { +func (c *Conn) ReadPacket() (hdr, payload []byte, err error) { data, ok := <-c.rawCh2 if !ok { - return nil, c.Error() + return nil, nil, c.Error() } - return data, nil + return data[:32], data[32:], nil } -func (c *Conn) WritePacket(data []byte) error { +func (c *Conn) WritePacket(hdr, payload []byte) error { const offset = 12 - n := uint32(len(data)) + n := 32 + uint32(len(payload)) req := make([]byte, n+offset) req[0] = magic req[1] = msgDrw @@ -290,7 +292,8 @@ func (c *Conn) WritePacket(data []byte) error { binary.BigEndian.PutUint16(req[6:], c.seqCh3) c.seqCh3++ binary.BigEndian.PutUint32(req[8:], n) - copy(req[offset:], data) + copy(req[offset:], hdr) + copy(req[offset+32:], payload) _, err := c.conn.Write(req) return err diff --git a/pkg/xiaomi/miss/producer.go b/pkg/xiaomi/miss/producer.go new file mode 100644 index 00000000..eeaa4969 --- /dev/null +++ b/pkg/xiaomi/miss/producer.go @@ -0,0 +1,204 @@ +package miss + +import ( + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + client *Client +} + +func Dial(rawURL string) (core.Producer, error) { + client, err := NewClient(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + err = client.StartMedia(query.Get("channel"), query.Get("subtype"), query.Get("audio")) + if err != nil { + _ = client.Close() + return nil, err + } + + medias, err := probe(client, query.Get("audio") != "0") + if err != nil { + _ = client.Close() + return nil, err + } + + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "xiaomi/miss", + Protocol: client.Protocol(), + RemoteAddr: client.RemoteAddr().String(), + UserAgent: client.Version(), + Medias: medias, + Transport: client, + }, + client: client, + }, nil +} + +func probe(client *Client, audio bool) ([]*core.Media, error) { + _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + + var vcodec, acodec *core.Codec + + for { + pkt, err := client.ReadPacket() + if err != nil { + if vcodec != nil { + err = fmt.Errorf("no audio") + } else if acodec != nil { + err = fmt.Errorf("no video") + } + return nil, fmt.Errorf("xiaomi: probe: %w", err) + } + + switch pkt.CodecID { + case codecH264: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h264.NALUType(buf) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(buf) + } + } + case codecH265: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h265.NALUType(buf) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(buf) + } + } + case codecPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} + } + case codecOPUS: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + } + } + + if vcodec != nil && (acodec != nil || !audio) { + break + } + } + + _ = client.SetDeadline(time.Time{}) + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + } + + if acodec != nil { + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }) + + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{acodec.Clone()}, + }) + } + + return medias, nil +} + +const timestamp40ms = 48000 * 0.040 + +func (p *Producer) Start() error { + var audioTS uint32 + + for { + _ = p.client.SetDeadline(time.Now().Add(10 * time.Second)) + pkt, err := p.client.ReadPacket() + if err != nil { + return err + } + + p.Recv += len(pkt.Payload) + + // TODO: rewrite this + var name string + var pkt2 *core.Packet + + switch pkt.CodecID { + case codecH264, codecH265: + pkt2 = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: uint16(pkt.Sequence), + Timestamp: TimeToRTP(pkt.Timestamp, 90000), + }, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + if pkt.CodecID == codecH264 { + name = core.CodecH264 + } else { + name = core.CodecH265 + } + case codecPCMA: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + audioTS += uint32(len(pkt.Payload)) + case codecOPUS: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + // known cameras sends packets with 40ms long + audioTS += timestamp40ms + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +func (p *Producer) Stop() error { + _ = p.client.StopMedia() + return p.Connection.Stop() +} + +// TimeToRTP convert time in milliseconds to RTP time +func TimeToRTP(timeMS, clockRate uint64) uint32 { + return uint32(timeMS * clockRate / 1000) +} diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go index 805a3f86..d0290f2f 100644 --- a/pkg/xiaomi/producer.go +++ b/pkg/xiaomi/producer.go @@ -1,229 +1,23 @@ package xiaomi import ( - "fmt" - "net/url" - "time" + "strings" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/h264" - "github.com/AlexxIT/go2rtc/pkg/h264/annexb" - "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/legacy" "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" - "github.com/pion/rtp" ) -type Producer struct { - core.Connection - client *miss.Client - model string -} - func Dial(rawURL string) (core.Producer, error) { - client, err := miss.Dial(rawURL) - if err != nil { - return nil, err + // Format: xiaomi/miss + if strings.Contains(rawURL, "vendor") { + return miss.Dial(rawURL) } - u, _ := url.Parse(rawURL) - query := u.Query() - - // 0 - main, 1 - second - channel := core.ParseByte(query.Get("channel")) - - // 0 - auto, 1 - worst, 3 or 5 - best - var quality byte - switch s := query.Get("subtype"); s { - case "", "hd": - quality = 3 - case "sd": - quality = 1 - case "auto": - quality = 0 - default: - quality = core.ParseByte(s) - } - - // 0 - disabled, 1 - enabled, 2 - enabled (another API) - var audio byte - switch s := query.Get("audio"); s { - case "", "1": - audio = 1 - default: - audio = core.ParseByte(s) - } - - medias, err := probe(client, channel, quality, audio) - if err != nil { - _ = client.Close() - return nil, err - } - - return &Producer{ - Connection: core.Connection{ - ID: core.NewID(), - FormatName: "xiaomi", - Protocol: client.Protocol(), - RemoteAddr: client.RemoteAddr().String(), - Source: rawURL, - Medias: medias, - Transport: client, - }, - client: client, - model: query.Get("model"), - }, nil + // Format: xiaomi/legacy + return legacy.Dial(rawURL) } -func probe(client *miss.Client, channel, quality, audio uint8) ([]*core.Media, error) { - _ = client.SetDeadline(time.Now().Add(10 * time.Second)) - - if err := client.VideoStart(channel, quality, audio&1); err != nil { - return nil, err - } - - if audio > 1 { - _ = client.AudioStart() - } - - var vcodec, acodec *core.Codec - - for { - pkt, err := client.ReadPacket() - if err != nil { - return nil, fmt.Errorf("xiaomi: probe: %w", err) - } - - switch pkt.CodecID { - case miss.CodecH264: - if vcodec == nil { - buf := annexb.EncodeToAVCC(pkt.Payload) - if h264.NALUType(buf) == h264.NALUTypeSPS { - vcodec = h264.AVCCToCodec(buf) - } - } - case miss.CodecH265: - if vcodec == nil { - buf := annexb.EncodeToAVCC(pkt.Payload) - if h265.NALUType(buf) == h265.NALUTypeVPS { - vcodec = h265.AVCCToCodec(buf) - } - } - case miss.CodecPCMA: - if acodec == nil { - acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} - } - case miss.CodecOPUS: - if acodec == nil { - acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} - } - } - - if vcodec != nil && (acodec != nil || audio == 0) { - break - } - } - - _ = client.SetDeadline(time.Time{}) - - medias := []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{vcodec}, - }, - } - - if acodec != nil { - medias = append(medias, &core.Media{ - Kind: core.KindAudio, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{acodec}, - }) - - medias = append(medias, &core.Media{ - Kind: core.KindAudio, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{acodec.Clone()}, - }) - } - - return medias, nil -} - -const timestamp40ms = 48000 * 0.040 - -func (p *Producer) Start() error { - var audioTS uint32 - - for { - _ = p.client.SetDeadline(time.Now().Add(10 * time.Second)) - pkt, err := p.client.ReadPacket() - if err != nil { - return err - } - - p.Recv += len(pkt.Payload) - - // TODO: rewrite this - var name string - var pkt2 *core.Packet - - switch pkt.CodecID { - case miss.CodecH264: - name = core.CodecH264 - pkt2 = &core.Packet{ - Header: rtp.Header{ - SequenceNumber: uint16(pkt.Sequence), - Timestamp: TimeToRTP(pkt.Timestamp, 90000), - }, - Payload: annexb.EncodeToAVCC(pkt.Payload), - } - case miss.CodecH265: - name = core.CodecH265 - pkt2 = &core.Packet{ - Header: rtp.Header{ - SequenceNumber: uint16(pkt.Sequence), - Timestamp: TimeToRTP(pkt.Timestamp, 90000), - }, - Payload: annexb.EncodeToAVCC(pkt.Payload), - } - case miss.CodecPCMA: - name = core.CodecPCMA - pkt2 = &core.Packet{ - Header: rtp.Header{ - Version: 2, - Marker: true, - SequenceNumber: uint16(pkt.Sequence), - Timestamp: audioTS, - }, - Payload: pkt.Payload, - } - audioTS += uint32(len(pkt.Payload)) - case miss.CodecOPUS: - name = core.CodecOpus - pkt2 = &core.Packet{ - Header: rtp.Header{ - Version: 2, - Marker: true, - SequenceNumber: uint16(pkt.Sequence), - Timestamp: audioTS, - }, - Payload: pkt.Payload, - } - // known cameras sends packets with 40ms long - audioTS += timestamp40ms - } - - for _, recv := range p.Receivers { - if recv.Codec.Name == name { - recv.WriteRTP(pkt2) - break - } - } - } -} - -// TimeToRTP convert time in milliseconds to RTP time -func TimeToRTP(timeMS, clockRate uint64) uint32 { - return uint32(timeMS * clockRate / 1000) +func IsLegacy(model string) bool { + return legacy.Supported(model) } diff --git a/pkg/xiaomi/tutk/README.md b/pkg/xiaomi/tutk/README.md deleted file mode 100644 index 95773594..00000000 --- a/pkg/xiaomi/tutk/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# TUTK - -The most terrible protocol I have ever had to work with. - -## Messages - -Ping from camera (24b). The shortest message. - -``` -off sample -0 0402 tutk magic -2 190a tutk version (120a, 190a...) -4 0800 msg size = len(b)-16 = 24-16 -6 0000 channel seq (always 0 for ping) -8 2804 msg type (2804 - ping from camera, 0804 - usual msg from camera) -10 1200 direction (12 - from camera, 21 - from client) -12 00000000 fixed -16 7ecc93c4 random -20 56c2561f random -``` - -Usual msg from camera (52b + msg data). - -``` -off sample -12 e6e8 same bytes b[20:22] -14 0000 channel (0, 1, 5) -16 0c00 fixed -18 0000 fixed -20 e6e839da random session id -24 66b0dc14 random session id -28 0070 command -30 0b00 version -32 0100 command seq -34 0000 ??? -36 00000000 ??? -40 00000000 ??? -44 e300 msg data size -46 0000 ??? -48 8f15a02f random msg id -52 ... msg data -``` - -Message with media from camera. - -``` -off sample -28 0c00 command -30 0b00 version -32 7700 command seq -34 0000 ??? data only for last message per pack (14/14) -36 0200 pack seq, don't know how packs used -38 0914 09/14 - message seq/messages per packs -40 01000000 fixed -42 0500 command 2 -44 3200 command 2 seq -46 4f00 chunks count per this frame -48 1b00 chunk seq, starts from 0 (wrong for last chunk) -50 0004 frame data size -52 c8f6 random msg id -54 01000000 previous frame seq, starts from 0 -58 02000000 current frame seq, starts from 1 -``` diff --git a/pkg/xiaomi/tutk/conn.go b/pkg/xiaomi/tutk/conn.go deleted file mode 100644 index 1b0ea56e..00000000 --- a/pkg/xiaomi/tutk/conn.go +++ /dev/null @@ -1,262 +0,0 @@ -package tutk - -import ( - "bytes" - "crypto/rand" - "encoding/binary" - "fmt" - "io" - "net" - "sync" - "sync/atomic" - "time" -) - -func Dial(host, uid, model string) (*Conn, error) { - conn, err := net.ListenUDP("udp", nil) - if err != nil { - return nil, err - } - - addr, err := net.ResolveUDPAddr("udp", host) - if err != nil { - addr = &net.UDPAddr{IP: net.ParseIP(host), Port: 32761} - } - - c := &Conn{conn: conn, addr: addr, sid: genSID()} - - if err = c.handshake([]byte(uid)); err != nil { - _ = c.Close() - return nil, err - } - - switch model { - case "isa.camera.df3": - c.msgCtrl = c.oldMsgCtrl - c.handleCh0 = c.oldHandlerCh0() - default: - c.msgCtrl = c.newMsgCtrl - c.handleCh0 = c.newHandlerCh0() - } - - c.rawCmd = make(chan []byte, 10) - c.rawPkt = make(chan []byte, 100) - - go c.worker() - - return c, nil -} - -type Conn struct { - conn *net.UDPConn - addr *net.UDPAddr - sid []byte - - err error - rawCmd chan []byte - rawPkt chan []byte - - cmdMu sync.Mutex - cmdAck func() - - seqSendCh0 uint16 - seqSendCh1 uint16 - - seqSendCmd1 uint16 - seqSendCmd2 uint16 - seqSendCnt uint16 - seqSendAud uint16 - - seqRecvPkt0 uint16 - seqRecvPkt1 uint16 - seqRecvCmd2 uint16 - - msgCtrl func(ctrlType uint16, ctrlData []byte) []byte - handleCh0 func(cmd []byte) int8 -} - -func (c *Conn) handshake(uid []byte) (err error) { - _ = c.SetDeadline(time.Now().Add(5 * time.Second)) - - if _, err = c.WriteAndWait( - c.msgConnectByUID(uid, 1), - func(_, res []byte) bool { - return bytes.Index(res, uid) == 16 // 02061200 - }, - ); err != nil { - return err - } - - if err = c.Write(c.msgConnectByUID(uid, 2)); err != nil { - return err - } - - if _, err = c.WriteAndWait( - c.msgAvClientStart(), - func(req, res []byte) bool { - return bytes.Index(res, req[48:52]) == 48 - }, - ); err != nil { - return err - } - - _ = c.SetDeadline(time.Time{}) - - return nil -} - -func (c *Conn) worker() { - defer func() { - close(c.rawCmd) - close(c.rawPkt) - }() - - buf := make([]byte, 1200) - - for { - n, _, err := c.ReadFromUDP(buf) - if err != nil { - c.err = fmt.Errorf("%s: %w", "tutk", err) - return - } - - if c.handleMsg(buf[:n]) <= 0 { - if c.err != nil { - return - } - fmt.Printf("tutk: unknown msg: %x\n", buf[:n]) - } - } -} - -func (c *Conn) Write(buf []byte) error { - //log.Printf("-> %x", buf) - _, err := c.conn.WriteToUDP(TransCodePartial(nil, buf), c.addr) - return err -} - -func (c *Conn) ReadFromUDP(buf []byte) (n int, addr *net.UDPAddr, err error) { - for { - if n, addr, err = c.conn.ReadFromUDP(buf); err != nil { - return 0, nil, err - } - - if string(addr.IP) != string(c.addr.IP) || n < 16 { - continue // skip messages from another IP - } - - ReverseTransCodePartial(buf, buf[:n]) - //log.Printf("<- %x", buf[:n]) - return n, addr, nil - } -} - -func (c *Conn) WriteAndWait(req []byte, ok func(req, res []byte) bool) ([]byte, error) { - var t *time.Timer - t = time.AfterFunc(1, func() { - if err := c.Write(req); err == nil && t != nil { - t.Reset(time.Second) - } - }) - defer t.Stop() - - buf := make([]byte, 1200) - - for { - n, addr, err := c.ReadFromUDP(buf) - if err != nil { - return nil, err - } - - if ok(req, buf[:n]) { - c.addr.Port = addr.Port - return buf[:n], nil - } - } -} - -func (c *Conn) Protocol() string { - return "tutk+udp" -} - -func (c *Conn) RemoteAddr() net.Addr { - return c.addr -} - -func (c *Conn) SetDeadline(t time.Time) error { - return c.conn.SetDeadline(t) -} - -func (c *Conn) Close() error { - return c.conn.Close() -} - -func (c *Conn) Error() error { - if c.err != nil { - return c.err - } - return io.EOF -} - -func (c *Conn) ReadCommand() (cmd uint16, data []byte, err error) { - buf, ok := <-c.rawCmd - if !ok { - return 0, nil, c.Error() - } - cmd = binary.LittleEndian.Uint16(buf[:2]) - data = buf[4:] - return -} - -// WriteCommand will send a command every second five times -func (c *Conn) WriteCommand(cmd uint16, data []byte) error { - c.cmdMu.Lock() - defer c.cmdMu.Unlock() - - var repeat atomic.Int32 - repeat.Store(5) - - timeout := time.NewTicker(time.Second) - defer timeout.Stop() - - c.cmdAck = func() { - repeat.Store(0) - timeout.Reset(1) - } - - msg := c.msgCtrl(cmd, data) - - for { - if err := c.WriteCh0(msg); err != nil { - return err - } - <-timeout.C - r := repeat.Add(-1) - if r < 0 { - return nil - } - if r == 0 { - return fmt.Errorf("%s: can't send command %d", "tutk", cmd) - } - } -} - -func (c *Conn) ReadPacket() ([]byte, error) { - buf, ok := <-c.rawPkt - if !ok { - return nil, c.Error() - } - return buf, nil -} - -func (c *Conn) WritePacket(data []byte) error { - return c.WriteCh1(c.oldMsgAud(data)) -} - -func genSID() []byte { - b := make([]byte, 16) - _, _ = rand.Read(b[8:]) - copy(b, b[8:10]) - b[4] = 0x0c - return b -} diff --git a/pkg/xiaomi/tutk/proto.go b/pkg/xiaomi/tutk/proto.go deleted file mode 100644 index a440900a..00000000 --- a/pkg/xiaomi/tutk/proto.go +++ /dev/null @@ -1,251 +0,0 @@ -package tutk - -import ( - "encoding/binary" - "time" -) - -func (c *Conn) WriteCh0(msg []byte) error { - binary.LittleEndian.PutUint16(msg[6:], c.seqSendCh0) - c.seqSendCh0++ - return c.Write(msg) -} - -func (c *Conn) WriteCh1(msg []byte) error { - binary.LittleEndian.PutUint16(msg[6:], c.seqSendCh1) - c.seqSendCh1++ - msg[14] = 1 // channel - return c.Write(msg) -} - -func (c *Conn) msgConnectByUID(uid []byte, i byte) []byte { - const size = 68 // or 52 or 68 or 88 - b := make([]byte, size) - copy(b, "\x04\x02\x19\x02") - b[4] = size - 16 - copy(b[8:], "\x01\x06\x21\x00") - copy(b[16:], uid) - copy(b[52:], "\x00\x03\x01\x02") // or 07000303 or 01010204 - copy(b[56:], c.sid[8:]) - b[64] = i // 1 or 2 - return b -} - -func (c *Conn) msgAvClientStart() []byte { - const size = 566 + 32 - msg := c.msg(size) - - cmd := msg[msgHhrSize:] - copy(cmd, "\x00\x00\x0b\x00") - binary.LittleEndian.PutUint16(cmd[16:], size-52) - //cmd[18] = 1 // ??? - binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) - - // important values for some cameras (not for df3) - data := cmd[cmdHdrSize:] - copy(data, "Miss") - copy(data[257:], "client") - - // 0100000004000000fb071f000000000000000000000003000000000001000000 - cfg := msg[566:] - cfg[0] = 0 // 0 - simple proto, 1 - complex proto with "0Cxx" commands - cfg[4] = 4 - copy(cfg[8:], "\xfb\x07\x1f\x00") - cfg[22] = 3 - cfg[28] = 1 - return msg -} - -func (c *Conn) msg(size uint16) []byte { - b := make([]byte, size) - copy(b, "\x04\x02\x19\x0a") - binary.LittleEndian.PutUint16(b[4:], size-16) - copy(b[8:], "\x07\x04\x21\x00") - copy(b[12:], c.sid) - return b -} - -const ( - msgPing = iota + 1 - msgClientStart00 - msgClientStart20 - msgCommand - msgCounters - msgMediaChunk - msgMediaFrame - msgMediaLost - msgCh5 - msgUnknown0007 - msgUnknown0008 - msgUnknown0010 - msgUnknown0013 - msgUnknown0a08 - msgDafang0012 - msgDafang0071 -) - -// handleMsg will return parsed msg type or zero -func (c *Conn) handleMsg(msg []byte) int8 { - //log.Printf("<- %x", msg) - // off sample - // 0 0402 tutk magic - // 2 120a tutk version (120a, 190a...) - // 4 0800 msg size = len(b)-16 - // 6 0000 channel seq - // 8 28041200 msg type - // 14 0100 channel (not all msg) - // 28 0700 msg data (not all msg) - switch msg[8] { - case 0x28: - _ = c.Write(msgAckPing(msg)) - return msgPing - case 0x08: - switch ch := msg[14]; ch { - case 0: - return c.handleCh0(msg[28:]) - case 1: - return c.handleCh1(msg[28:]) - case 5: - return c.handleCh5(msg) - } - } - return 0 -} - -func (c *Conn) handleCh1(cmd []byte) int8 { - // Channel 1 used for two-way audio. It's important: - // - answer on 0000 command with exact config response (can't set simple proto) - // - send 0012 command at start - // - respond on every 0008 command for smooth playback - switch cid := string(cmd[:2]); cid { - case "\x00\x00": // client start - _ = c.WriteCh1(c.msgAck0000(cmd)) - _ = c.WriteCh1(c.msg0012()) - return msgClientStart00 - case "\x00\x07": // time sync without data - _ = c.WriteCh1(c.msgAck0007(cmd)) - return msgUnknown0007 - case "\x00\x08": // time sync with data - _ = c.WriteCh1(c.msgAck0008(cmd)) - return msgUnknown0008 - case "\x00\x13": // ack for 0012 - return msgUnknown0013 - case "\x00\x20": // client start2 - //_ = c.WriteCh1(c.msgAck0020(cmd)) - return msgClientStart20 - case "\x09\x00": // counters sync - return msgCounters - case "\x0a\x08": // unknown - _ = c.WriteCh1(c.msgAck0A08(cmd)) - return msgUnknown0a08 - } - return 0 -} - -func (c *Conn) handleCh5(msg []byte) int8 { - if len(msg) != 48 { - return 0 - } - - // <- [48] 0402190a 20000400 07042100 7ecc05000c0000007ecc93c456c2561f 5a97c2f101050000000000000000000000010000 - // -> [48] 0402190a 20000400 08041200 7ecc05000c0000007ecc93c456c2561f 5a97c2f141050000000000000000000000010000 - copy(msg[8:], "\x07\x04\x21\x00") - msg[32] = 0x41 - _ = c.Write(msg) - return msgCh5 -} - -const msgHhrSize = 28 -const cmdHdrSize = 24 - -func (c *Conn) msgAck0000(msg28 []byte) []byte { - // <- 000008000000000000000000000000001a0200004f47c714 ... 00000000000000000100000004000000fb071f00000000000000000000000300 - // -> 00140b00000000000000000000000000200000004f47c714 00000000000000000100000004000000fb071f00000000000000000000000300 - const cmdDataSize = 32 - msg := c.msg(msgHhrSize + cmdHdrSize + cmdDataSize) - - cmd := msg[msgHhrSize:] - copy(cmd, "\x00\x14\x0b\x00") - cmd[16] = cmdDataSize - copy(cmd[20:], msg28[20:24]) // request id (random) - - // Important to answer with same data. - data := cmd[cmdHdrSize:] - copy(data, msg28[len(msg28)-32:]) - return msg -} - -//func (c *Conn) msgAck0020(msg28 []byte) []byte { -// const cmdDataSize = 36 -// -// msg := c.msg(msgHhrSize + cmdHdrSize + cmdDataSize) -// -// cmd := msg[msgHhrSize:] -// copy(cmd, "\x00\x14\x0b\x00") -// cmd[16] = cmdDataSize -// copy(cmd[20:], msg28[20:24]) // request id (random) -// -// data := cmd[cmdHdrSize:] -// data[5] = 1 -// data[7] = 1 -// data[8] = 1 -// data[12] = 4 -// copy(data[16:], "\xfb\x07\x1f\x00") -// data[30] = 3 -// data[32] = 1 -// return msg -//} - -func (c *Conn) msg0012() []byte { - // -> 00120b000000000000000000000000000c00000000000000020000000100000001000000 - const dataSize = 12 - msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) - cmd := msg[msgHhrSize:] - - copy(cmd, "\x00\x12\x0b\x00") - cmd[16] = dataSize - data := cmd[cmdHdrSize:] - - data[0] = 2 - data[4] = 1 - data[9] = 1 - return msg -} - -func (c *Conn) msgAck0007(msg28 []byte) []byte { - // <- 000708000000000000000000000000000c00000001000000000000001c551f7a00000000 - // -> 010a0b00000000000000000000000000000000000100000000000000 - msg := c.msg(msgHhrSize + 28) - cmd := msg[msgHhrSize:] - copy(cmd, "\x01\x0a\x0b\x00") - cmd[20] = 1 - return msg -} - -func (c *Conn) msgAck0008(msg28 []byte) []byte { - // <- 000808000000000000000000000000000000f9f0010000000200000050f31f7a - // -> 01090b0000000000000000000000000000000000010000000200000050f31f7a - msg := c.msg(msgHhrSize + 28) - cmd := msg[msgHhrSize:] - copy(cmd, "\x01\x09\x0b\x00") - copy(cmd[20:], msg28[20:]) - return msg -} - -func (c *Conn) msgAck0A08(msg28 []byte) []byte { - // <- 0a080b005b0000000b51590002000000 - // -> 0b000b00000001000b5103000300000000000000 - msg := c.msg(msgHhrSize + 20) - cmd := msg[msgHhrSize:] - copy(cmd, "\x0b\x00\x0b\x00") - copy(cmd[8:], msg28[8:10]) - return msg -} - -func msgAckPing(req []byte) []byte { - // <- [24] 0402120a 08000000 28041200 000000005b0d4202070aa8c0 - // -> [24] 04021a0a 08000000 27042100 000000005b0d4202070aa8c0 - req[8] = 0x27 - req[10] = 0x21 - return req -} diff --git a/pkg/xiaomi/tutk/proto_new.go b/pkg/xiaomi/tutk/proto_new.go deleted file mode 100644 index e7a08080..00000000 --- a/pkg/xiaomi/tutk/proto_new.go +++ /dev/null @@ -1,191 +0,0 @@ -package tutk - -import ( - "encoding/binary" - "fmt" - "time" -) - -func (c *Conn) newMsgCtrl(ctrlType uint16, ctrlData []byte) []byte { - size := msgHhrSize + 28 + 4 + uint16(len(ctrlData)) - msg := c.msg(size) - - // 0 0070 command - // 2 0b00 version - // 4 1000 seq - // 6 0076 ??? - cmd := msg[msgHhrSize:] - copy(cmd, "\x00\x70\x0b\x00") - binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) - c.seqSendCmd1++ - - // 8 0070 command (second time) - // 10 0300 seq - // 12 0100 chunks count - // 14 0000 chunk seq (starts from 0) - // 16 5500 size - // 18 0000 random msg id (always 0) - // 20 03000000 seq (second time) - // 24 00000000 - // 28 01010000 ctrlType - cmd[9] = 0x70 - cmd[12] = 1 - binary.LittleEndian.PutUint16(cmd[16:], size-52) - - binary.LittleEndian.PutUint16(cmd[10:], c.seqSendCmd2) - binary.LittleEndian.PutUint16(cmd[20:], c.seqSendCmd2) - c.seqSendCmd2++ - - data := cmd[28:] - binary.LittleEndian.PutUint16(data, ctrlType) - copy(data[4:], ctrlData) - return msg -} - -func (c *Conn) newHandlerCh0() func(msg []byte) int8 { - var waitData []byte - var waitSeq uint16 - - return func(cmd []byte) int8 { - switch cmd[0] { - case 0x07, 0x05: - flag := cmd[1] - - var cmd2 []byte - if flag&0b1000 == 0 { - // off sample - // 0 0700 command - // 2 0b00 version - // 4 2700 seq - // 6 0000 ??? - // 8 0700 command (second time) - // 10 1400 seq - // 12 1300 chunks count per this frame - // 14 1100 chunk seq, starts from 0 (0x20 for last chunk) - // 16 0004 frame data size - // 18 0000 random msg id (always 0) - // 20 02000000 previous frame seq, starts from 0 - // 24 03000000 current frame seq, starts from 1 - cmd2 = cmd[8:] - } else { - // off sample - // 0 070d0b00 - // 4 30000000 - // 8 5c965500 ??? - // 12 ffff0000 ??? - // 16 0701 fixed command - // 18 190001002000a802000006000000070000000 - cmd2 = cmd[16:] - } - - seq := binary.LittleEndian.Uint16(cmd2[2:]) - - // Check if this is first chunk for frame. - // Handle protocol bug "0x20 chunk seq for last chunk" and sometimes - // "0x20 chunk seq for first chunk if only one chunk". - if binary.LittleEndian.Uint16(cmd2[6:]) == 0 || binary.LittleEndian.Uint16(cmd2[4:]) == 1 { - waitData = waitData[:0] - waitSeq = seq - } else if seq != waitSeq { - return msgMediaLost - } - - if flag&0b0001 == 0 { - waitData = append(waitData, cmd2[20:]...) - waitSeq++ - return msgMediaChunk - } - - c.seqRecvPkt1 = seq - _ = c.WriteCh0(c.msgAckCounters()) - - data := cmd2[20:] - n := len(data) - 32 - waitData = append(waitData, data[:n]...) - - packetData := make([]byte, 32+len(waitData)) - copy(packetData, data[n:]) - copy(packetData[32:], waitData) - - select { - case c.rawPkt <- packetData: - default: - c.err = fmt.Errorf("%s: media queue is full", "tutk") - return -1 - } - return msgMediaFrame - - case 0x00: - _ = c.WriteCh0(c.msgAckCounters()) - c.seqRecvCmd2 = binary.LittleEndian.Uint16(cmd[2:]) - - switch cmd[1] { - case 0x10: - return msgUnknown0010 // unknown - case 0x70: - select { - case c.rawCmd <- cmd[28:]: - default: - } - return msgCommand // cmd from camera - } - - case 0x09: - // off sample - // 0 09000b00 cmd1 - // 4 0d000000 seqCmd1 - // 12 0000 seqRecvCmd2 - seq := binary.LittleEndian.Uint16(cmd[12:]) - if c.seqSendCmd1 > seq { - if c.cmdAck != nil { - c.cmdAck() - } - } - return msgCounters - - case 0x0a: - // seq sample - // 0 0a080b00 - // 4 03000000 - // 8 e2043200 - // 12 01000000 - _ = c.WriteCh0(c.msgAck0A08(cmd)) - return msgUnknown0a08 - } - - return 0 - } -} - -func (c *Conn) msgAckCounters() []byte { - msg := c.msg(msgHhrSize + cmdHdrSize) - - // off sample - // 0 09000b00 cmd1 - // 4 2700 seqCmd1 - // 6 0000 - // 8 1300 seqRecvPkt0 - // 10 2600 seqRecvPkt1 - // 12 0400 seqRecvCmd2 - // 14 00000000 - // 18 1400 seqSendCnt - // 20 d91a random - // 22 0000 - cmd := msg[msgHhrSize:] - copy(cmd, "\x09\x00\x0b\x00") - - binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) - c.seqSendCmd1++ - - // seqRecvPkt0 stores previous value of seqRecvPkt1 - // don't understand why this needs - binary.LittleEndian.PutUint16(cmd[8:], c.seqRecvPkt0) - c.seqRecvPkt0 = c.seqRecvPkt1 - binary.LittleEndian.PutUint16(cmd[10:], c.seqRecvPkt1) - binary.LittleEndian.PutUint16(cmd[12:], c.seqRecvCmd2) - - binary.LittleEndian.PutUint16(cmd[18:], c.seqSendCnt) - c.seqSendCnt++ - binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixMilli())) - return msg -} diff --git a/pkg/xiaomi/tutk/proto_old.go b/pkg/xiaomi/tutk/proto_old.go deleted file mode 100644 index 875c67a9..00000000 --- a/pkg/xiaomi/tutk/proto_old.go +++ /dev/null @@ -1,192 +0,0 @@ -package tutk - -import ( - "encoding/binary" - "fmt" - "time" -) - -func (c *Conn) oldMsgCtrl(ctrlType uint16, ctrlData []byte) []byte { - dataSize := 4 + uint16(len(ctrlData)) - msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) - - cmd := msg[msgHhrSize:] - copy(cmd, "\x00\x70\x0b\x00") - - binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) - c.seqSendCmd1++ - - binary.LittleEndian.PutUint16(cmd[16:], dataSize) - //binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) - - data := cmd[cmdHdrSize:] - binary.LittleEndian.PutUint16(data, ctrlType) - copy(data[4:], ctrlData) - return msg -} - -const pktHdrSize = 32 - -func (c *Conn) oldMsgAud(pkt []byte) []byte { - // -> 01030b001d0000008802000000002800b0020bf501000000 ... 4f4455412000000088020000030400001d000000000000000bf51f7a9b0100000000000000000000 - hdr := pkt[:pktHdrSize] - payload := pkt[pktHdrSize:] - - n := uint16(len(payload)) - dataSize := n + 8 + 32 - msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) - - // 0 01030b00 command + version - // 4 1d000000 seq - // 8 8802 media size (648) - // 10 00000000 - // 14 2800 tail (pkt header) size? - // 16 b002 size (648 + 8 + 32) - // 18 0bf5 random msg id (unixms) - // 20 01000000 fixed - cmd := msg[msgHhrSize:] - copy(cmd, "\x01\x03\x0b\x00") - binary.LittleEndian.PutUint16(cmd[4:], c.seqSendAud) - c.seqSendAud++ - binary.LittleEndian.PutUint16(cmd[8:], n) - cmd[14] = 0x28 // important! - binary.LittleEndian.PutUint16(cmd[16:], dataSize) - binary.LittleEndian.PutUint16(cmd[18:], uint16(time.Now().UnixMilli())) - cmd[20] = 1 - - data := cmd[cmdHdrSize:] - copy(data, payload) - copy(data[n:], "ODUA\x20\x00\x00\x00") - copy(data[n+8:], hdr) - - return msg -} - -func (c *Conn) oldHandlerCh0() func([]byte) int8 { - var waitSeq uint16 - var waitSize uint32 - var waitData []byte - - return func(cmd []byte) int8 { - // 0 01030800 command + version - // 4 00000000 fixed - // 8 ac880100 total size - // 12 6200 chunk seq - // 14 2000 tail (pkt header) size? - // 16 cc00 size - // 18 0000 - // 20 01000000 fixed - - switch cmd[0] { - case 0x01: - var packetData []byte - - switch cmd[1] { - case 0x03: - seq := binary.LittleEndian.Uint16(cmd[12:]) - if seq != waitSeq { - waitSeq = 0 - return msgMediaLost - } - if seq == 0 { - waitData = waitData[:0] - waitSize = binary.LittleEndian.Uint32(cmd[8:]) + 32 - } - - waitData = append(waitData, cmd[24:]...) - if n := uint32(len(waitData)); n < waitSize { - waitSeq++ - return msgMediaChunk - } else if n > waitSize { - waitSeq = 0 - return msgMediaLost - } - - waitSeq = 0 - - // create a buffer for the header and collected data - packetData = make([]byte, waitSize) - // there's a header at the end - let's move it to the beginning - copy(packetData, waitData[waitSize-32:]) - copy(packetData[32:], waitData) - - case 0x04: - // This is audio from miss audio start command. MiHome not using miss commands. - waitSize2 := binary.LittleEndian.Uint32(cmd[8:]) - waitData2 := cmd[24:] - - if uint32(len(waitData2)) != waitSize2 { - return -1 // shouldn't happen for audio - } - - packetData = make([]byte, waitSize2) - copy(packetData, waitData2) - - default: - return 0 - } - - // fix Dafang bug (timestamp in seconds) - binary.LittleEndian.PutUint64(packetData[16:], uint64(time.Now().UnixMilli())) - - select { - case c.rawPkt <- packetData: - default: - c.err = fmt.Errorf("%s: media queue is full", "tutk") - return -1 - } - return msgMediaFrame - - case 0x00: - switch cmd[1] { - case 0x70: - _ = c.WriteCh0(c.msgAck0070(cmd)) - select { - case c.rawCmd <- cmd[24:]: - default: - } - return msgCommand - case 0x12: - _ = c.WriteCh0(c.msgAck0012(cmd)) - return msgDafang0012 - case 0x71: - if c.cmdAck != nil { - c.cmdAck() - } - return msgDafang0071 - } - } - - return 0 - } -} - -func (c *Conn) msgAck0070(msg28 []byte) []byte { - // <- 00700800010000000000000000000000340000007625a02f ... - // -> 00710800010000000000000000000000000000007625a02f - msg := c.msg(msgHhrSize + cmdHdrSize) - - cmd := msg[msgHhrSize:] - copy(cmd, "\x00\x71\x0b\x00") - binary.LittleEndian.PutUint16(cmd[4:], c.seqSendCmd1) - c.seqSendCmd1++ - copy(cmd[8:], msg28[8:10]) - - return msg -} - -func (c *Conn) msgAck0012(msg28 []byte) []byte { - // <- 001208000000000000000000000000000c00000000000000 020000000100000001000000 - // -> 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000 - const dataSize = 20 - msg := c.msg(msgHhrSize + cmdHdrSize + dataSize) - - cmd := msg[msgHhrSize:] - copy(cmd, "\x00\x13\x0b\x00") - cmd[16] = dataSize - - data := cmd[cmdHdrSize:] - copy(data, msg28[cmdHdrSize:]) - - return msg -} From 9a1eac8ef4d87ba8042f291a9cb1960d600d2e92 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 09:41:41 +0300 Subject: [PATCH 214/241] Add cache to xiaomi cloud logins --- internal/xiaomi/xiaomi.go | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index a27cb9f6..d8611981 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -50,18 +50,26 @@ func Init() { } var tokens map[string]string -var tokensMu sync.Mutex +var clouds map[string]*xiaomi.Cloud +var cloudsMu sync.Mutex func getCloud(userID string) (*xiaomi.Cloud, error) { - tokensMu.Lock() - defer tokensMu.Unlock() + cloudsMu.Lock() + defer cloudsMu.Unlock() - token := tokens[userID] - cloud := xiaomi.NewCloud(AppXiaomiHome) - if err := cloud.LoginWithToken(userID, token); err != nil { - return nil, err + if cloud := clouds[userID]; cloud != nil { + return cloud, nil } + cloud := xiaomi.NewCloud(AppXiaomiHome) + if err := cloud.LoginWithToken(userID, tokens[userID]); err != nil { + return nil, err + } + if clouds == nil { + clouds = map[string]*xiaomi.Cloud{userID: cloud} + } else { + clouds[userID] = cloud + } return cloud, nil } @@ -221,12 +229,12 @@ func apiDeviceList(w http.ResponseWriter, r *http.Request) { user := query.Get("id") if user == "" { - tokensMu.Lock() + cloudsMu.Lock() users := make([]string, 0, len(tokens)) for s := range tokens { users = append(users, s) } - tokensMu.Unlock() + cloudsMu.Unlock() api.ResponseJSON(w, users) return @@ -308,13 +316,13 @@ func apiAuth(w http.ResponseWriter, r *http.Request) { userID, token := auth.UserToken() auth = nil - tokensMu.Lock() + cloudsMu.Lock() if tokens == nil { tokens = map[string]string{userID: token} } else { tokens[userID] = token } - tokensMu.Unlock() + cloudsMu.Unlock() err = app.PatchConfig([]string{"xiaomi", userID}, token) } From 425fcffbe1f1fa43055ed27513a86df8362c8c29 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 10:06:13 +0300 Subject: [PATCH 215/241] Code refactoring for xiaomi source --- internal/xiaomi/xiaomi.go | 6 +++--- pkg/tutk/session25.go | 4 ---- pkg/xiaomi/legacy/client.go | 11 +---------- pkg/xiaomi/legacy/producer.go | 3 +-- pkg/xiaomi/miss/cs2/conn.go | 30 +++++++++--------------------- 5 files changed, 14 insertions(+), 40 deletions(-) diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index d8611981..1801fa86 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -99,12 +99,12 @@ func getCameraURL(url *url.URL) (string, error) { // The getMissURL request has a fallback to getP2PURL. // But for known models we can save one request to the cloud. if xiaomi.IsLegacy(model) { - return getP2PURL(url) + return getLegacyURL(url) } return getMissURL(url) } -func getP2PURL(url *url.URL) (string, error) { +func getLegacyURL(url *url.URL) (string, error) { query := url.Query() clientPublic, clientPrivate, err := crypto.GenerateKey() @@ -161,7 +161,7 @@ func getMissURL(url *url.URL) (string, error) { res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params) if err != nil { if strings.Contains(err.Error(), "no available vendor support") { - return getP2PURL(url) + return getLegacyURL(url) } return "", err } diff --git a/pkg/tutk/session25.go b/pkg/tutk/session25.go index a12f52f3..fd1f16b4 100644 --- a/pkg/tutk/session25.go +++ b/pkg/tutk/session25.go @@ -231,10 +231,6 @@ func (s *Session25) msgAckCounters() []byte { } func (s *Session25) handleCh1(cmd []byte) int { - // Channel 1 used for two-way audio. It's important: - // - answer on 0000 command with exact config response (can't set simple proto) - // - send 0012 command at start - // - respond on every 0008 command for smooth playback switch cid := string(cmd[:2]); cid { case "\x00\x00": // client start return msgClientStart diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index cf84b6fe..a35592d4 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -85,10 +85,7 @@ func xiaofangLogin(conn *tutk.Conn, password string) error { } _, data, err = conn.ReadCommand() - if err != nil { - return err - } - return nil + return err } type Client struct { @@ -110,14 +107,8 @@ func (c *Client) ReadPacket() (hdr, payload []byte, err error) { switch hdr[0] { case tutk.CodecH264, tutk.CodecH265: payload, err = DecodeVideo(payload, c.key) - if err != nil { - return - } case tutk.CodecAAC: payload, err = crypto.Decode(payload, c.key) - if err != nil { - return - } } } return diff --git a/pkg/xiaomi/legacy/producer.go b/pkg/xiaomi/legacy/producer.go index 6669d837..5c1f795d 100644 --- a/pkg/xiaomi/legacy/producer.go +++ b/pkg/xiaomi/legacy/producer.go @@ -62,7 +62,7 @@ func probe(client *Client) ([]*core.Media, error) { var vcodec, acodec *core.Codec for { - // 0 5000 + // 0 5000 codec // 2 0000 codec params // 4 01 active clients // 5 34 unknown const @@ -83,7 +83,6 @@ func probe(client *Client) ([]*core.Media, error) { if codec == tutk.CodecH264 { if h264.NALUType(avcc) == h264.NALUTypeSPS { vcodec = h264.AVCCToCodec(avcc) - vcodec.FmtpLine = "" } } else { if h265.NALUType(avcc) == h265.NALUTypeVPS { diff --git a/pkg/xiaomi/miss/cs2/conn.go b/pkg/xiaomi/miss/cs2/conn.go index 52a70fa1..1bea07c6 100644 --- a/pkg/xiaomi/miss/cs2/conn.go +++ b/pkg/xiaomi/miss/cs2/conn.go @@ -21,7 +21,7 @@ func Dial(host, transport string) (*Conn, error) { _, isTCP := conn.(*tcpConn) c := &Conn{ - conn: conn, + Conn: conn, isTCP: isTCP, channels: [4]*dataChannel{ newDataChannel(0, 10), nil, newDataChannel(250, 100), nil, @@ -32,7 +32,7 @@ func Dial(host, transport string) (*Conn, error) { } type Conn struct { - conn net.Conn + net.Conn isTCP bool err error @@ -116,7 +116,7 @@ func (c *Conn) worker() { buf := make([]byte, 1200) for { - n, err := c.conn.Read(buf) + n, err := c.Conn.Read(buf) if err != nil { c.err = fmt.Errorf("%s: %w", "cs2", err) return @@ -136,7 +136,7 @@ func (c *Conn) worker() { // For TCP we should send ping every second to keep connection alive. // Based on PCAP analysis: official Mi Home app sends PING every ~1s. if now := time.Now(); now.After(keepaliveTS) { - _, _ = c.conn.Write([]byte{magic, msgPing, 0, 0}) + _, _ = c.Conn.Write([]byte{magic, msgPing, 0, 0}) keepaliveTS = now.Add(time.Second) } @@ -151,7 +151,7 @@ func (c *Conn) worker() { if pushed >= 0 { // For UDP we should send ACK. ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO} - _, _ = c.conn.Write(ack) + _, _ = c.Conn.Write(ack) } } @@ -161,7 +161,7 @@ func (c *Conn) worker() { } case msgPing: - _, _ = c.conn.Write([]byte{magic, msgPong, 0, 0}) + _, _ = c.Conn.Write([]byte{magic, msgPong, 0, 0}) case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose: // skip it case msgDrwAck: // only for UDP if c.cmdAck != nil { @@ -184,18 +184,6 @@ func (c *Conn) Version() string { return "CS2" } -func (c *Conn) RemoteAddr() net.Addr { - return c.conn.RemoteAddr() -} - -func (c *Conn) SetDeadline(t time.Time) error { - return c.conn.SetDeadline(t) -} - -func (c *Conn) Close() error { - return c.conn.Close() -} - func (c *Conn) Error() error { if c.err != nil { return c.err @@ -221,7 +209,7 @@ func (c *Conn) WriteCommand(cmd uint32, data []byte) error { c.seqCh0++ if c.isTCP { - _, err := c.conn.Write(req) + _, err := c.Conn.Write(req) return err } @@ -237,7 +225,7 @@ func (c *Conn) WriteCommand(cmd uint32, data []byte) error { } for { - if _, err := c.conn.Write(req); err != nil { + if _, err := c.Conn.Write(req); err != nil { return err } <-timeout.C @@ -278,7 +266,7 @@ func (c *Conn) WritePacket(hdr, payload []byte) error { copy(req[offset:], hdr) copy(req[offset+hdrSize:], hdr) - _, err := c.conn.Write(req) + _, err := c.Conn.Write(req) return err } From 6d77b175d8b5a9bb2c0d6fe82799e67932b7c9b3 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 13:47:21 +0300 Subject: [PATCH 216/241] Move all JS libs to cdn.jsdelivr.net --- www/links.html | 2 +- www/net.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/links.html b/www/links.html index 466d30a5..13e08edf 100644 --- a/www/links.html +++ b/www/links.html @@ -87,7 +87,7 @@ if (data.setup_code === undefined) return; const script = document.createElement('script'); - script.src = 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js'; + script.src = 'https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js'; script.async = true; script.onload = () => { /* global BigInt */ diff --git a/www/net.html b/www/net.html index 43db024e..1c2a697f 100644 --- a/www/net.html +++ b/www/net.html @@ -4,7 +4,7 @@ net - go2rtc - + - - -
    - - - \ No newline at end of file From 1ec40f2fc33cfb4b71cbf1e6ea60580c3aa1e55d Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 13:57:05 +0300 Subject: [PATCH 218/241] Move schema.json to www/static --- www/config.html | 2 +- {website => www}/schema.json | 0 www/static.go | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename {website => www}/schema.json (100%) diff --git a/www/config.html b/www/config.html index fe84275c..026b5beb 100644 --- a/www/config.html +++ b/www/config.html @@ -1205,7 +1205,7 @@ (async () => { try { - const r = await fetch('https://go2rtc.org/schema.json', {cache: 'no-cache'}); + const r = await fetch('schema.json', {cache: 'no-cache'}); if (r.ok) setupYamlHints(await r.json()); } catch (e) { // ignore schema load errors diff --git a/website/schema.json b/www/schema.json similarity index 100% rename from website/schema.json rename to www/schema.json diff --git a/www/static.go b/www/static.go index 01f50906..064fec36 100644 --- a/www/static.go +++ b/www/static.go @@ -4,4 +4,5 @@ import "embed" //go:embed *.html //go:embed *.js +//go:embed *.json var Static embed.FS From c03cd9f1561eedb6933b01dc78498c1050ec81ba Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 12:09:38 +0100 Subject: [PATCH 219/241] minor improvements --- pkg/wyze/client.go | 19 +++++++++++- pkg/wyze/tutk/README.md | 68 +++++++++++++++++++++-------------------- pkg/wyze/tutk/conn.go | 29 ++++++++++-------- pkg/wyze/tutk/dtls.go | 54 +++++++++++++++++++++++++++++--- pkg/wyze/tutk/frame.go | 23 +++++++++++++- 5 files changed, 141 insertions(+), 52 deletions(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index ab8f7d4e..e047cfd5 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -211,7 +211,7 @@ func (c *Client) StartAudio() error { } func (c *Client) StartIntercom() error { - if c.conn.IsBackchannelReady() { + if c.conn == nil || !c.conn.IsBackchannelReady() { return nil } @@ -223,6 +223,17 @@ func (c *Client) StartIntercom() error { return c.conn.AVServStart() } +func (c *Client) StopIntercom() error { + if c.conn == nil || !c.conn.IsBackchannelReady() { + return nil + } + + k10010 := c.buildK10010(MediaTypeReturnAudio, false) + c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second) + + return c.conn.AVServStop() +} + func (c *Client) ReadPacket() (*tutk.Packet, error) { return c.conn.AVRecvFrameData() } @@ -270,10 +281,16 @@ func (c *Client) Close() error { fmt.Printf("[Wyze] Closing connection\n") } + c.StopIntercom() + if c.conn != nil { c.conn.Close() } + if c.verbose { + fmt.Printf("[Wyze] Connection closed\n") + } + return nil } diff --git a/pkg/wyze/tutk/README.md b/pkg/wyze/tutk/README.md index ed98a857..36fa4728 100644 --- a/pkg/wyze/tutk/README.md +++ b/pkg/wyze/tutk/README.md @@ -443,38 +443,10 @@ Offset Size Field Description [31] 1 TwoWayAudio 0x01 if intercom supported [32-35] 4 Reserved [36-39] 4 BufferConfig 0x00000004 -[40-43] 4 Capabilities 0x001F07FB (see below) +[40-43] 4 Capabilities 0x001F07FB [44-57] 14 Reserved ``` -### Capabilities Bitmask (0x001F07FB) - -``` -Bit Hex Name Description -────────────────────────────────────────────────────────────── -0 0x00000001 CYCLIC_FRAME_NUMBERING Frame numbers wrap around -1 0x00000002 CLEAN_BUF_ON_RESET Clear buffer on stream reset -3 0x00000008 TIMESTAMP_IN_FRAMEINFO Timestamps in FRAMEINFO struct -4 0x00000010 MULTI_CHANNEL Multiple AV channels supported -5 0x00000020 EXTENDED_FRAMEINFO 40-byte FRAMEINFO (vs 16-byte SDK) -6 0x00000040 RESEND_TIMEOUT Packet resend with timeout -7 0x00000080 DTLS_SUPPORT DTLS encryption supported -8 0x00000100 SPEAKER_CHANNEL Two-way audio / intercom -9 0x00000200 PTZ_CHANNEL PTZ control channel -10 0x00000400 PLAYBACK_CHANNEL SD card playback channel -16 0x00010000 AV_SECURITY_ENABLED Encrypted AV stream -17 0x00020000 RESEND_ENABLED Packet resend mechanism -18 0x00040000 DTLS_PSK DTLS with Pre-Shared Key -19 0x00080000 DTLS_ECDHE DTLS with ECDHE key exchange -20 0x00100000 CHACHA20_POLY1305 ChaCha20-Poly1305 cipher support -``` - -**0x001F07FB breakdown:** -``` -0x001F07FB = 0b0000_0000_0001_1111_0000_0111_1111_1011 - = Bits: 0,1,3,4,5,6,7,8,9,10,16,17,18,19,20 -``` - --- ## 7. K-Command Authentication @@ -515,9 +487,21 @@ Offset Size Field Description [16+] var Payload Command-specific data ``` -### K10000 - Auth Request (16 bytes) +### K10000 - Auth Request (16 + JSON bytes) -Header only, no payload. Initiates authentication. +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 HLHeader CommandID = 10000, PayloadLen = len(JSON) +[16+] var JSONPayload Audio codec preferences +``` + +**JSON Payload:** +```json +{"cameraInfo":{"audioEncoderList":[137,138,140]}} +``` + +Where audioEncoderList contains supported codec IDs: 137=PCMU, 138=PCMA, 140=PCM. ### K10001 - Challenge (33+ bytes) @@ -543,7 +527,7 @@ Offset Size Field Description ────────────────────────────────────────────────────────────── [0-15] 16 HLHeader CommandID = 10002, PayloadLen = 22 [16-31] 16 Response XXTEA-decrypted challenge -[32-35] 4 UIDPrefix First 4 bytes of UID +[32-35] 4 SessionID Random 4-byte session identifier [36] 1 VideoFlag 1 = enable video stream [37] 1 AudioFlag 1 = enable audio stream ``` @@ -620,6 +604,22 @@ Offset Size Field Description | 0xF0 (240) | Maximum | | 0x3C (60) | SD quality | +### K10052 - Set Resolution Doorbell (22 bytes) + +Used by doorbell models (WYZEDB3, WVOD1, HL_WCO2, WYZEC1) instead of K10056: + +``` +Offset Size Field Description +────────────────────────────────────────────────────────────── +[0-15] 16 HLHeader CommandID = 10052, PayloadLen = 6 +[16-17] 2 Bitrate KB/s value (LE) +[18] 1 FrameSize Resolution + 1 (see table above) +[19] 1 FPS Frames per second, 0 = auto +[20-21] 2 Reserved Zero-filled +``` + +**Note:** K10052 has a different field order than K10056 (bitrate before frameSize). + --- ## 9. AV Frame Structure @@ -1155,12 +1155,14 @@ authkey = b64.replace('+', 'Z').replace('/', '9').replace('=', 'A') | Command | ID | Description | |---------|-----|-------------| -| KCmdAuth | 10000 | Auth request | +| KCmdAuth | 10000 | Auth request (with JSON) | | KCmdChallenge | 10001 | Challenge from camera | | KCmdChallengeResp | 10002 | Challenge response | | KCmdAuthResult | 10003 | Auth result (JSON) | | KCmdControlChannel | 10010 | Start/stop media | | KCmdControlChannelResp | 10011 | Control response | +| KCmdSetResolutionDB | 10052 | Set resolution (doorbell) | +| KCmdSetResolutionDBResp | 10053 | Resolution response (doorbell) | | KCmdSetResolution | 10056 | Set resolution/bitrate | | KCmdSetResolutionResp | 10057 | Resolution response | diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 22b72afd..fc16da27 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -222,17 +222,19 @@ func (c *Conn) AVServStart() error { func (c *Conn) AVServStop() error { c.mu.Lock() - defer c.mu.Unlock() - + serverConn := c.serverConn + c.serverConn = nil // Reset audio TX state c.audioSeq = 0 c.audioFrameNo = 0 + c.mu.Unlock() - if c.serverConn != nil { - err := c.serverConn.Close() - c.serverConn = nil - return err + if serverConn == nil { + return nil } + + go serverConn.Close() + return nil } @@ -339,8 +341,13 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, frame := c.buildIOCtrlFrame(payload) var t *time.Timer t = time.AfterFunc(1, func() { - if _, err := c.clientConn.Write(frame); err == nil && t != nil { - t.Reset(time.Second) + c.mu.RLock() + conn := c.clientConn + c.mu.RUnlock() + if conn != nil { + if _, err := conn.Write(frame); err == nil && t != nil { + t.Reset(time.Second) + } } }) defer t.Stop() @@ -399,10 +406,6 @@ func (c *Conn) Close() error { c.clientConn.Close() c.clientConn = nil } - if c.serverConn != nil { - c.serverConn.Close() - c.serverConn = nil - } if c.frames != nil { c.frames.Close() } @@ -705,7 +708,7 @@ func (c *Conn) handleSpeakerAVLogin() error { } buf := make([]byte, 1024) - c.serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + c.serverConn.SetReadDeadline(time.Now().Add(2 * time.Second)) n, err := c.serverConn.Read(buf) if err != nil { return fmt.Errorf("read av login: %w", err) diff --git a/pkg/wyze/tutk/dtls.go b/pkg/wyze/tutk/dtls.go index e4e2b3ea..c51b7762 100644 --- a/pkg/wyze/tutk/dtls.go +++ b/pkg/wyze/tutk/dtls.go @@ -2,6 +2,7 @@ package tutk import ( "net" + "sync" "time" "github.com/pion/dtls/v3" @@ -42,6 +43,9 @@ func buildDtlsConfig(psk []byte, isServer bool) *dtls.Config { type ChannelAdapter struct { conn *Conn channel uint8 + + mu sync.Mutex + readDeadline time.Time } func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { @@ -52,6 +56,29 @@ func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { buf = a.conn.serverBuf } + a.mu.Lock() + deadline := a.readDeadline + a.mu.Unlock() + + if !deadline.IsZero() { + timeout := time.Until(deadline) + if timeout <= 0 { + return 0, nil, &timeoutError{} + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case data := <-buf: + return copy(p, data), a.conn.addr, nil + case <-timer.C: + return 0, nil, &timeoutError{} + case <-a.conn.ctx.Done(): + return 0, nil, net.ErrClosed + } + } + select { case data := <-buf: return copy(p, data), a.conn.addr, nil @@ -67,8 +94,27 @@ func (a *ChannelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { return len(p), nil } -func (a *ChannelAdapter) Close() error { return nil } -func (a *ChannelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } -func (a *ChannelAdapter) SetDeadline(time.Time) error { return nil } -func (a *ChannelAdapter) SetReadDeadline(time.Time) error { return nil } +func (a *ChannelAdapter) Close() error { return nil } +func (a *ChannelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } + +func (a *ChannelAdapter) SetDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} + +func (a *ChannelAdapter) SetReadDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} + func (a *ChannelAdapter) SetWriteDeadline(time.Time) error { return nil } + +type timeoutError struct{} + +func (e *timeoutError) Error() string { return "i/o timeout" } +func (e *timeoutError) Timeout() bool { return true } +func (e *timeoutError) Temporary() bool { return true } diff --git a/pkg/wyze/tutk/frame.go b/pkg/wyze/tutk/frame.go index cebdc825..ee673181 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/wyze/tutk/frame.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "encoding/hex" "fmt" + "sync" "github.com/AlexxIT/go2rtc/pkg/aac" ) @@ -282,6 +283,8 @@ type FrameHandler struct { audioTS tsTracker output chan *Packet verbose bool + closed bool + closeMu sync.Mutex } func NewFrameHandler(verbose bool) *FrameHandler { @@ -297,6 +300,13 @@ func (h *FrameHandler) Recv() <-chan *Packet { } func (h *FrameHandler) Close() { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + h.closed = true close(h.output) } @@ -540,6 +550,13 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { } func (h *FrameHandler) queue(pkt *Packet) { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + select { case h.output <- pkt: default: @@ -548,7 +565,11 @@ func (h *FrameHandler) queue(pkt *Packet) { case <-h.output: default: } - h.output <- pkt + select { + case h.output <- pkt: + default: + // Queue still full, drop this packet + } } } From ebe454e3be8d2e6efca31af7ff31fbd6218a94b5 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 18:24:24 +0300 Subject: [PATCH 220/241] Fix relative links in readme --- README.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 03db381f..7edbaf5a 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ streams: rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90 ``` -All transcoding formats have [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. +All transcoding formats have [built-in templates](internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. But you can override them via YAML config. You can also add your own formats to the config and use them with source params. @@ -488,7 +488,9 @@ streams: *[New in v1.8.2](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.2)* -Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/expr/README.md)). +Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language. + +*[read more](internal/expr/README.md)* #### Source: HomeKit @@ -616,19 +618,25 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. *[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/tuya/README.md). +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. + +*[read more](internal/tuya/README.md)* #### Source: Xiaomi *[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* -This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/xiaomi/README.md). +This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. + +*[read more](internal/xiaomi/README.md)* #### Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* -Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. [Read more](https://github.com/AlexxIT/go2rtc/tree/master/internal/gopro). +Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. + +*[read more](internal/gopro/README.md)* #### Source: Ivideon @@ -928,7 +936,7 @@ The HTTP API is the main part for interacting with the application. Default addr **Important!** go2rtc passes requests from localhost and from Unix sockets without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to the API. If not properly configured, an attacker can gain access to your cameras and even your server. -[API description](https://github.com/AlexxIT/go2rtc/tree/master/api). +[API description](api/README.md). **Module config** @@ -1140,7 +1148,9 @@ Link example: https://go2rtc.org/webtorrent/#share=02SNtgjKXY&pwd=wznEQqznxW&med ### Module: ngrok -With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/ngrok/README.md)). +With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address. + +*[read more](internal/ngrok/README.md)* ### Module: Hass @@ -1246,7 +1256,7 @@ API examples: - You can use `rotate` param with `90`, `180`, `270` or `-90` values - You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) -**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/mjpeg/README.md)): +**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](internal/mjpeg/README.md)). [![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) From 6796bdabe2888b4f3fe52c8f964424f520a6b85c Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 18:30:06 +0300 Subject: [PATCH 221/241] Change header levels in readme --- README.md | 187 +++++++++++++++++++++++++++--------------------------- 1 file changed, 93 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 7edbaf5a..ed66838b 100644 --- a/README.md +++ b/README.md @@ -64,37 +64,37 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [go2rtc: Dev version](#go2rtc-dev-version) * [Configuration](#configuration) * [Module: Streams](#module-streams) - * [Two way audio](#two-way-audio) - * [Source: RTSP](#source-rtsp) - * [Source: RTMP](#source-rtmp) - * [Source: HTTP](#source-http) - * [Source: ONVIF](#source-onvif) - * [Source: FFmpeg](#source-ffmpeg) - * [Source: FFmpeg Device](#source-ffmpeg-device) - * [Source: Exec](#source-exec) - * [Source: Echo](#source-echo) - * [Source: Expr](#source-expr) - * [Source: HomeKit](#source-homekit) - * [Source: Bubble](#source-bubble) - * [Source: DVRIP](#source-dvrip) - * [Source: Tapo](#source-tapo) - * [Source: Kasa](#source-kasa) - * [Source: Tuya](#source-tuya) - * [Source: Xiaomi](#source-xiaomi) - * [Source: GoPro](#source-gopro) - * [Source: Ivideon](#source-ivideon) - * [Source: Hass](#source-hass) - * [Source: ISAPI](#source-isapi) - * [Source: Nest](#source-nest) - * [Source: Ring](#source-ring) - * [Source: Roborock](#source-roborock) - * [Source: Doorbird](#source-doorbird) - * [Source: WebRTC](#source-webrtc) - * [Source: WebTorrent](#source-webtorrent) - * [Incoming sources](#incoming-sources) - * [Stream to camera](#stream-to-camera) - * [Publish stream](#publish-stream) - * [Preload stream](#preload-stream) + * [Two way audio](#two-way-audio) + * [Source: RTSP](#source-rtsp) + * [Source: RTMP](#source-rtmp) + * [Source: HTTP](#source-http) + * [Source: ONVIF](#source-onvif) + * [Source: FFmpeg](#source-ffmpeg) + * [Source: FFmpeg Device](#source-ffmpeg-device) + * [Source: Exec](#source-exec) + * [Source: Echo](#source-echo) + * [Source: Expr](#source-expr) + * [Source: HomeKit](#source-homekit) + * [Source: Bubble](#source-bubble) + * [Source: DVRIP](#source-dvrip) + * [Source: Tapo](#source-tapo) + * [Source: Kasa](#source-kasa) + * [Source: Tuya](#source-tuya) + * [Source: Xiaomi](#source-xiaomi) + * [Source: GoPro](#source-gopro) + * [Source: Ivideon](#source-ivideon) + * [Source: Hass](#source-hass) + * [Source: ISAPI](#source-isapi) + * [Source: Nest](#source-nest) + * [Source: Ring](#source-ring) + * [Source: Roborock](#source-roborock) + * [Source: Doorbird](#source-doorbird) + * [Source: WebRTC](#source-webrtc) + * [Source: WebTorrent](#source-webtorrent) + * [Incoming sources](#incoming-sources) + * [Stream to camera](#stream-to-camera) + * [Publish stream](#publish-stream) + * [Preload stream](#preload-stream) * [Module: API](#module-api) * [Module: RTSP](#module-rtsp) * [Module: RTMP](#module-rtmp) @@ -114,9 +114,8 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [Projects using go2rtc](#projects-using-go2rtc) * [Camera experience](#cameras-experience) * [TIPS](#tips) -* [FAQ](#faq) -## Fast start +# Fast start 1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or Home Assistant [Add-on](#go2rtc-home-assistant-add-on) or [Integration](#go2rtc-home-assistant-integration) 2. Open web interface: `http://localhost:1984/` @@ -131,7 +130,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - write your own [web interface](#module-api) - integrate [web api](#module-api) into your smart home platform -### go2rtc: Binary +## go2rtc: Binary Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): @@ -153,11 +152,11 @@ Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac. PS. The application is compiled with the latest versions of the Go language for maximum speed and security. Therefore, the [minimum OS versions](https://go.dev/wiki/MinimumRequirements) depend on the Go language. -### go2rtc: Docker +## go2rtc: Docker The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant Add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg) and [Python](#source-echo). -### go2rtc: Home Assistant Add-on +## go2rtc: Home Assistant Add-on [![](https://my.home-assistant.io/badges/supervisor_addon.svg)](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons) @@ -166,11 +165,11 @@ The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) - go2rtc > Install > Start 2. Setup [Integration](#module-hass) -### go2rtc: Home Assistant Integration +## go2rtc: Home Assistant Integration [WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom component can be used on any [Home Assistant installation](https://www.home-assistant.io/installation/), including [HassWP](https://github.com/AlexxIT/HassWP) on Windows. It can automatically download and use the latest version of go2rtc. Or it can connect to an existing version of go2rtc. Addon installation in this case is optional. -### go2rtc: Dev version +## go2rtc: Dev version Latest, but maybe unstable version: @@ -178,7 +177,7 @@ Latest, but maybe unstable version: - Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions - Hass Add-on: `go2rtc master` or `go2rtc master hardware` versions -## Configuration +# Configuration - by default go2rtc will search `go2rtc.yaml` in the current work directory - `api` server will start on default **1984 port** (TCP) @@ -202,7 +201,7 @@ Available modules: - [hass](#module-hass) - Home Assistant integration - [log](#module-log) - logs config -### Module: Streams +## Module: Streams **go2rtc** supports different stream source types. You can config one or multiple links of any type as a stream source. @@ -237,7 +236,7 @@ Available source types: Read more about [incoming sources](#incoming-sources) -#### Two-way audio +## Two-way audio Supported sources: @@ -257,7 +256,7 @@ Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. T go2rtc also supports [play audio](#stream-to-camera) files and live streams on this cameras. -#### Source: RTSP +## Source: RTSP ```yaml streams: @@ -301,7 +300,7 @@ streams: dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket ``` -#### Source: RTMP +## Source: RTMP You can get a stream from an RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). @@ -310,7 +309,7 @@ streams: rtmp_stream: rtmp://192.168.1.123/live/camera1 ``` -#### Source: HTTP +## Source: HTTP Support Content-Type: @@ -341,7 +340,7 @@ streams: **PS.** Dahua camera has a bug: if you select MJPEG codec for RTSP second stream, snapshot won't work. -#### Source: ONVIF +## Source: ONVIF *[New in v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)* @@ -356,7 +355,7 @@ streams: tapo1: onvif://admin:password@192.168.1.123:2020 ``` -#### Source: FFmpeg +## Source: FFmpeg You can get any stream, file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. @@ -417,7 +416,7 @@ Read more about [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/H **PS.** It is recommended to check the available hardware in the WebUI add page. -#### Source: FFmpeg Device +## Source: FFmpeg Device You can get video from any USB camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. @@ -438,7 +437,7 @@ streams: **PS.** It is recommended to check the available devices in the WebUI add page. -#### Source: Exec +## Source: Exec Exec source can run any external application and expect data from it. Two transports are supported - **pipe** (*from [v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)*) and **RTSP**. @@ -471,7 +470,7 @@ streams: play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1 ``` -#### Source: Echo +## Source: Echo Some sources may have a dynamic link. And you will need to get it using a Bash or Python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams). @@ -484,7 +483,7 @@ streams: apple_hls: echo:python3 hls.py https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html ``` -#### Source: Expr +## Source: Expr *[New in v1.8.2](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.2)* @@ -492,7 +491,7 @@ Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/ex *[read more](internal/expr/README.md)* -#### Source: HomeKit +## Source: HomeKit **Important:** @@ -525,7 +524,7 @@ RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g **This source is in active development!** Tested only with [Aqara Camera Hub G3](https://www.aqara.com/eu/product/camera-hub-g3) (both EU and CN versions). -#### Source: Bubble +## Source: Bubble *[New in v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)* @@ -539,7 +538,7 @@ streams: camera1: bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0 ``` -#### Source: DVRIP +## Source: DVRIP *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* @@ -559,7 +558,7 @@ streams: - dvrip://username:password@192.168.1.123:34567?backchannel=1 ``` -#### Source: EseeCloud +## Source: EseeCloud *[New in v1.9.10](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10)* @@ -568,7 +567,7 @@ streams: camera1: eseecloud://user:pass@192.168.1.123:80/livestream/12 ``` -#### Source: Tapo +## Source: Tapo *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* @@ -598,7 +597,7 @@ echo -n "cloud password" | md5 | awk '{print toupper($0)}' echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}' ``` -#### Source: Kasa +## Source: Kasa *[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)* @@ -614,7 +613,7 @@ streams: Tested: KD110, KC200, KC401, KC420WS, EC71. -#### Source: Tuya +## Source: Tuya *[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* @@ -622,7 +621,7 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. *[read more](internal/tuya/README.md)* -#### Source: Xiaomi +## Source: Xiaomi *[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* @@ -630,7 +629,7 @@ This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi *[read more](internal/xiaomi/README.md)* -#### Source: GoPro +## Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* @@ -638,7 +637,7 @@ Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or *[read more](internal/gopro/README.md)* -#### Source: Ivideon +## Source: Ivideon Support public cameras from the service [Ivideon](https://tv.ivideon.com/). @@ -647,7 +646,7 @@ streams: quailcam: ivideon:100-tu5dkUPct39cTp9oNEN2B6/0 ``` -#### Source: Hass +## Source: Hass Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files: @@ -683,7 +682,7 @@ streams: By default, the Home Assistant API does not allow you to get a dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others, can also be imported using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). -#### Source: ISAPI +## Source: ISAPI *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -696,7 +695,7 @@ streams: - isapi://admin:password@192.168.1.123:80/ ``` -#### Source: Nest +## Source: Nest *[New in v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)* @@ -709,7 +708,7 @@ streams: nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** ``` -#### Source: Ring +## Source: Ring This source type support Ring cameras with [two way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras. @@ -719,7 +718,7 @@ streams: ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot ``` -#### Source: Roborock +## Source: Roborock *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -733,7 +732,7 @@ Source supports loading Roborock credentials from Home Assistant [custom integra If you have a graphic PIN for your vacuum, add it as a numeric PIN (lines: 123, 456, 789) to the end of the `roborock` link. -#### Source: Doorbird +## Source: Doorbird *[New in v1.9.11](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)* @@ -748,7 +747,7 @@ streams: - doorbird://admin:password@192.168.1.123 # two-way audio ``` -#### Source: WebRTC +## Source: WebRTC *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -790,7 +789,7 @@ streams: **PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language. -#### Source: WebTorrent +## Source: WebTorrent *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -801,7 +800,7 @@ streams: webtorrent1: webtorrent:?share=huofssuxaty00izc&pwd=k3l2j9djeg8v8r7e ``` -#### Incoming sources +## Incoming sources By default, go2rtc establishes a connection to the source when any client requests it. Go2rtc drops the connection to the source when it has no clients left. @@ -830,7 +829,7 @@ By default, go2rtc establishes a connection to the source when any client reques ffmpeg -re -i BigBuckBunny.mp4 -c copy -f mpegts http://localhost:1984/api/stream.ts?dst=camera1 ``` -#### Incoming: Browser +### Incoming: Browser *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -842,7 +841,7 @@ You can turn the browser of any PC or mobile into an IP camera with support for 4. Select `camera+microphone` or `display+speaker` option 5. Open `webrtc` local page (your go2rtc **should work over HTTPS!**) or `share link` via [WebTorrent](#module-webtorrent) technology (work over HTTPS by default) -#### Incoming: WebRTC/WHIP +### Incoming: WebRTC/WHIP *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -850,7 +849,7 @@ You can use **OBS Studio** or any other broadcast software with [WHIP](https://w - Settings > Stream > Service: WHIP > http://192.168.1.123:1984/api/webrtc?dst=camera1 -#### Stream to camera +## Stream to camera *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -872,7 +871,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com - you can stop active playback by calling the API with the empty `src` parameter - you will see one active producer and one active consumer in go2rtc WebUI info page during streaming -### Publish stream +## Publish stream *[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)* @@ -910,7 +909,7 @@ streams: - **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. - **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key. -### Preload stream +## Preload stream You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. @@ -930,7 +929,7 @@ streams: - ffmpeg:camera3#video=h264#audio=opus#hardware ``` -### Module: API +## Module: API The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. @@ -972,7 +971,7 @@ api: - MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446) - MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4 -### Module: RTSP +## Module: RTSP You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}` @@ -995,7 +994,7 @@ By default go2rtc provide RTSP-stream with only one first video and only one fir Read more about [codecs filters](#codecs-filters). -### Module: RTMP +## Module: RTMP *[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)* @@ -1008,7 +1007,7 @@ rtmp: listen: ":1935" # by default - disabled! ``` -### Module: WebRTC +## Module: WebRTC In most cases, [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses a direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP. It **can't pass** media data through your Nginx or Cloudflare or [Nabu Casa](https://www.nabucasa.com/) HTTP TCP connection! @@ -1066,7 +1065,7 @@ webrtc: credential: your_pass ``` -### Module: HomeKit +## Module: HomeKit *[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)* @@ -1120,7 +1119,7 @@ homekit: aqara1: # same stream ID from streams list ``` -### Module: WebTorrent +## Module: WebTorrent *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -1146,13 +1145,13 @@ webtorrent: Link example: https://go2rtc.org/webtorrent/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio -### Module: ngrok +## Module: ngrok With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address. *[read more](internal/ngrok/README.md)* -### Module: Hass +## Module: Hass The best and easiest way to use go2rtc inside Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom Lovelace card. @@ -1190,7 +1189,7 @@ streams: **PS.** There is also another nice card with go2rtc support - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card). -### Module: MP4 +## Module: MP4 Provides several features: @@ -1213,7 +1212,7 @@ Read more about [codecs filters](#codecs-filters). **PS.** Rotate and scale params don't use transcoding and change video using metadata. -### Module: HLS +## Module: HLS *[New in v1.1.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.1.0)* @@ -1228,7 +1227,7 @@ API examples: Read more about [codecs filters](#codecs-filters). -### Module: MJPEG +## Module: MJPEG **Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. @@ -1260,7 +1259,7 @@ API examples: [![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) -### Module: Log +## Module: Log You can set different log levels for different modules. @@ -1274,7 +1273,7 @@ log: webrtc: fatal ``` -## Security +# Security > [!IMPORTANT] > If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server. @@ -1321,7 +1320,7 @@ If you need web interface protection without the Home Assistant add-on, you need PS. Additionally, WebRTC will try to use the 8555 UDP port to transmit encrypted media. It works without problems on the local network, and sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. -## Codecs filters +# Codecs filters go2rtc can automatically detect which codecs your device supports for [WebRTC](#module-webrtc) and [MSE](#module-mp4) technologies. @@ -1344,7 +1343,7 @@ Some examples: - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12) - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non-standard audio codecs, won't work on some players -## Codecs madness +# Codecs madness `AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. @@ -1385,7 +1384,7 @@ Some examples: - AAC = MPEG4-GENERIC - MP3 = MPEG-1 Audio Layer III or MPEG-2 Audio Layer III -## Built-in transcoding +# Built-in transcoding There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support. @@ -1414,7 +1413,7 @@ PCMU/xxx => PCMU/8000 => WebRTC - FLAC codec not supported in an RTSP stream. If you are using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio, you should set up transcoding to the AAC codec. - PCMA and PCMU are VERY low-quality codecs. They support only 256! different sounds. Use them only when you have no other options. -## Codecs negotiation +# Codecs negotiation For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser. @@ -1443,7 +1442,7 @@ streams: **PS.** You can select `PCMU` or `PCMA` codec in camera settings and not use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. -## Projects using go2rtc +# Projects using go2rtc - [Home Assistant](https://www.home-assistant.io/) [2024.11+](https://www.home-assistant.io/integrations/go2rtc/) - top open-source smart home project - [Frigate](https://frigate.video/) [0.12+](https://docs.frigate.video/guides/configuring_go2rtc/) - open-source NVR built around real-time AI object detection @@ -1467,7 +1466,7 @@ streams: - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) -## Camera experience +# Camera experience - [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients - [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol implementation, many bugs in SDP @@ -1477,7 +1476,7 @@ streams: - [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss? - Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usually have `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol implementation, low stream quality, few settings, packet loss? -## TIPS +# TIPS **Using apps for low RTSP delay** From 338a3a6f036617b82e3a44e8b7e546a119c1cf9f Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 18:40:18 +0300 Subject: [PATCH 222/241] Move doorbird docs to separate file --- README.md | 16 ++-------------- internal/doorbird/README.md | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 internal/doorbird/README.md diff --git a/README.md b/README.md index 902cb4f8..bdc0af74 100644 --- a/README.md +++ b/README.md @@ -725,21 +725,9 @@ If you have a graphic PIN for your vacuum, add it as a numeric PIN (lines: 123, #### Source: Doorbird -*[New in v1.9.11](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)* +This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio. -This source type supports Doorbird devices including MJPEG stream, audio stream as well as two-way audio. -It is recommended to create a sepearate user within your doorbird setup for go2rtc. Minimum permissions for the user are: -- Watch always -- API operator - -```yaml -streams: - doorbird1: - - rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream - - doorbird://admin:password@192.168.1.123?media=video # MJPEG stream - - doorbird://admin:password@192.168.1.123?media=audio # audio stream - - doorbird://admin:password@192.168.1.123 # two-way audio -``` +*[read more](internal/doorbird/README.md)* #### Source: WebRTC diff --git a/internal/doorbird/README.md b/internal/doorbird/README.md new file mode 100644 index 00000000..7c31efae --- /dev/null +++ b/internal/doorbird/README.md @@ -0,0 +1,21 @@ +# Doorbird + +*[added in v1.9.8](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)* + +This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio. + +It is recommended to create a sepearate user within your doorbird setup for go2rtc. Minimum permissions for the user are: + +- Watch always +- API operator + +## Configuration + +```yaml +streams: + doorbird1: + - rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream + - doorbird://admin:password@192.168.1.123?media=video # MJPEG stream + - doorbird://admin:password@192.168.1.123?media=audio # audio stream + - doorbird://admin:password@192.168.1.123 # two-way audio +``` From 9d1e4b11d7569aa38365cc81779888c3e59a473f Mon Sep 17 00:00:00 2001 From: Aram Akhavan Date: Sat, 17 Jan 2026 08:14:07 -0800 Subject: [PATCH 223/241] Add EXPOSE to Dockerfile (#2010) * Expose ports 1984 and 8554 in Dockerfile * Update Dockerfile * Update hardware.Dockerfile * Expose additional ports in Rockchip Dockerfile * Move docker expose ports to single line --------- Co-authored-by: Alex X --- docker/Dockerfile | 1 + docker/hardware.Dockerfile | 1 + docker/rockchip.Dockerfile | 1 + 3 files changed, 3 insertions(+) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8d064f21..9efded4b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -47,6 +47,7 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver COPY --from=build /build/go2rtc /usr/local/bin/ +EXPOSE 1984 8554 8555 8555/udp ENTRYPOINT ["/sbin/tini", "--"] VOLUME /config WORKDIR /config diff --git a/docker/hardware.Dockerfile b/docker/hardware.Dockerfile index a80d08d7..563843b5 100644 --- a/docker/hardware.Dockerfile +++ b/docker/hardware.Dockerfile @@ -49,6 +49,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t COPY --from=build /build/go2rtc /usr/local/bin/ +EXPOSE 1984 8554 8555 8555/udp ENTRYPOINT ["/usr/bin/tini", "--"] VOLUME /config WORKDIR /config diff --git a/docker/rockchip.Dockerfile b/docker/rockchip.Dockerfile index 949db83b..6ab924ee 100644 --- a/docker/rockchip.Dockerfile +++ b/docker/rockchip.Dockerfile @@ -43,6 +43,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t COPY --from=build /build/go2rtc /usr/local/bin/ ADD --chmod=755 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-8-no_extra_dump/ffmpeg /usr/local/bin +EXPOSE 1984 8554 8555 8555/udp ENTRYPOINT ["/usr/bin/tini", "--"] VOLUME /config WORKDIR /config From 572f07fcceb5e2844a43a4a424b0279770a5f828 Mon Sep 17 00:00:00 2001 From: forrestsocool Date: Sat, 17 Jan 2026 10:39:49 -0800 Subject: [PATCH 224/241] Add two-way talk support for tp-link ipc camera (#1995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add tplink multitrans support * 已经能成功播放音频了 * 初步能通讯了 * finish * add tplink multitrans support * 已经能成功播放音频了 * 初步能通讯了 * cleanup * clean up * Code refactoring for #1995 --------- Co-authored-by: Your Name Co-authored-by: Alex X --- internal/multitrans/multitrans.go | 10 ++ main.go | 2 + pkg/multitrans/client.go | 203 ++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 internal/multitrans/multitrans.go create mode 100644 pkg/multitrans/client.go diff --git a/internal/multitrans/multitrans.go b/internal/multitrans/multitrans.go new file mode 100644 index 00000000..31e6a9a4 --- /dev/null +++ b/internal/multitrans/multitrans.go @@ -0,0 +1,10 @@ +package multitrans + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/multitrans" +) + +func Init() { + streams.HandleFunc("multitrans", multitrans.Dial) +} diff --git a/main.go b/main.go index df5322eb..02d11cd8 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/mjpeg" "github.com/AlexxIT/go2rtc/internal/mp4" "github.com/AlexxIT/go2rtc/internal/mpegts" + "github.com/AlexxIT/go2rtc/internal/multitrans" "github.com/AlexxIT/go2rtc/internal/nest" "github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/onvif" @@ -95,6 +96,7 @@ func main() { {"isapi", isapi.Init}, {"ivideon", ivideon.Init}, {"mpegts", mpegts.Init}, + {"multitrans", multitrans.Init}, {"nest", nest.Init}, {"ring", ring.Init}, {"roborock", roborock.Init}, diff --git a/pkg/multitrans/client.go b/pkg/multitrans/client.go new file mode 100644 index 00000000..d71269c1 --- /dev/null +++ b/pkg/multitrans/client.go @@ -0,0 +1,203 @@ +package multitrans + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "net" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/google/uuid" + "github.com/pion/rtp" +) + +type Client struct { + core.Connection + conn net.Conn + rd *bufio.Reader + closed core.Waiter +} + +func Dial(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.Port() == "" { + u.Host += ":554" + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + c := &Client{ + conn: conn, + rd: bufio.NewReader(conn), + } + + if err = c.handshake(u); err != nil { + _ = conn.Close() + return nil, err + } + + c.Connection = core.Connection{ + ID: core.NewID(), + FormatName: "multitrans", + Protocol: "rtsp", + RemoteAddr: conn.RemoteAddr().String(), + Source: rawURL, + Medias: []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{{Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}}, + }, + }, + Transport: conn, + } + + return c, nil +} + +func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: packet.Marker, + PayloadType: 8, + SequenceNumber: packet.SequenceNumber, + Timestamp: packet.Timestamp, + SSRC: packet.SSRC, + }, + Payload: packet.Payload, + } + + // Encapsulate in RTSP Interleaved Frame (Channel 1) + // $ + Channel(1 byte) + Length(2 bytes) + Packet + size := 12 + len(clone.Payload) + b := make([]byte, 4+size) + b[0] = '$' + b[1] = 1 // Channel 1 for audio + b[2] = byte(size >> 8) + b[3] = byte(size) + if _, err := clone.MarshalTo(b[4:]); err != nil { + return + } + if _, err := c.conn.Write(b); err != nil { + return + } + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Client) handshake(u *url.URL) error { + // Step 1: Get Challenge + uid := uuid.New().String() + + uri := fmt.Sprintf("rtsp://%s/multitrans", u.Host) + data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 0\r\nX-Client-UUID: %s\r\n\r\n", uri, uid) + + if _, err := c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err := tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusUnauthorized { + return errors.New("multitrans: expected 401, got " + res.Status) + } + + auth := res.Header.Get("WWW-Authenticate") + realm := tcp.Between(auth, `realm="`, `"`) + nonce := tcp.Between(auth, `nonce="`, `"`) + + // Step 2: Send Auth + user := u.User.Username() + pass, _ := u.User.Password() + + ha1 := tcp.HexMD5(user, realm, pass) + ha2 := tcp.HexMD5("MULTITRANS", uri) + response := tcp.HexMD5(ha1, nonce, ha2) + + authHeader := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, + user, realm, nonce, uri, response) + + data = fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 1\r\nAuthorization: %s\r\nX-Client-UUID: %s\r\n\r\n", + uri, authHeader, uid) + + if _, err = c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err = tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("multitrans: auth failed: " + res.Status) + } + + // Session: 7116520596809429228 + session := res.Header.Get("Session") + if session == "" { + return errors.New("multitrans: no session") + } + + return c.openTalkChannel(uri, session) +} + +func (c *Client) openTalkChannel(uri, session string) error { + payload := `{"type":"request","seq":0,"params":{"method":"get","talk":{"mode":"full_duplex"}}}` + + data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 2\r\nSession: %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", + uri, session, len(payload), payload) + + if _, err := c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err := tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("multitrans: talkback failed: " + res.Status) + } + + // Python checks for "error_code":0 in body. + if !bytes.Contains(res.Body, []byte(`"error_code":0`)) { + return fmt.Errorf("multitrans: talkback error: %s", string(res.Body)) + } + + return nil +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Client) Start() error { + _ = c.closed.Wait() + return nil +} + +func (c *Client) Stop() error { + c.closed.Done(nil) + return c.Connection.Stop() +} From 6a5deecfccde84070f2cf7acd569613c61fc2d60 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sat, 17 Jan 2026 21:53:37 +0300 Subject: [PATCH 225/241] Add multitrans source to readme --- README.md | 11 +++++++++-- internal/multitrans/README.md | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 internal/multitrans/README.md diff --git a/README.md b/README.md index 5f2f266d..90109ddc 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - devices: `alsa` (Linux audio), `v4l2` (Linux video) - files: `adts`, `flv`, `h264`, `hevc`, `hls`, `mjpeg`, `mpegts`, `mp4`, `wav` -- network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `y2m` (yuv4mpegpipe) -- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `xiaomi` (Mi Home) +- network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `yuv4mpegpipe` +- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `multitrans` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `xiaomi` (Mi Home) - webrtc related: `creality`, `kinesis` (Amazon), `openipc`, `switchbot`, `whep`, `whip`, `wyze` - other: `ascii`, `echo`, `exec`, `expr`, `ffmpeg` @@ -79,6 +79,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: Multitrans](#source-multitrans) * [Source: Tuya](#source-tuya) * [Source: Xiaomi](#source-xiaomi) * [Source: GoPro](#source-gopro) @@ -613,6 +614,12 @@ streams: Tested: KD110, KC200, KC401, KC420WS, EC71. +## Source: Multitrans + +Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-link.com.cn/list_2549.html). + +*[read more](internal/multitrans/README.md)* + ## Source: Tuya *[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* diff --git a/internal/multitrans/README.md b/internal/multitrans/README.md new file mode 100644 index 00000000..6201f8b6 --- /dev/null +++ b/internal/multitrans/README.md @@ -0,0 +1,16 @@ +# Multitrans + +**added in v1.9.14** by [@forrestsocool](https://github.com/forrestsocool) + +Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-link.com.cn/list_2549.html). + +## Configuration + +```yaml +streams: + tplink_cam: + # video use standard RTSP + - rtsp://admin:admin@192.168.1.202:554/stream1 + # two-way audio use MULTITRANS schema + - multitrans://admin:admin@192.168.1.202:554 +``` From 160695857e2a7c3d9e774e5cf7713f0fe224913b Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 20:22:08 +0100 Subject: [PATCH 226/241] refactor --- pkg/tutk/auth.go | 35 + pkg/{wyze => }/tutk/cipher.go | 2 +- pkg/tutk/codec.go | 50 + pkg/{wyze/tutk/conn.go => tutk/conn_dtls.go} | 496 ++++--- pkg/tutk/crypto.go | 102 ++ pkg/{wyze => }/tutk/dtls.go | 61 +- pkg/{wyze => }/tutk/frame.go | 114 +- pkg/tutk/helpers.go | 52 +- pkg/tutk/session0.go | 6 - pkg/wyze/backchannel.go | 2 +- pkg/wyze/client.go | 73 +- pkg/wyze/crypto/transcode.go | 143 -- pkg/wyze/crypto/xxtea.go | 147 -- pkg/wyze/producer.go | 28 +- pkg/wyze/tutk/README.md | 1329 ------------------ pkg/wyze/tutk/proto.go | 281 ---- pkg/xiaomi/legacy/client.go | 2 +- pkg/xiaomi/legacy/producer.go | 4 +- 18 files changed, 613 insertions(+), 2314 deletions(-) create mode 100644 pkg/tutk/auth.go rename pkg/{wyze => }/tutk/cipher.go (99%) create mode 100644 pkg/tutk/codec.go rename pkg/{wyze/tutk/conn.go => tutk/conn_dtls.go} (59%) rename pkg/{wyze => }/tutk/dtls.go (62%) rename pkg/{wyze => }/tutk/frame.go (84%) delete mode 100644 pkg/wyze/crypto/transcode.go delete mode 100644 pkg/wyze/crypto/xxtea.go delete mode 100644 pkg/wyze/tutk/README.md delete mode 100644 pkg/wyze/tutk/proto.go diff --git a/pkg/tutk/auth.go b/pkg/tutk/auth.go new file mode 100644 index 00000000..8dca29aa --- /dev/null +++ b/pkg/tutk/auth.go @@ -0,0 +1,35 @@ +package tutk + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +func CalculateAuthKey(enr, mac string) []byte { + data := enr + strings.ToUpper(mac) + hash := sha256.Sum256([]byte(data)) + b64 := base64.StdEncoding.EncodeToString(hash[:6]) + b64 = strings.ReplaceAll(b64, "+", "Z") + b64 = strings.ReplaceAll(b64, "/", "9") + b64 = strings.ReplaceAll(b64, "=", "A") + return []byte(b64) +} + +func DerivePSK(enr string) []byte { + // DerivePSK derives the DTLS PSK from ENR + // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) + // contains a 0x00 byte, the PSK is truncated at that position. + hash := sha256.Sum256([]byte(enr)) + pskLen := 32 + for i := range 32 { + if hash[i] == 0x00 { + pskLen = i + break + } + } + + psk := make([]byte, 32) + copy(psk[:pskLen], hash[:pskLen]) + return psk +} diff --git a/pkg/wyze/tutk/cipher.go b/pkg/tutk/cipher.go similarity index 99% rename from pkg/wyze/tutk/cipher.go rename to pkg/tutk/cipher.go index 85831abe..0a238fa3 100644 --- a/pkg/wyze/tutk/cipher.go +++ b/pkg/tutk/cipher.go @@ -72,7 +72,7 @@ func computeNonce(iv []byte, epoch uint16, sequenceNumber uint64) []byte { binary.BigEndian.PutUint64(nonce[4:], sequenceNumber) binary.BigEndian.PutUint16(nonce[4:], epoch) - for i := 0; i < chachaNonceLength; i++ { + for i := range chachaNonceLength { nonce[i] ^= iv[i] } diff --git a/pkg/tutk/codec.go b/pkg/tutk/codec.go new file mode 100644 index 00000000..68ca72ca --- /dev/null +++ b/pkg/tutk/codec.go @@ -0,0 +1,50 @@ +package tutk + +// https://github.com/seydx/tutk_wyze#11-codec-reference +const ( + CodecMPEG4 byte = 0x4C + CodecH263 byte = 0x4D + CodecH264 byte = 0x4E + CodecMJPEG byte = 0x4F + CodecH265 byte = 0x50 +) + +const ( + CodecAACRaw byte = 0x86 + CodecAACADTS byte = 0x87 + CodecAACLATM byte = 0x88 + CodecPCMU byte = 0x89 + CodecPCMA byte = 0x8A + CodecADPCM byte = 0x8B + CodecPCML byte = 0x8C + CodecSPEEX byte = 0x8D + CodecMP3 byte = 0x8E + CodecG726 byte = 0x8F + CodecAACAlt byte = 0x90 + CodecOpus byte = 0x92 +) + +var sampleRates = [9]uint32{8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000} + +func GetSamplesPerFrame(codecID byte) uint32 { + switch codecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + return 1024 + case CodecPCMU, CodecPCMA, CodecPCML, CodecADPCM, CodecSPEEX, CodecG726: + return 160 + case CodecMP3: + return 1152 + case CodecOpus: + return 960 + default: + return 1024 + } +} + +func IsVideoCodec(id byte) bool { + return id >= CodecMPEG4 && id <= CodecH265 +} + +func IsAudioCodec(id byte) bool { + return id >= CodecAACRaw && id <= CodecOpus +} diff --git a/pkg/wyze/tutk/conn.go b/pkg/tutk/conn_dtls.go similarity index 59% rename from pkg/wyze/tutk/conn.go rename to pkg/tutk/conn_dtls.go index fc16da27..eccd985f 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/tutk/conn_dtls.go @@ -3,31 +3,71 @@ package tutk import ( "context" "crypto/hmac" - "crypto/rand" "crypto/sha1" - "crypto/sha256" "encoding/binary" - "encoding/hex" "fmt" "io" "net" "sync" "time" - "github.com/AlexxIT/go2rtc/pkg/wyze/crypto" "github.com/pion/dtls/v3" ) const ( - MaxPacketSize = 2048 - ReadBufferSize = 2 * 1024 * 1024 - DiscoTimeout = 5000 * time.Millisecond - DiscoInterval = 100 * time.Millisecond - SessionTimeout = 5000 * time.Millisecond - ReadWaitInterval = 50 * time.Millisecond + magicCC51 = "\x51\xcc" // (wyze specific?) + sdkVersion42 = "\x01\x01\x02\x04" // 4.2.1.1 + sdkVersion43 = "\x00\x08\x03\x04" // 4.3.8.0 ) -type Conn struct { +const ( + cmdDiscoReq uint16 = 0x0601 + cmdDiscoRes uint16 = 0x0602 + cmdSessionReq uint16 = 0x0402 + cmdSessionRes uint16 = 0x0404 + cmdDataTX uint16 = 0x0407 + cmdDataRX uint16 = 0x0408 + cmdKeepaliveReq uint16 = 0x0427 + cmdKeepaliveRes uint16 = 0x0428 + + headerSize = 16 + discoBodySize = 72 + discoSize = headerSize + discoBodySize + sessionBody = 36 + sessionSize = headerSize + sessionBody +) + +const ( + cmdDiscoCC51 uint16 = 0x1002 + cmdKeepaliveCC51 uint16 = 0x1202 + cmdDTLSCC51 uint16 = 0x1502 + payloadSizeCC51 uint16 = 0x0028 + packetSizeCC51 = 52 + headerSizeCC51 = 28 + authSizeCC51 = 20 + keepaliveSizeCC51 = 48 +) + +const ( + magicAVLoginResp uint16 = 0x2100 + magicIOCtrl uint16 = 0x7000 + magicChannelMsg uint16 = 0x1000 + magicACK uint16 = 0x0009 + magicAVLogin1 uint16 = 0x0000 + magicAVLogin2 uint16 = 0x2000 +) + +const ( + protoVersion uint16 = 0x000c + defaultCaps uint32 = 0x001f07fb +) + +const ( + iotcChannelMain = 0 // Main AV (we = DTLS Client) + iotcChannelBack = 1 // Backchannel (we = DTLS Server) +) + +type DTLSConn struct { conn *net.UDPConn addr *net.UDPAddr frames *FrameHandler @@ -49,17 +89,15 @@ type Conn struct { uid string authKey string enr string - mac string psk []byte - rid []byte // Session - sid []byte - ticket uint16 - avResp *AVLoginResponse + sid []byte + ticket uint16 + hasTwoWayStreaming bool // Protocol - newProto bool + isCC51 bool seq uint16 seqCmd uint16 avSeq uint32 @@ -75,34 +113,32 @@ type Conn struct { cmdAck func() } -func Dial(host string, port int, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { +func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*DTLSConn, error) { udp, err := net.ListenUDP("udp", nil) if err != nil { return nil, err } - _ = udp.SetReadBuffer(ReadBufferSize) + _ = udp.SetReadBuffer(2 * 1024 * 1024) ctx, cancel := context.WithCancel(context.Background()) - psk := derivePSK(enr) + psk := DerivePSK(enr) if port == 0 { - port = DefaultPort + port = 32761 } - c := &Conn{ + c := &DTLSConn{ conn: udp, addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, - rid: genRandomID(), uid: uid, authKey: authKey, enr: enr, - mac: mac, psk: psk, verbose: verbose, ctx: ctx, cancel: cancel, - rxSeqStart: 0xffff, // Initialize RX seq for ACK + rxSeqStart: 0xffff, rxSeqEnd: 0xffff, } @@ -130,10 +166,10 @@ func Dial(host string, port int, uid, authKey, enr, mac string, verbose bool) (* return c, nil } -func (c *Conn) AVClientStart(timeout time.Duration) error { - randomID := genRandomID() - pkt1 := c.buildAVLoginPacket(MagicAVLogin1, 570, 0x0001, randomID) - pkt2 := c.buildAVLoginPacket(MagicAVLogin2, 572, 0x0000, randomID) +func (c *DTLSConn) AVClientStart(timeout time.Duration) error { + randomID := GenSessionID() + pkt1 := c.msgAVLogin(magicAVLogin1, 570, 0x0001, randomID) + pkt2 := c.msgAVLogin(magicAVLogin2, 572, 0x0000, randomID) pkt2[20]++ // pkt2 has randomID incremented by 1 if _, err := c.clientConn.Write(pkt1); err != nil { @@ -155,16 +191,13 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { if !ok { return io.EOF } - if len(data) >= 32 && binary.LittleEndian.Uint16(data) == MagicAVLoginResp { - c.avResp = &AVLoginResponse{ - ServerType: binary.LittleEndian.Uint32(data[4:]), - Resend: int32(data[29]), - TwoWayStreaming: int32(data[31]), - } + if len(data) >= 32 && binary.LittleEndian.Uint16(data) == magicAVLoginResp { + c.hasTwoWayStreaming = data[31] == 1 - ack := c.buildACK() + ack := c.msgACK() c.clientConn.Write(ack) + // Start ACK sender for continuous streaming c.wg.Add(1) go func() { defer c.wg.Done() @@ -177,7 +210,7 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { return case <-ackTicker.C: if c.clientConn != nil { - ack := c.buildACK() + ack := c.msgACK() c.clientConn.Write(ack) } } @@ -192,14 +225,9 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { } } -func (c *Conn) AVServStart() error { - if c.verbose { - fmt.Printf("[DTLS] Waiting for client handshake on channel %d\n", IOTCChannelBack) - fmt.Printf("[DTLS] PSK Identity: %s\n", PSKIdentity) - fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) - } - - conn, err := NewDtlsServer(c, IOTCChannelBack, c.psk) +func (c *DTLSConn) AVServStart() error { + adapter := NewChannelAdapter(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf) + conn, err := NewDTLSServer(adapter, c.addr, c.psk) if err != nil { return fmt.Errorf("dtls: server handshake failed: %w", err) } @@ -209,7 +237,7 @@ func (c *Conn) AVServStart() error { c.mu.Unlock() if c.verbose { - fmt.Printf("[DTLS] Server handshake complete on channel %d\n", IOTCChannelBack) + fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack) } // Wait for and respond to AV Login request from camera @@ -220,10 +248,11 @@ func (c *Conn) AVServStart() error { return nil } -func (c *Conn) AVServStop() error { +func (c *DTLSConn) AVServStop() error { c.mu.Lock() serverConn := c.serverConn c.serverConn = nil + // Reset audio TX state c.audioSeq = 0 c.audioFrameNo = 0 @@ -238,7 +267,7 @@ func (c *Conn) AVServStop() error { return nil } -func (c *Conn) AVRecvFrameData() (*Packet, error) { +func (c *DTLSConn) AVRecvFrameData() (*Packet, error) { select { case pkt, ok := <-c.frames.Recv(): if !ok { @@ -250,7 +279,7 @@ func (c *Conn) AVRecvFrameData() (*Packet, error) { } } -func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { +func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { c.mu.Lock() conn := c.serverConn if conn == nil { @@ -258,7 +287,7 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, return fmt.Errorf("speaker channel not connected") } - frame := c.buildAudioFrame(payload, timestampUS, codec, sampleRate, channels) + frame := c.msgAudioFrame(payload, timestampUS, codec, sampleRate, channels) c.mu.Unlock() @@ -273,35 +302,27 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, return err } -func (c *Conn) Write(data []byte) error { - // if c.verbose { - // fmt.Printf("[UDP TX] to=%s, len=%d, data:\n%s", c.addr.String(), len(data), hexDump(data)) - // } - - if c.newProto { +func (c *DTLSConn) Write(data []byte) error { + if c.isCC51 { _, err := c.conn.WriteToUDP(data, c.addr) return err } - _, err := c.conn.WriteToUDP(crypto.TransCodeBlob(data), c.addr) + _, err := c.conn.WriteToUDP(TransCodeBlob(data), c.addr) return err } -func (c *Conn) WriteDTLS(payload []byte, channel byte) error { +func (c *DTLSConn) WriteDTLS(payload []byte, channel byte) error { var frame []byte - if c.newProto { - frame = c.buildNewTxData(payload, channel) + if c.isCC51 { + frame = c.msgTxDataCC51(payload, channel) } else { - frame = c.buildTxData(payload, channel) + frame = c.msgTxData(payload, channel) } - // if c.verbose { - // fmt.Printf("[DTLS TX] to=%s, len=%d, channel=%d, data:\n%s", c.addr.String(), len(frame), channel, hexDump(frame)) - // } - return c.Write(frame) } -func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byte) bool) ([]byte, error) { +func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, error) { var t *time.Timer t = time.AfterFunc(1, func() { if err := c.Write(req); err == nil && t != nil { @@ -310,10 +331,10 @@ func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byt }) defer t.Stop() - _ = c.conn.SetDeadline(time.Now().Add(timeout)) + _ = c.conn.SetDeadline(time.Now().Add(5000 * time.Millisecond)) defer c.conn.SetDeadline(time.Time{}) - buf := make([]byte, MaxPacketSize) + buf := make([]byte, 2048) for { n, addr, err := c.conn.ReadFromUDP(buf) if err != nil { @@ -324,10 +345,10 @@ func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byt } var res []byte - if c.newProto { + if c.isCC51 { res = buf[:n] } else { - res = crypto.ReverseTransCodeBlob(buf[:n]) + res = ReverseTransCodeBlob(buf[:n]) } if ok(res) { @@ -337,8 +358,8 @@ func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byt } } -func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, timeout time.Duration) ([]byte, error) { - frame := c.buildIOCtrlFrame(payload) +func (c *DTLSConn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, timeout time.Duration) ([]byte, error) { + frame := c.msgIOCtrl(payload) var t *time.Timer t = time.AfterFunc(1, func() { c.mu.RLock() @@ -362,7 +383,7 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, return nil, io.EOF } - ack := c.buildACK() + ack := c.msgACK() c.clientConn.Write(ack) if len(data) >= 6 { @@ -376,29 +397,29 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, } } -func (c *Conn) GetAVLoginResponse() *AVLoginResponse { - return c.avResp +func (c *DTLSConn) HasTwoWayStreaming() bool { + return c.hasTwoWayStreaming } -func (c *Conn) IsBackchannelReady() bool { +func (c *DTLSConn) IsBackchannelReady() bool { c.mu.RLock() defer c.mu.RUnlock() return c.serverConn != nil } -func (c *Conn) RemoteAddr() *net.UDPAddr { +func (c *DTLSConn) RemoteAddr() *net.UDPAddr { return c.addr } -func (c *Conn) LocalAddr() *net.UDPAddr { +func (c *DTLSConn) LocalAddr() *net.UDPAddr { return c.conn.LocalAddr().(*net.UDPAddr) } -func (c *Conn) SetDeadline(t time.Time) error { +func (c *DTLSConn) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) } -func (c *Conn) Close() error { +func (c *DTLSConn) Close() error { c.cancel() c.mu.Lock() @@ -416,27 +437,27 @@ func (c *Conn) Close() error { return c.conn.Close() } -func (c *Conn) Error() error { +func (c *DTLSConn) Error() error { if c.err != nil { return c.err } return io.EOF } -func (c *Conn) discovery() error { - c.sid = make([]byte, 8) - rand.Read(c.sid) +func (c *DTLSConn) discovery() error { + c.sid = GenSessionID() - oldPkt := crypto.TransCodeBlob(c.buildDisco(1)) - newPkt := c.buildNewDisco(0, 0, false) - buf := make([]byte, MaxPacketSize) - deadline := time.Now().Add(DiscoTimeout) + pktIOTC := TransCodeBlob(c.msgDisco(1)) + pktCC51 := c.msgDiscoCC51(0, 0, false) + + buf := make([]byte, 2048) + deadline := time.Now().Add(5000 * time.Millisecond) for time.Now().Before(deadline) { - c.conn.WriteToUDP(oldPkt, c.addr) - c.conn.WriteToUDP(newPkt, c.addr) + c.conn.WriteToUDP(pktIOTC, c.addr) + c.conn.WriteToUDP(pktCC51, c.addr) - c.conn.SetReadDeadline(time.Now().Add(DiscoInterval)) + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) n, addr, err := c.conn.ReadFromUDP(buf) if err != nil { continue @@ -445,59 +466,54 @@ func (c *Conn) discovery() error { continue } - // NEW protocol - if n >= NewPacketSize && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { - if binary.LittleEndian.Uint16(buf[4:]) == CmdNewDisco { - c.addr, c.newProto, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) + // CC51 protocol + if n >= packetSizeCC51 && string(buf[:2]) == magicCC51 { + if binary.LittleEndian.Uint16(buf[4:]) == cmdDiscoCC51 { + c.addr, c.isCC51, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) if n >= 24 { copy(c.sid, buf[16:24]) } - return c.newDiscoDone() + return c.discoDoneCC51() } continue } - // OLD protocol - data := crypto.ReverseTransCodeBlob(buf[:n]) - if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == CmdDiscoRes { - c.addr, c.newProto = addr, false - return c.oldDiscoDone() + // IOTC Protocol (Basis) + data := ReverseTransCodeBlob(buf[:n]) + if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == cmdDiscoRes { + c.addr, c.isCC51 = addr, false + return c.discoDone() } } return fmt.Errorf("discovery timeout") } -func (c *Conn) oldDiscoDone() error { - c.Write(c.buildDisco(2)) +func (c *DTLSConn) discoDone() error { + c.Write(c.msgDisco(2)) time.Sleep(100 * time.Millisecond) - _, err := c.WriteAndWait(c.buildSession(), SessionTimeout, func(res []byte) bool { - return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == CmdSessionRes + _, err := c.WriteAndWait(c.msgSession(), func(res []byte) bool { + return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == cmdSessionRes }) return err } -func (c *Conn) newDiscoDone() error { - _, err := c.WriteAndWait(c.buildNewDisco(2, c.ticket, false), SessionTimeout, func(res []byte) bool { - if len(res) < NewPacketSize || binary.LittleEndian.Uint16(res[:2]) != MagicNewProto { +func (c *DTLSConn) discoDoneCC51() error { + _, err := c.WriteAndWait(c.msgDiscoCC51(2, c.ticket, false), func(res []byte) bool { + if len(res) < packetSizeCC51 || string(res[:2]) != magicCC51 { return false } cmd := binary.LittleEndian.Uint16(res[4:]) dir := binary.LittleEndian.Uint16(res[8:]) seq := binary.LittleEndian.Uint16(res[12:]) - return cmd == CmdNewDisco && dir == 0xFFFF && seq == 3 + return cmd == cmdDiscoCC51 && dir == 0xFFFF && seq == 3 }) return err } -func (c *Conn) connect() error { - if c.verbose { - fmt.Printf("[DTLS] Starting client handshake on channel %d\n", IOTCChannelMain) - fmt.Printf("[DTLS] PSK Identity: %s\n", PSKIdentity) - fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) - } - - conn, err := NewDtlsClient(c, IOTCChannelMain, c.psk) +func (c *DTLSConn) connect() error { + adapter := NewChannelAdapter(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf) + conn, err := NewDTLSClient(adapter, c.addr, c.psk) if err != nil { return fmt.Errorf("dtls: client create failed: %w", err) } @@ -507,13 +523,13 @@ func (c *Conn) connect() error { c.mu.Unlock() if c.verbose { - fmt.Printf("[DTLS] Client created for channel %d\n", IOTCChannelMain) + fmt.Printf("[DTLS] Client created for channel %d\n", iotcChannelMain) } return nil } -func (c *Conn) worker() { +func (c *DTLSConn) worker() { defer c.wg.Done() buf := make([]byte, 2048) @@ -538,15 +554,11 @@ func (c *Conn) worker() { data := buf[:n] magic := binary.LittleEndian.Uint16(data) - // if c.verbose { - // fmt.Printf("[DTLS RX] from=%s, len=%d, data:\n%s", c.addr.String(), n, hexDump(data)) - // } - switch magic { - case MagicAVLoginResp: + case magicAVLoginResp: c.queue(c.rawCmd, data) - case MagicIOCtrl: + case magicIOCtrl: if len(data) >= 32 { for i := 32; i+2 < len(data); i++ { if data[i] == 'H' && data[i+1] == 'L' { @@ -556,7 +568,7 @@ func (c *Conn) worker() { } } - case MagicChannelMsg: + case magicChannelMsg: if len(data) >= 36 && data[16] == 0x00 { for i := 36; i+2 < len(data); i++ { if data[i] == 'H' && data[i+1] == 'L' { @@ -566,7 +578,7 @@ func (c *Conn) worker() { } } - case ProtoVersion: + case protoVersion: if len(data) >= 8 { // Extract seq number at byte 4-5 (uint16 of uint32 AVSeq) seq := binary.LittleEndian.Uint16(data[4:]) @@ -589,7 +601,7 @@ func (c *Conn) worker() { } } - case MagicACK: + case magicACK: c.mu.RLock() ack := c.cmdAck c.mu.RUnlock() @@ -606,9 +618,10 @@ func (c *Conn) worker() { } } -func (c *Conn) reader() { +func (c *DTLSConn) reader() { defer c.wg.Done() - buf := make([]byte, MaxPacketSize) + + buf := make([]byte, 2048) for { select { @@ -626,10 +639,6 @@ func (c *Conn) reader() { return } - // if c.verbose { - // fmt.Printf("[UDP RX] from=%s, len=%d, data:\n%s", addr.String(), n, hexDump(buf[:n])) - // } - if !addr.IP.Equal(c.addr.IP) { if c.verbose { fmt.Printf("Ignored packet from unknown IP: %s\n", addr.IP.String()) @@ -640,47 +649,47 @@ func (c *Conn) reader() { c.addr.Port = addr.Port } - // NEW protocol (0xCC51) - if c.newProto && n >= 12 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + // CC51 Protocol + if c.isCC51 && n >= 12 && string(buf[:2]) == magicCC51 { cmd := binary.LittleEndian.Uint16(buf[4:]) switch cmd { - case CmdNewKeepalive: - if n >= NewKeepaliveSize { - _ = c.Write(c.buildNewKeepalive()) + case cmdKeepaliveCC51: + if n >= keepaliveSizeCC51 { + _ = c.Write(c.msgKeepaliveCC51()) } - case CmdNewDTLS: - if n >= NewHeaderSize+NewAuthSize { + case cmdDTLSCC51: + if n >= headerSizeCC51+authSizeCC51 { ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) - dtls := buf[NewHeaderSize : n-NewAuthSize] + dtlsData := buf[headerSizeCC51 : n-authSizeCC51] switch ch { - case IOTCChannelMain: - c.queue(c.clientBuf, dtls) - case IOTCChannelBack: - c.queue(c.serverBuf, dtls) + case iotcChannelMain: + c.queue(c.clientBuf, dtlsData) + case iotcChannelBack: + c.queue(c.serverBuf, dtlsData) } } } continue } - // OLD protocol (TransCode) - data := crypto.ReverseTransCodeBlob(buf[:n]) + // IOTC Protocol (Basis) + data := ReverseTransCodeBlob(buf[:n]) if len(data) < 16 { continue } switch binary.LittleEndian.Uint16(data[8:]) { - case CmdKeepaliveRes: + case cmdKeepaliveRes: if len(data) > 24 { - _ = c.Write(c.buildKeepAlive(data[16:])) + _ = c.Write(c.msgKeepalive(data[16:])) } - case CmdDataRX: + case cmdDataRX: if len(data) > 28 { ch := data[14] switch ch { - case IOTCChannelMain: + case iotcChannelMain: c.queue(c.clientBuf, data[28:]) - case IOTCChannelBack: + case iotcChannelBack: c.queue(c.serverBuf, data[28:]) } } @@ -688,7 +697,7 @@ func (c *Conn) reader() { } } -func (c *Conn) queue(ch chan []byte, data []byte) { +func (c *DTLSConn) queue(ch chan []byte, data []byte) { b := make([]byte, len(data)) copy(b, data) select { @@ -702,7 +711,7 @@ func (c *Conn) queue(ch chan []byte, data []byte) { } } -func (c *Conn) handleSpeakerAVLogin() error { +func (c *DTLSConn) handleSpeakerAVLogin() error { if c.verbose { fmt.Printf("[SPEAK] Waiting for AV Login request from camera...\n") } @@ -723,7 +732,7 @@ func (c *Conn) handleSpeakerAVLogin() error { } checksum := binary.LittleEndian.Uint32(buf[20:]) - resp := c.buildAVLoginResponse(checksum) + resp := c.msgAVLoginResponse(checksum) if c.verbose { fmt.Printf("[SPEAK] Sending AV Login response: %d bytes\n", len(resp)) @@ -751,16 +760,16 @@ func (c *Conn) handleSpeakerAVLogin() error { return nil } -func (c *Conn) buildDisco(stage byte) []byte { - b := make([]byte, OldDiscoSize) - copy(b, "\x04\x02\x1a\x02") // marker + mode - binary.LittleEndian.PutUint16(b[4:], OldDiscoBodySize) // body size - binary.LittleEndian.PutUint16(b[8:], CmdDiscoReq) // 0x0601 - binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags - body := b[OldHeaderSize:] - copy(body[:UIDSize], c.uid) - copy(body[36:], "\x01\x01\x02\x04") // unknown - copy(body[40:], c.rid) +func (c *DTLSConn) msgDisco(stage byte) []byte { + b := make([]byte, discoSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], discoBodySize) // body size + binary.LittleEndian.PutUint16(b[8:], cmdDiscoReq) // 0x0601 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[36:], sdkVersion42) // SDK 4.2.1.1 + copy(body[40:], c.sid) body[48] = stage if stage == 1 && len(c.authKey) > 0 { copy(body[58:], c.authKey) @@ -768,69 +777,67 @@ func (c *Conn) buildDisco(stage byte) []byte { return b } -func (c *Conn) buildNewDisco(seq, ticket uint16, isResponse bool) []byte { - b := make([]byte, NewPacketSize) - binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 - binary.LittleEndian.PutUint16(b[4:], CmdNewDisco) // 0x1002 - binary.LittleEndian.PutUint16(b[6:], NewPayloadSize) // 40 bytes +func (c *DTLSConn) msgDiscoCC51(seq, ticket uint16, isResponse bool) []byte { + b := make([]byte, packetSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDiscoCC51) // 0x1002 + binary.LittleEndian.PutUint16(b[6:], payloadSizeCC51) // 40 bytes if isResponse { binary.LittleEndian.PutUint16(b[8:], 0xFFFF) // response } binary.LittleEndian.PutUint16(b[12:], seq) binary.LittleEndian.PutUint16(b[14:], ticket) copy(b[16:24], c.sid) - copy(b[24:32], "\x00\x08\x03\x04\x1d\x00\x00\x00") // SDK 4.3.8.0 - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) + copy(b[24:28], sdkVersion43) // SDK 4.3.8.0 + b[28] = 0x1d // unknown field (capability/build flag?) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) h.Write(b[:32]) copy(b[32:52], h.Sum(nil)) return b } -func (c *Conn) buildNewKeepalive() []byte { +func (c *DTLSConn) msgKeepaliveCC51() []byte { c.kaSeq += 2 - b := make([]byte, NewKeepaliveSize) - binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 - binary.LittleEndian.PutUint16(b[4:], CmdNewKeepalive) // 0x1202 - binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload - binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter - copy(b[20:28], c.sid) // session ID - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) + b := make([]byte, keepaliveSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdKeepaliveCC51) // 0x1202 + binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload + binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter + copy(b[20:28], c.sid) // session ID + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) h.Write(b[:28]) copy(b[28:48], h.Sum(nil)) return b } -func (c *Conn) buildSession() []byte { - b := make([]byte, OldSessionSize) - copy(b, "\x04\x02\x1a\x02") // marker + mode - binary.LittleEndian.PutUint16(b[4:], OldSessionBody) // body size - binary.LittleEndian.PutUint16(b[8:], CmdSessionReq) // 0x0402 - binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags - body := b[OldHeaderSize:] - copy(body[:UIDSize], c.uid) - copy(body[UIDSize:], c.rid) +func (c *DTLSConn) msgSession() []byte { + b := make([]byte, sessionSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], sessionBody) // body size + binary.LittleEndian.PutUint16(b[8:], cmdSessionReq) // 0x0402 + binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[20:], c.sid) binary.LittleEndian.PutUint32(body[32:], uint32(time.Now().Unix())) return b } -func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID []byte) []byte { +func (c *DTLSConn) msgAVLogin(magic uint16, size int, flags uint16, randomID []byte) []byte { b := make([]byte, size) binary.LittleEndian.PutUint16(b, magic) - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) + binary.LittleEndian.PutUint16(b[2:], protoVersion) binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size binary.LittleEndian.PutUint16(b[18:], flags) copy(b[20:], randomID[:4]) - copy(b[24:], DefaultUser) // username - copy(b[280:], c.enr) // password/ENR - // binary.LittleEndian.PutUint32(b[536:], 1) // resend enabled + copy(b[24:], "admin") // username + copy(b[280:], c.enr) // password/ENR binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? - binary.LittleEndian.PutUint32(b[552:], DefaultCaps) // capabilities + binary.LittleEndian.PutUint32(b[552:], defaultCaps) // capabilities return b } -func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { +func (c *DTLSConn) msgAVLoginResponse(checksum uint32) []byte { b := make([]byte, 60) binary.LittleEndian.PutUint16(b, 0x2100) // magic binary.LittleEndian.PutUint16(b[2:], 0x000c) // version @@ -840,13 +847,13 @@ func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { b[29] = 0x01 // enable flag b[31] = 0x01 // two-way streaming binary.LittleEndian.PutUint32(b[36:], 0x04) // buffer config - binary.LittleEndian.PutUint32(b[40:], DefaultCaps) + binary.LittleEndian.PutUint32(b[40:], defaultCaps) binary.LittleEndian.PutUint16(b[54:], 0x0003) // channel info binary.LittleEndian.PutUint16(b[56:], 0x0002) return b } -func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { +func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, sampleRate uint32, channels uint8) []byte { c.audioSeq++ c.audioFrameNo++ prevFrame := uint32(0) @@ -860,7 +867,7 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, // Outer header (36 bytes) b[0] = ChannelAudio // 0x03 b[1] = FrameTypeStartAlt // 0x09 - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) + binary.LittleEndian.PutUint16(b[2:], protoVersion) binary.LittleEndian.PutUint32(b[4:], c.audioSeq) binary.LittleEndian.PutUint32(b[8:], timestampUS) if c.audioFrameNo == 1 { @@ -880,54 +887,65 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo) copy(b[36:], payload) // Payload + FrameInfo fi := b[36+len(payload):] - binary.LittleEndian.PutUint16(fi, codec) - fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) + fi[0] = codec // Codec ID (low byte) + fi[1] = 0 // Codec ID (high byte, unused) + // Audio flags: [3:2]=sampleRateIdx [1]=16bit [0]=stereo + var srIdx uint8 = 3 // default 16kHz + for i, rate := range sampleRates { + if rate == sampleRate { + srIdx = uint8(i) + break + } + } + fi[2] = (srIdx << 2) | 0x02 // 16-bit always set + if channels == 2 { + fi[2] |= 0x01 + } fi[4] = 1 // online binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*GetSamplesPerFrame(codec)*1000/sampleRate) return b } -func (c *Conn) buildTxData(payload []byte, channel byte) []byte { +func (c *DTLSConn) msgTxData(payload []byte, channel byte) []byte { bodySize := 12 + len(payload) b := make([]byte, 16+bodySize) copy(b, "\x04\x02\x1a\x0b") // marker + mode=data binary.LittleEndian.PutUint16(b[4:], uint16(bodySize)) // body size binary.LittleEndian.PutUint16(b[6:], c.seq) // sequence c.seq++ - binary.LittleEndian.PutUint16(b[8:], CmdDataTX) // 0x0407 + binary.LittleEndian.PutUint16(b[8:], cmdDataTX) // 0x0407 binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags - copy(b[12:], c.rid[:2]) // rid[0:2] + copy(b[12:], c.sid[:2]) // rid[0:2] b[14] = channel // channel b[15] = 0x01 // marker binary.LittleEndian.PutUint32(b[16:], 0x0000000c) // const - copy(b[20:], c.rid[:8]) // rid + copy(b[20:], c.sid[:8]) // rid copy(b[28:], payload) return b } -func (c *Conn) buildNewTxData(payload []byte, channel byte) []byte { - payloadSize := uint16(16 + len(payload) + NewAuthSize) - b := make([]byte, NewHeaderSize+len(payload)+NewAuthSize) - binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 - binary.LittleEndian.PutUint16(b[4:], CmdNewDTLS) // 0x1502 +func (c *DTLSConn) msgTxDataCC51(payload []byte, channel byte) []byte { + payloadSize := uint16(16 + len(payload) + authSizeCC51) + b := make([]byte, headerSizeCC51+len(payload)+authSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDTLSCC51) // 0x1502 binary.LittleEndian.PutUint16(b[6:], payloadSize) binary.LittleEndian.PutUint16(b[12:], uint16(0x0010)|(uint16(channel)<<8)) // channel in high byte binary.LittleEndian.PutUint16(b[14:], c.ticket) copy(b[16:24], c.sid) binary.LittleEndian.PutUint32(b[24:], 1) // const - copy(b[NewHeaderSize:], payload) - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) - h.Write(b[:NewHeaderSize]) - copy(b[NewHeaderSize+len(payload):], h.Sum(nil)) + copy(b[headerSizeCC51:], payload) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:headerSizeCC51]) + copy(b[headerSizeCC51+len(payload):], h.Sum(nil)) return b } -func (c *Conn) buildACK() []byte { +func (c *DTLSConn) msgACK() []byte { c.ackFlags++ b := make([]byte, 24) - binary.LittleEndian.PutUint16(b[0:], MagicACK) // 0x0009 - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) // 0x000c + binary.LittleEndian.PutUint16(b[0:], magicACK) // 0x0009 + binary.LittleEndian.PutUint16(b[2:], protoVersion) // 0x000c binary.LittleEndian.PutUint32(b[4:], c.avSeq) // TX seq c.avSeq++ binary.LittleEndian.PutUint16(b[8:], c.rxSeqStart) // RX start (last acked) @@ -942,11 +960,11 @@ func (c *Conn) buildACK() []byte { return b } -func (c *Conn) buildKeepAlive(incoming []byte) []byte { +func (c *DTLSConn) msgKeepalive(incoming []byte) []byte { b := make([]byte, 24) copy(b, "\x04\x02\x1a\x0a") // marker + mode binary.LittleEndian.PutUint16(b[4:], 8) // body size - binary.LittleEndian.PutUint16(b[8:], CmdKeepaliveReq) // 0x0427 + binary.LittleEndian.PutUint16(b[8:], cmdKeepaliveReq) // 0x0427 binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags if len(incoming) >= 8 { copy(b[16:], incoming[:8]) // echo payload @@ -954,13 +972,13 @@ func (c *Conn) buildKeepAlive(incoming []byte) []byte { return b } -func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { +func (c *DTLSConn) msgIOCtrl(payload []byte) []byte { b := make([]byte, 40+len(payload)) - binary.LittleEndian.PutUint16(b, ProtoVersion) // magic - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) // version + binary.LittleEndian.PutUint16(b, protoVersion) // magic + binary.LittleEndian.PutUint16(b[2:], protoVersion) // version binary.LittleEndian.PutUint32(b[4:], c.avSeq) // av seq c.avSeq++ - binary.LittleEndian.PutUint16(b[16:], MagicIOCtrl) // 0x7000 + binary.LittleEndian.PutUint16(b[16:], magicIOCtrl) // 0x7000 binary.LittleEndian.PutUint16(b[18:], c.seqCmd) // sub channel binary.LittleEndian.PutUint32(b[20:], 1) // ioctl seq binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)+4)) // payload size @@ -971,30 +989,6 @@ func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { return b } -func derivePSK(enr string) []byte { - // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) - // contains a 0x00 byte, the PSK is truncated at that position. - // bytes after the first 0x00 are padded with zeros to make a 32-byte key. - hash := sha256.Sum256([]byte(enr)) - pskLen := 32 - for i := range 32 { - if hash[i] == 0x00 { - pskLen = i - break - } - } - - psk := make([]byte, 32) - copy(psk[:pskLen], hash[:pskLen]) - return psk -} - -func genRandomID() []byte { - b := make([]byte, 8) - _, _ = rand.Read(b) - return b -} - func hexDump(data []byte) string { const maxBytes = 650 totalLen := len(data) diff --git a/pkg/tutk/crypto.go b/pkg/tutk/crypto.go index 6b306255..469bd2bc 100644 --- a/pkg/tutk/crypto.go +++ b/pkg/tutk/crypto.go @@ -50,6 +50,34 @@ func ReverseTransCodePartial(dst, src []byte) []byte { return dst } +func ReverseTransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return ReverseTransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := ReverseTransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if dst[3]&1 != 0 { // Partial encryption (check decrypted header) + remaining := len(src) - 16 + decryptLen := min(remaining, 48) + if decryptLen > 0 { + decrypted := ReverseTransCodePartial(nil, src[16:16+decryptLen]) + copy(dst[16:], decrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full decryption + decrypted := ReverseTransCodePartial(nil, src[16:]) + copy(dst[16:], decrypted) + } + } + return dst +} + func TransCodePartial(dst, src []byte) []byte { n := len(src) tmp := make([]byte, n) @@ -92,6 +120,34 @@ func TransCodePartial(dst, src []byte) []byte { return dst } +func TransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return TransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := TransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if src[3]&1 != 0 { // Partial encryption + remaining := len(src) - 16 + encryptLen := min(remaining, 48) + if encryptLen > 0 { + encrypted := TransCodePartial(nil, src[16:16+encryptLen]) + copy(dst[16:], encrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full encryption + encrypted := TransCodePartial(nil, src[16:]) + copy(dst[16:], encrypted) + } + } + return dst +} + func swap(dst, src []byte, n int) { switch n { case 2: @@ -175,3 +231,49 @@ func XXTEADecrypt(dst, src, key []byte) { dst = dst[4:] } } + +func XXTEADecryptVar(data, key []byte) []byte { + if len(data) < 8 || len(key) < 16 { + return nil + } + + k := make([]uint32, 4) + for i := range 4 { + k[i] = binary.LittleEndian.Uint32(key[i*4:]) + } + + n := max(len(data)/4, 2) + v := make([]uint32, n) + for i := 0; i < len(data)/4; i++ { + v[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + + rounds := 6 + 52/n + sum := uint32(rounds) * delta + y := v[0] + + for rounds > 0 { + e := (sum >> 2) & 3 + for p := n - 1; p > 0; p-- { + z := v[p-1] + v[p] -= xxteaMX(sum, y, z, p, e, k) + y = v[p] + } + z := v[n-1] + v[0] -= xxteaMX(sum, y, z, 0, e, k) + y = v[0] + sum -= delta + rounds-- + } + + result := make([]byte, n*4) + for i := range n { + binary.LittleEndian.PutUint32(result[i*4:], v[i]) + } + + return result[:len(data)] +} + +func xxteaMX(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { + return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) +} diff --git a/pkg/wyze/tutk/dtls.go b/pkg/tutk/dtls.go similarity index 62% rename from pkg/wyze/tutk/dtls.go rename to pkg/tutk/dtls.go index c51b7762..e807e96f 100644 --- a/pkg/wyze/tutk/dtls.go +++ b/pkg/tutk/dtls.go @@ -1,6 +1,7 @@ package tutk import ( + "context" "net" "sync" "time" @@ -8,22 +9,26 @@ import ( "github.com/pion/dtls/v3" ) -func NewDtlsClient(c *Conn, channel uint8, psk []byte) (*dtls.Conn, error) { - adapter := &ChannelAdapter{conn: c, channel: channel} - return dtls.Client(adapter, c.addr, buildDtlsConfig(psk, false)) +type DTLSConfig struct { + PSK []byte + Identity string + IsServer bool } -func NewDtlsServer(c *Conn, channel uint8, psk []byte) (*dtls.Conn, error) { - adapter := &ChannelAdapter{conn: c, channel: channel} - return dtls.Server(adapter, c.addr, buildDtlsConfig(psk, true)) +func NewDTLSClient(adapter net.PacketConn, addr net.Addr, psk []byte) (*dtls.Conn, error) { + return dtls.Client(adapter, addr, buildDTLSConfig(psk, false)) } -func buildDtlsConfig(psk []byte, isServer bool) *dtls.Config { +func NewDTLSServer(adapter net.PacketConn, addr net.Addr, psk []byte) (*dtls.Conn, error) { + return dtls.Server(adapter, addr, buildDTLSConfig(psk, true)) +} + +func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config { config := &dtls.Config{ PSK: func(hint []byte) ([]byte, error) { return psk, nil }, - PSKIdentityHint: []byte(PSKIdentity), + PSKIdentityHint: []byte("AUTHPWD_admin"), InsecureSkipVerify: true, InsecureSkipVerifyHello: true, MTU: 1200, @@ -41,21 +46,26 @@ func buildDtlsConfig(psk []byte, isServer bool) *dtls.Config { } type ChannelAdapter struct { - conn *Conn - channel uint8 - + ctx context.Context + channel uint8 + writeFn func([]byte, uint8) error + readChan chan []byte + addr net.Addr mu sync.Mutex readDeadline time.Time } -func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { - var buf chan []byte - if a.channel == IOTCChannelMain { - buf = a.conn.clientBuf - } else { - buf = a.conn.serverBuf +func NewChannelAdapter(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte) *ChannelAdapter { + return &ChannelAdapter{ + ctx: ctx, + channel: channel, + addr: addr, + writeFn: writeFn, + readChan: readChan, } +} +func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { a.mu.Lock() deadline := a.readDeadline a.mu.Unlock() @@ -70,25 +80,25 @@ func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { defer timer.Stop() select { - case data := <-buf: - return copy(p, data), a.conn.addr, nil + case data := <-a.readChan: + return copy(p, data), a.addr, nil case <-timer.C: return 0, nil, &timeoutError{} - case <-a.conn.ctx.Done(): + case <-a.ctx.Done(): return 0, nil, net.ErrClosed } } select { - case data := <-buf: - return copy(p, data), a.conn.addr, nil - case <-a.conn.ctx.Done(): + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-a.ctx.Done(): return 0, nil, net.ErrClosed } } func (a *ChannelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { - if err := a.conn.WriteDTLS(p, a.channel); err != nil { + if err := a.writeFn(p, a.channel); err != nil { return 0, err } return len(p), nil @@ -96,21 +106,18 @@ func (a *ChannelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { func (a *ChannelAdapter) Close() error { return nil } func (a *ChannelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } - func (a *ChannelAdapter) SetDeadline(t time.Time) error { a.mu.Lock() a.readDeadline = t a.mu.Unlock() return nil } - func (a *ChannelAdapter) SetReadDeadline(t time.Time) error { a.mu.Lock() a.readDeadline = t a.mu.Unlock() return nil } - func (a *ChannelAdapter) SetWriteDeadline(time.Time) error { return nil } type timeoutError struct{} diff --git a/pkg/wyze/tutk/frame.go b/pkg/tutk/frame.go similarity index 84% rename from pkg/wyze/tutk/frame.go rename to pkg/tutk/frame.go index ee673181..db5bf074 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/tutk/frame.go @@ -25,18 +25,7 @@ const ( ChannelPVideo uint8 = 0x07 ) -const ( - ResTierLow uint8 = 1 // 360P/SD - ResTierHigh uint8 = 4 // HD/2K -) - -const ( - Bitrate360P uint8 = 30 - BitrateHD uint8 = 100 - Bitrate2K uint8 = 200 -) - -const FrameInfoSize = 40 +const frameInfoSize = 40 // FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet) // Video: 40 bytes, Audio: 16 bytes (uses same struct, fields 16+ are zero) @@ -56,7 +45,7 @@ const FrameInfoSize = 40 // 24-35 12 DeviceID - MAC address (ASCII) - video only // 36-39 4 Padding - Always 0 - video only type FrameInfo struct { - CodecID uint16 // 0-1 + CodecID byte // 0 (only low byte used) Flags uint8 // 2 CamIndex uint8 // 3 OnlineNum uint8 // 4 @@ -73,22 +62,12 @@ func (fi *FrameInfo) IsKeyframe() bool { return fi.Flags == 0x01 } -func (fi *FrameInfo) Resolution() string { - switch fi.Bitrate { - case Bitrate360P: - return "360P" - case BitrateHD: - return "HD" - case Bitrate2K: - return "2K" - default: - return "unknown" - } -} - func (fi *FrameInfo) SampleRate() uint32 { idx := (fi.Flags >> 2) & 0x0F - return uint32(SampleRateValue(idx)) + if idx < uint8(len(sampleRates)) { + return sampleRates[idx] + } + return 16000 } func (fi *FrameInfo) Channels() uint8 { @@ -98,24 +77,16 @@ func (fi *FrameInfo) Channels() uint8 { return 1 } -func (fi *FrameInfo) IsVideo() bool { - return IsVideoCodec(fi.CodecID) -} - -func (fi *FrameInfo) IsAudio() bool { - return IsAudioCodec(fi.CodecID) -} - func ParseFrameInfo(data []byte) *FrameInfo { - if len(data) < FrameInfoSize { + if len(data) < frameInfoSize { return nil } - offset := len(data) - FrameInfoSize + offset := len(data) - frameInfoSize fi := data[offset:] return &FrameInfo{ - CodecID: binary.LittleEndian.Uint16(fi), + CodecID: fi[0], Flags: fi[2], CamIndex: fi[3], OnlineNum: fi[4], @@ -131,7 +102,7 @@ func ParseFrameInfo(data []byte) *FrameInfo { type Packet struct { Channel uint8 - Codec uint16 + Codec byte Timestamp uint32 Payload []byte IsKeyframe bool @@ -140,14 +111,6 @@ type Packet struct { Channels uint8 } -func (p *Packet) IsVideo() bool { - return p.Channel == ChannelIVideo || p.Channel == ChannelPVideo -} - -func (p *Packet) IsAudio() bool { - return p.Channel == ChannelAudio -} - type PacketHeader struct { Channel byte FrameType byte @@ -347,7 +310,7 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame frameType := data[1] headerSize := 28 - frameInfoSize := 0 + fiSize := 0 switch frameType { case FrameTypeStart: @@ -357,17 +320,17 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame if len(data) >= 22 { pktTotal := binary.LittleEndian.Uint16(data[20:]) if pktTotal == 1 { - frameInfoSize = FrameInfoSize + fiSize = frameInfoSize } } case FrameTypeCont, FrameTypeContAlt: headerSize = 28 case FrameTypeEndSingle, FrameTypeEndMulti: headerSize = 28 - frameInfoSize = FrameInfoSize + fiSize = frameInfoSize case FrameTypeEndExt: headerSize = 36 - frameInfoSize = FrameInfoSize + fiSize = frameInfoSize default: headerSize = 28 } @@ -376,11 +339,11 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame return nil, nil } - if frameInfoSize == 0 { + if fiSize == 0 { return data[headerSize:], nil } - if len(data) < headerSize+frameInfoSize { + if len(data) < headerSize+fiSize { return data[headerSize:], nil } @@ -395,7 +358,7 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame } if validCodec { - payload := data[headerSize : len(data)-frameInfoSize] + payload := data[headerSize : len(data)-fiSize] return payload, fi } @@ -421,7 +384,7 @@ func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []by cs.pktTotal = hdr.PktTotal } - // Sequential check: if packet index doesn't match expected, reset (data loss) + // If packet index doesn't match expected, reset (data loss) if hdr.PktIdx != cs.waitSeq { fmt.Printf("[OOO] ch=0x%02x #%d frameType=0x%02x pktTotal=%d expected pkt %d, got %d - reset\n", channel, hdr.FrameNo, hdr.FrameType, hdr.PktTotal, cs.waitSeq, hdr.PktIdx) @@ -434,7 +397,6 @@ func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []by cs.hasStarted = true } - // Append payload (simple sequential accumulation) cs.waitData = append(cs.waitData, payload...) cs.waitSeq++ @@ -444,16 +406,13 @@ func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []by } // Check if frame is complete - if cs.waitSeq == cs.pktTotal && cs.frameInfo != nil { - h.emitVideo(channel, cs) - cs.reset() + if cs.waitSeq != cs.pktTotal || cs.frameInfo == nil { + return } -} -func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { - fi := cs.frameInfo + fi = cs.frameInfo + defer cs.reset() - // Size validation if fi.PayloadSize > 0 && uint32(len(cs.waitData)) != fi.PayloadSize { fmt.Printf("[SIZE] ch=0x%02x #%d mismatch: expected %d, got %d\n", channel, cs.frameNo, fi.PayloadSize, len(cs.waitData)) @@ -467,13 +426,9 @@ func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { accumUS := h.videoTS.update(fi.Timestamp) rtpTS := uint32(accumUS * 90000 / 1000000) - // Copy payload (buffer will be reused) - payload := make([]byte, len(cs.waitData)) - copy(payload, cs.waitData) - pkt := &Packet{ Channel: channel, - Payload: payload, + Payload: append([]byte{}, cs.waitData...), Codec: fi.CodecID, Timestamp: rtpTS, IsKeyframe: fi.IsKeyframe(), @@ -485,10 +440,10 @@ func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { if fi.IsKeyframe() { frameType = "KEY" } - fmt.Printf("[OK] ch=0x%02x #%d %s %s size=%d\n", - channel, fi.FrameNo, CodecName(fi.CodecID), frameType, len(payload)) - fmt.Printf(" [0-1]codec=0x%x(%s) [2]flags=0x%x [3]=%d [4]=%d\n", - fi.CodecID, CodecName(fi.CodecID), fi.Flags, fi.CamIndex, fi.OnlineNum) + fmt.Printf("[OK] ch=0x%02x #%d codec=0x%02x %s size=%d\n", + channel, fi.FrameNo, fi.CodecID, frameType, len(pkt.Payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x [3]=%d [4]=%d\n", + fi.CodecID, fi.Flags, fi.CamIndex, fi.OnlineNum) fmt.Printf(" [5]=%d [6]=%d [7]=%d [8-11]ts=%d\n", fi.FPS, fi.ResTier, fi.Bitrate, fi.Timestamp) fmt.Printf(" [12-15]=0x%x [16-19]payload=%d [20-23]frameNo=%d\n", @@ -509,7 +464,7 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { var channels uint8 switch fi.CodecID { - case AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACLATM, AudioCodecAACWyze: + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: sampleRate, channels = parseAudioParams(payload, fi) default: sampleRate = fi.SampleRate() @@ -537,10 +492,10 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { if fi.Flags&0x02 != 0 { bits = 16 } - fmt.Printf("[OK] Audio #%d %s size=%d\n", - fi.FrameNo, AudioCodecName(fi.CodecID), len(payload)) - fmt.Printf(" [0-1]codec=0x%x(%s) [2]flags=0x%x(%dHz/%dbit/%dch)\n", - fi.CodecID, AudioCodecName(fi.CodecID), fi.Flags, sampleRate, bits, channels) + fmt.Printf("[OK] Audio #%d codec=0x%02x size=%d\n", + fi.FrameNo, fi.CodecID, len(payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x(%dHz/%dbit/%dch)\n", + fi.CodecID, fi.Flags, sampleRate, bits, channels) fmt.Printf(" [8-11]ts=%d [12-15]=0x%x rtp_ts=%d\n", fi.Timestamp, fi.SessionID, rtpTS) fmt.Printf(" hex: %s\n", dumpHex(fi)) @@ -589,8 +544,9 @@ func parseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channel } func dumpHex(fi *FrameInfo) string { - b := make([]byte, FrameInfoSize) - binary.LittleEndian.PutUint16(b[0:], fi.CodecID) + b := make([]byte, frameInfoSize) + b[0] = fi.CodecID + b[1] = 0 // High byte (unused) b[2] = fi.Flags b[3] = fi.CamIndex b[4] = fi.OnlineNum diff --git a/pkg/tutk/helpers.go b/pkg/tutk/helpers.go index 118119be..b3623b9e 100644 --- a/pkg/tutk/helpers.go +++ b/pkg/tutk/helpers.go @@ -1,16 +1,16 @@ package tutk -import "encoding/binary" - -// https://github.com/seydx/tutk_wyze#11-codec-reference -const ( - CodecH264 = 0x4e - CodecH265 = 0x50 - CodecPCMA = 0x8a - CodecPCML = 0x8c - CodecAAC = 0x88 +import ( + "encoding/binary" + "time" ) +func GenSessionID() []byte { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) + return b +} + func ICAM(cmd uint32, args ...byte) []byte { // 0 4943414d ICAM // 4 d807ff00 command @@ -26,3 +26,37 @@ func ICAM(cmd uint32, args ...byte) []byte { copy(b[23:], args) return b } + +func HL(cmdID uint16, payload []byte) []byte { + // 0-1 "HL" magic + // 2 version (typically 5) + // 3 reserved + // 4-5 cmdID command ID (uint16 LE) + // 6-7 payloadLen payload length (uint16 LE) + // 8-15 reserved + // 16+ payload + const headerSize = 16 + const version = 5 + + b := make([]byte, headerSize+len(payload)) + copy(b, "HL") + b[2] = version + binary.LittleEndian.PutUint16(b[4:], cmdID) + binary.LittleEndian.PutUint16(b[6:], uint16(len(payload))) + copy(b[headerSize:], payload) + return b +} + +func ParseHL(data []byte) (cmdID uint16, payload []byte, ok bool) { + if len(data) < 16 || data[0] != 'H' || data[1] != 'L' { + return 0, nil, false + } + cmdID = binary.LittleEndian.Uint16(data[4:]) + payloadLen := binary.LittleEndian.Uint16(data[6:]) + if len(data) >= 16+int(payloadLen) { + payload = data[16 : 16+payloadLen] + } else if len(data) > 16 { + payload = data[16:] + } + return cmdID, payload, true +} diff --git a/pkg/tutk/session0.go b/pkg/tutk/session0.go index 1f1bbc7e..6a1b2253 100644 --- a/pkg/tutk/session0.go +++ b/pkg/tutk/session0.go @@ -155,9 +155,3 @@ func ConnectByUID(stage byte, uid string, sid8 []byte) []byte { return b } - -func GenSessionID() []byte { - b := make([]byte, 8) - binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) - return b -} diff --git a/pkg/wyze/backchannel.go b/pkg/wyze/backchannel.go index d0b15db3..37472c10 100644 --- a/pkg/wyze/backchannel.go +++ b/pkg/wyze/backchannel.go @@ -5,7 +5,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/aac" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk" "github.com/pion/rtp" ) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index e047cfd5..6e691a25 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -12,8 +12,7 @@ import ( "sync" "time" - "github.com/AlexxIT/go2rtc/pkg/wyze/crypto" - "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk" ) const ( @@ -29,15 +28,6 @@ const ( BitrateSD uint16 = 0x3C ) -const ( - QualityUnknown = 0 - QualityMax = 1 - QualityHigh = 2 - QualityMiddle = 3 - QualityLow = 4 - QualityMin = 5 -) - const ( MediaTypeVideo = 1 MediaTypeAudio = 2 @@ -59,7 +49,7 @@ const ( ) type Client struct { - conn *tutk.Conn + conn *tutk.DTLSConn host string uid string @@ -76,7 +66,7 @@ type Client struct { hasAudio bool hasIntercom bool - audioCodecID uint16 + audioCodecID byte audioSampleRate uint32 audioChannels uint8 } @@ -107,7 +97,7 @@ func Dial(rawURL string) (*Client, error) { verbose: query.Get("verbose") == "true", } - c.authKey = string(crypto.CalculateAuthKey(c.enr, c.mac)) + c.authKey = string(tutk.CalculateAuthKey(c.enr, c.mac)) if c.verbose { fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid) @@ -143,13 +133,13 @@ func (c *Client) SupportsIntercom() bool { return c.hasIntercom } -func (c *Client) SetBackchannelCodec(codecID uint16, sampleRate uint32, channels uint8) { +func (c *Client) SetBackchannelCodec(codecID byte, sampleRate uint32, channels uint8) { c.audioCodecID = codecID c.audioSampleRate = sampleRate c.audioChannels = channels } -func (c *Client) GetBackchannelCodec() (codecID uint16, sampleRate uint32, channels uint8) { +func (c *Client) GetBackchannelCodec() (codecID byte, sampleRate uint32, channels uint8) { return c.audioCodecID, c.audioSampleRate, c.audioChannels } @@ -238,13 +228,13 @@ func (c *Client) ReadPacket() (*tutk.Packet, error) { return c.conn.AVRecvFrameData() } -func (c *Client) WriteAudio(codec uint16, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { +func (c *Client) WriteAudio(codec byte, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { if !c.conn.IsBackchannelReady() { return fmt.Errorf("speaker channel not connected") } if c.verbose { - fmt.Printf("[Wyze] WriteAudio: codec=0x%04x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels) + fmt.Printf("[Wyze] WriteAudio: codec=0x%02x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels) } return c.conn.AVSendAudioData(codec, payload, timestamp, sampleRate, channels) @@ -305,7 +295,7 @@ func (c *Client) connect() error { host = host[:idx] } - conn, err := tutk.Dial(host, port, c.uid, c.authKey, c.enr, c.mac, c.verbose) + conn, err := tutk.DialDTLS(host, port, c.uid, c.authKey, c.enr, c.verbose) if err != nil { return fmt.Errorf("wyze: connect failed: %w", err) } @@ -386,9 +376,7 @@ func (c *Client) doKAuth() error { fmt.Printf("[Wyze] K10003 auth success\n") } - if avResp := c.conn.GetAVLoginResponse(); avResp != nil { - c.hasIntercom = avResp.TwoWayStreaming == 1 - } + c.hasIntercom = c.conn.HasTwoWayStreaming() if c.verbose { fmt.Printf("[Wyze] K-auth complete\n") @@ -409,7 +397,7 @@ func (c *Client) buildK10000() []byte { } func (c *Client) buildK10002(challenge []byte, status byte) []byte { - resp := crypto.GenerateChallengeResponse(challenge, c.enr, status) + resp := generateChallengeResponse(challenge, c.enr, status) sessionID := make([]byte, 4) rand.Read(sessionID) b := make([]byte, 38) @@ -555,3 +543,42 @@ func (c *Client) is2K() bool { func (c *Client) isFloodlight() bool { return c.model == "HL_CFL2" } + +const ( + statusDefault byte = 1 + statusENR16 byte = 3 + statusENR32 byte = 6 +) + +func generateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { + var secretKey []byte + + switch status { + case statusDefault: + secretKey = []byte("FFFFFFFFFFFFFFFF") + case statusENR16: + if len(enr) >= 16 { + secretKey = []byte(enr[:16]) + } else { + secretKey = make([]byte, 16) + copy(secretKey, enr) + } + case statusENR32: + if len(enr) >= 16 { + firstKey := []byte(enr[:16]) + challengeBytes = tutk.XXTEADecryptVar(challengeBytes, firstKey) + } + if len(enr) >= 32 { + secretKey = []byte(enr[16:32]) + } else if len(enr) > 16 { + secretKey = make([]byte, 16) + copy(secretKey, []byte(enr[16:])) + } else { + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + default: + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + + return tutk.XXTEADecryptVar(challengeBytes, secretKey) +} diff --git a/pkg/wyze/crypto/transcode.go b/pkg/wyze/crypto/transcode.go deleted file mode 100644 index 61cf5f2c..00000000 --- a/pkg/wyze/crypto/transcode.go +++ /dev/null @@ -1,143 +0,0 @@ -package crypto - -import ( - "bytes" - "crypto/rand" - "encoding/binary" - "math/bits" -) - -const charlie = "Charlie is the designer of P2P!!" - -func TransCodePartial(src []byte) []byte { - n := len(src) - tmp := make([]byte, n) - dst := bytes.Clone(src) - src16, tmp16, dst16 := src, tmp, dst - - for ; n >= 16; n -= 16 { - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(src16[i:]) - binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, -i-1)) - } - for i := range 16 { - dst16[i] = tmp16[i] ^ charlie[i] - } - swap(dst16, tmp16, 16) - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(tmp16[i:]) - binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, -i-3)) - } - tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:] - } - - for i := 0; i < n; i++ { - tmp16[i] = src16[i] ^ charlie[i] - } - swap(tmp16, dst16, n) - return dst -} - -func ReverseTransCodePartial(src []byte) []byte { - n := len(src) - tmp := make([]byte, n) - dst := bytes.Clone(src) - src16, tmp16, dst16 := src, tmp, dst - - for ; n >= 16; n -= 16 { - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(src16[i:]) - binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3)) - } - swap(tmp16, dst16, 16) - for i := range 16 { - tmp16[i] = dst16[i] ^ charlie[i] - } - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(tmp16[i:]) - binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, i+1)) - } - tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:] - } - - swap(src16, tmp16, n) - for i := 0; i < n; i++ { - dst16[i] = tmp16[i] ^ charlie[i] - } - return dst -} - -func TransCodeBlob(src []byte) []byte { - if len(src) < 16 { - return TransCodePartial(src) - } - - dst := make([]byte, len(src)) - header := TransCodePartial(src[:16]) - copy(dst, header) - - if len(src) > 16 { - if src[3]&1 != 0 { // Partial encryption - remaining := len(src) - 16 - encryptLen := min(remaining, 48) - if encryptLen > 0 { - encrypted := TransCodePartial(src[16 : 16+encryptLen]) - copy(dst[16:], encrypted) - } - if remaining > 48 { - copy(dst[64:], src[64:]) - } - } else { // Full encryption - encrypted := TransCodePartial(src[16:]) - copy(dst[16:], encrypted) - } - } - return dst -} - -func ReverseTransCodeBlob(src []byte) []byte { - if len(src) < 16 { - return ReverseTransCodePartial(src) - } - - dst := make([]byte, len(src)) - header := ReverseTransCodePartial(src[:16]) - copy(dst, header) - - if len(src) > 16 { - if dst[3]&1 != 0 { // Partial encryption (check decrypted header) - remaining := len(src) - 16 - decryptLen := min(remaining, 48) - if decryptLen > 0 { - decrypted := ReverseTransCodePartial(src[16 : 16+decryptLen]) - copy(dst[16:], decrypted) - } - if remaining > 48 { - copy(dst[64:], src[64:]) - } - } else { // Full decryption - decrypted := ReverseTransCodePartial(src[16:]) - copy(dst[16:], decrypted) - } - } - return dst -} - -func RandRead(b []byte) { - _, _ = rand.Read(b) -} - -func swap(src, dst []byte, n int) { - switch n { - case 8: - dst[0], dst[1], dst[2], dst[3] = src[7], src[4], src[3], src[2] - dst[4], dst[5], dst[6], dst[7] = src[1], src[6], src[5], src[0] - case 16: - dst[0], dst[1], dst[2], dst[3] = src[11], src[9], src[8], src[15] - dst[4], dst[5], dst[6], dst[7] = src[13], src[10], src[12], src[14] - dst[8], dst[9], dst[10], dst[11] = src[2], src[1], src[5], src[0] - dst[12], dst[13], dst[14], dst[15] = src[6], src[4], src[7], src[3] - default: - copy(dst, src[:n]) - } -} diff --git a/pkg/wyze/crypto/xxtea.go b/pkg/wyze/crypto/xxtea.go deleted file mode 100644 index a28901cb..00000000 --- a/pkg/wyze/crypto/xxtea.go +++ /dev/null @@ -1,147 +0,0 @@ -package crypto - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "strings" -) - -const delta = 0x9e3779b9 - -const ( - StatusDefault byte = 1 - StatusENR16 byte = 3 - StatusENR32 byte = 6 -) - -func XXTEADecrypt(data, key []byte) []byte { - if len(data) < 8 || len(key) < 16 { - return nil - } - - k := make([]uint32, 4) - for i := range 4 { - k[i] = binary.LittleEndian.Uint32(key[i*4:]) - } - - n := max(len(data)/4, 2) - v := make([]uint32, n) - for i := 0; i < len(data)/4; i++ { - v[i] = binary.LittleEndian.Uint32(data[i*4:]) - } - - rounds := 6 + 52/n - sum := uint32(rounds) * delta - y := v[0] - - for rounds > 0 { - e := (sum >> 2) & 3 - for p := n - 1; p > 0; p-- { - z := v[p-1] - v[p] -= mx(sum, y, z, p, e, k) - y = v[p] - } - z := v[n-1] - v[0] -= mx(sum, y, z, 0, e, k) - y = v[0] - sum -= delta - rounds-- - } - - result := make([]byte, n*4) - for i := range n { - binary.LittleEndian.PutUint32(result[i*4:], v[i]) - } - - return result[:len(data)] -} - -func XXTEAEncrypt(data, key []byte) []byte { - if len(data) < 8 || len(key) < 16 { - return nil - } - - k := make([]uint32, 4) - for i := range 4 { - k[i] = binary.LittleEndian.Uint32(key[i*4:]) - } - - n := max(len(data)/4, 2) - v := make([]uint32, n) - for i := 0; i < len(data)/4; i++ { - v[i] = binary.LittleEndian.Uint32(data[i*4:]) - } - - rounds := 6 + 52/n - var sum uint32 - z := v[n-1] - - for rounds > 0 { - sum += delta - e := (sum >> 2) & 3 - for p := 0; p < n-1; p++ { - y := v[p+1] - v[p] += mx(sum, y, z, p, e, k) - z = v[p] - } - y := v[0] - v[n-1] += mx(sum, y, z, n-1, e, k) - z = v[n-1] - rounds-- - } - - result := make([]byte, n*4) - for i := range n { - binary.LittleEndian.PutUint32(result[i*4:], v[i]) - } - - return result[:len(data)] -} - -func mx(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { - return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) -} - -func GenerateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { - var secretKey []byte - - switch status { - case StatusDefault: - secretKey = []byte("FFFFFFFFFFFFFFFF") - case StatusENR16: - if len(enr) >= 16 { - secretKey = []byte(enr[:16]) - } else { - secretKey = make([]byte, 16) - copy(secretKey, enr) - } - case StatusENR32: - if len(enr) >= 16 { - firstKey := []byte(enr[:16]) - challengeBytes = XXTEADecrypt(challengeBytes, firstKey) - } - if len(enr) >= 32 { - secretKey = []byte(enr[16:32]) - } else if len(enr) > 16 { - secretKey = make([]byte, 16) - copy(secretKey, []byte(enr[16:])) - } else { - secretKey = []byte("FFFFFFFFFFFFFFFF") - } - default: - secretKey = []byte("FFFFFFFFFFFFFFFF") - } - - return XXTEADecrypt(challengeBytes, secretKey) -} - -func CalculateAuthKey(enr, mac string) []byte { - data := enr + strings.ToUpper(mac) - hash := sha256.Sum256([]byte(data)) - b64 := base64.StdEncoding.EncodeToString(hash[:6]) - b64 = strings.ReplaceAll(b64, "+", "Z") - b64 = strings.ReplaceAll(b64, "/", "9") - b64 = strings.ReplaceAll(b64, "=", "A") - return []byte(b64) -} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 4eb70ab3..16219c44 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -10,7 +10,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h264/annexb" "github.com/AlexxIT/go2rtc/pkg/h265" - "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk" "github.com/pion/rtp" ) @@ -96,21 +96,21 @@ func (p *Producer) Start() error { Payload: annexb.EncodeToAVCC(pkt.Payload), } - case tutk.AudioCodecG711U: + case tutk.CodecPCMU: name = core.CodecPCMU pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecG711A: + case tutk.CodecPCMA: name = core.CodecPCMA pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecAACADTS, tutk.AudioCodecAACWyze, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM: + case tutk.CodecAACADTS, tutk.CodecAACAlt, tutk.CodecAACRaw, tutk.CodecAACLATM: name = core.CodecAAC payload := pkt.Payload if aac.IsADTS(payload) { @@ -121,21 +121,21 @@ func (p *Producer) Start() error { Payload: payload, } - case tutk.AudioCodecOpus: + case tutk.CodecOpus: name = core.CodecOpus pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecPCM: + case tutk.CodecPCML: name = core.CodecPCML pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecMP3: + case tutk.CodecMP3: name = core.CodecMP3 pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, @@ -167,7 +167,7 @@ func probe(client *Client, quality byte) ([]*core.Media, error) { client.SetDeadline(time.Now().Add(core.ProbeTimeout)) var vcodec, acodec *core.Codec - var tutkAudioCodec uint16 + var tutkAudioCodec byte for { if client.verbose { @@ -197,33 +197,33 @@ func probe(client *Client, quality byte) ([]*core.Media, error) { vcodec = h265.AVCCToCodec(buf) } } - case tutk.AudioCodecG711U: + case tutk.CodecPCMU: if acodec == nil { acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecG711A: + case tutk.CodecPCMA: if acodec == nil { acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecAACWyze, tutk.AudioCodecAACADTS, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM: + case tutk.CodecAACAlt, tutk.CodecAACADTS, tutk.CodecAACRaw, tutk.CodecAACLATM: if acodec == nil { config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false) acodec = aac.ConfigToCodec(config) tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecOpus: + case tutk.CodecOpus: if acodec == nil { acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecPCM: + case tutk.CodecPCML: if acodec == nil { acodec = &core.Codec{Name: core.CodecPCML, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecMP3: + case tutk.CodecMP3: if acodec == nil { acodec = &core.Codec{Name: core.CodecMP3, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec diff --git a/pkg/wyze/tutk/README.md b/pkg/wyze/tutk/README.md deleted file mode 100644 index 36fa4728..00000000 --- a/pkg/wyze/tutk/README.md +++ /dev/null @@ -1,1329 +0,0 @@ -# TUTK/IOTC Protocol Reference for Wyze Cameras - -This document provides a complete reverse-engineering reference for the ThroughTek TUTK/IOTC protocol as used by Wyze cameras. It covers the entire protocol stack from UDP transport through encrypted P2P streaming, enabling implementation of native Wyze camera streaming without the proprietary TUTK SDK. - -## Table of Contents - -1. [Protocol Stack Overview](#1-protocol-stack-overview) -2. [Encryption Layers](#2-encryption-layers) -3. [Connection Flow](#3-connection-flow) -4. [IOTC Packet Structures](#4-iotc-packet-structures) -5. [DTLS Transport](#5-dtls-transport) -6. [AV Login](#6-av-login) -7. [K-Command Authentication](#7-k-command-authentication) -8. [K-Command Control](#8-k-command-control) -9. [AV Frame Structure](#9-av-frame-structure) -10. [FRAMEINFO Structure](#10-frameinfo-structure) -11. [Codec Reference](#11-codec-reference) -12. [Two-Way Audio (Backchannel)](#12-two-way-audio-backchannel) -13. [Frame Reassembly](#13-frame-reassembly) -14. [Wyze Cloud API](#14-wyze-cloud-api) -15. [Cryptography Details](#15-cryptography-details) -16. [Constants Reference](#16-constants-reference) -17. [NEW Protocol (0xCC51) Overview](#17-new-protocol-0xcc51-overview) -18. [NEW Protocol Discovery](#18-new-protocol-discovery) -19. [NEW Protocol DTLS Wrapper](#19-new-protocol-dtls-wrapper) - ---- - -## 1. Protocol Stack Overview - -Wyze cameras support two protocol variants depending on firmware version: - -| Protocol | Firmware | Magic | Discovery | Encryption | -|----------|----------|-------|-----------|------------| -| OLD | Cam v4 ≤ 4.52.9.4188 | TransCode | 0x0601/0x0602 | TransCode + DTLS | -| NEW | Cam v4 ≥ 4.52.9.5332 | 0xCC51 | 0x1002 | HMAC-SHA1 + DTLS | - -### OLD Protocol Stack (TransCode-based) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ Video (H.264/H.265) + Audio (AAC/G.711/Opus) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Frame Layer │ -│ Frame Types, Channels, FRAMEINFO, Packet Reassembly │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ K-Command Authentication │ -│ K10000-K10003 (XXTEA Challenge-Response) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Login Layer │ -│ Credentials + Capabilities Exchange │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ DTLS 1.2 Encryption │ -│ PSK = SHA256(ENR), ChaCha20-Poly1305 AEAD │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ IOTC Session │ -│ Discovery (0x0601) + Session Setup (0x0402) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ TransCode Cipher ("Charlie") │ -│ XOR + Bit Rotation Obfuscation │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ UDP Transport │ -│ Port 32761 (default) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### NEW Protocol Stack (0xCC51-based) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ Video (H.264/H.265) + Audio (AAC/G.711/Opus) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Frame Layer │ -│ Frame Types, Channels, FRAMEINFO, Packet Reassembly │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ K-Command Authentication │ -│ K10000-K10003 (XXTEA Challenge-Response) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Login Layer │ -│ Credentials + Capabilities Exchange │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ DTLS 1.2 Encryption │ -│ PSK = SHA256(ENR), ChaCha20-Poly1305 AEAD │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ NEW Protocol Wrapper (0xCC51) │ -│ Discovery (0x1002) + DTLS Wrapper (0x1502) + HMAC-SHA1 │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ UDP Transport │ -│ Port 32761 (default) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Required Credentials - -| Parameter | Description | Source | -|-----------|-------------|--------| -| UID | Device P2P identifier (20 chars) | Wyze Cloud API | -| ENR | Encryption key (16+ bytes) | Wyze Cloud API | -| MAC | Device MAC address | Wyze Cloud API | -| AuthKey | SHA256(ENR + MAC)[:6] in Base64 | Calculated | - -### Credential Derivation - -``` -AuthKey = Base64(SHA256(ENR + uppercase(MAC))[0:6]) - with substitutions: '+' → 'Z', '/' → '9', '=' → 'A' - -PSK = SHA256(ENR) // 32 bytes for DTLS -``` - ---- - -## 2. Encryption Layers - -The protocol uses three distinct encryption layers: - -### Layer 1: TransCode ("Charlie" Cipher) - -Applied to all IOTC Discovery and Session packets before UDP transmission. - -**Algorithm:** -- XOR with magic string: `"Charlie is the designer of P2P!!"` -- 32-bit left rotation on each block -- Byte permutation/swapping - -**When Applied:** -- Disco Request/Response (0x0601/0x0602) -- Session Request/Response (0x0402/0x0404) -- Data TX/RX wrappers (0x0407/0x0408) - -### Layer 2: DTLS 1.2 - -Encrypts all data after session establishment. - -| Parameter | Value | -|-----------|-------| -| Version | DTLS 1.2 | -| Cipher Suite | TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256 (0xCCAC) | -| PSK Identity | `AUTHPWD_admin` | -| PSK | SHA256(ENR) - 32 bytes | -| Curve | X25519 | - -### Layer 3: XXTEA - -Used for K-Command challenge-response authentication. - -| Status | Key Derivation | -|--------|----------------| -| 1 (Default) | Key = `"FFFFFFFFFFFFFFFF"` (16 x 0xFF) | -| 3 (ENR16) | Key = ENR[0:16] | -| 6 (ENR32) | Double: decrypt with ENR[0:16], then with ENR[16:32] | - ---- - -## 3. Connection Flow - -### 3.1 OLD Protocol Flow (TransCode-based) - -``` -Client Camera - │ │ - │ ═══════════ Phase 1: IOTC Discovery ═══════════════ │ - │ │ - │ Disco Stage 1 (0x0601, broadcast) ───────────────► │ - │ ◄─────────────────────── Disco Response (0x0602) │ - │ Disco Stage 2 (0x0601, direct) ──────────────────► │ - │ │ - │ ═══════════ Phase 2: IOTC Session ═════════════════ │ - │ │ - │ Session Request (0x0402) ────────────────────────► │ - │ ◄───────────────────── Session Response (0x0404) │ - │ │ - │ ═══════════ Phase 3: DTLS Handshake ═══════════════ │ - │ │ - │ ClientHello (in DATA_TX 0x0407) ─────────────────► │ - │ ◄───────────────────── ServerHello + KeyExchange │ - │ ClientKeyExchange + Finished ────────────────────► │ - │ ◄───────────────────────────────── DTLS Finished │ - │ │ - │ ═══════════ Phase 4: AV Login ═════════════════════ │ - │ │ - │ AV Login #1 (magic=0x0000) ──────────────────────► │ - │ AV Login #2 (magic=0x2000) ──────────────────────► │ - │ ◄───────────────────── AV Login Response (0x2100) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 5: K-Authentication ═════════════ │ - │ │ - │ K10000 (Auth Request) ───────────────────────────► │ - │ ◄───────────────────────── K10001 (Challenge 16B) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ K10002 (Response 38B) ───────────────────────────► │ - │ ◄───────────────────────── K10003 (Result, JSON) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 6: Streaming ════════════════════ │ - │ │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ... │ -``` - -### 3.2 NEW Protocol Flow (0xCC51-based) - -``` -Client Camera - │ │ - │ ═══════════ Phase 1: Discovery (0x1002) ═══════════ │ - │ │ - │ seq=0, ticket=0 (broadcast) ────────────────────► │ - │ ◄─────────────── seq=1, ticket=T (response) │ - │ seq=2, ticket=T (echo) ─────────────────────────► │ - │ ◄───────────────────────────── seq=3, ticket=T │ - │ │ - │ ═══════════ Phase 2: DTLS Handshake (0x1502) ══════ │ - │ │ - │ ClientHello (wrapped in 0x1502) ────────────────► │ - │ ◄───────────────────── ServerHello + KeyExchange │ - │ ClientKeyExchange + Finished ───────────────────► │ - │ ◄───────────────────────────────── DTLS Finished │ - │ │ - │ ═══════════ Phase 3: AV Login ═════════════════════ │ - │ │ - │ AV Login #1 (magic=0x0000) ──────────────────────► │ - │ AV Login #2 (magic=0x2000) ──────────────────────► │ - │ ◄───────────────────── AV Login Response (0x2100) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 4: K-Authentication ═════════════ │ - │ │ - │ K10000 (Auth Request) ───────────────────────────► │ - │ ◄───────────────────────── K10001 (Challenge 16B) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ K10002 (Response 38B) ───────────────────────────► │ - │ ◄───────────────────────── K10003 (Result, JSON) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 5: Streaming ════════════════════ │ - │ │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ... │ -``` - -**Key Differences from OLD Protocol:** -- Discovery uses 4-packet handshake (seq 0→1→2→3) instead of 2-stage discovery + session setup -- No TransCode encryption layer - packets use HMAC-SHA1 authentication instead -- DTLS records wrapped in 0x1502 frames with auth bytes appended - ---- - -## 4. IOTC Packet Structures - -### 4.1 IOTC Frame Header (16 bytes) - -All IOTC packets share this outer wrapper: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Marker1 Always 0x04 -[1] 1 Marker2 Always 0x02 -[2] 1 Marker3 Always 0x1A -[3] 1 Mode 0x02 (Disco), 0x0A (Session), 0x0B (Data) -[4-5] 2 BodySize Body length in bytes (LE) -[6-7] 2 Sequence Packet sequence number (LE) -[8-9] 2 Command Command ID (LE) -[10-11] 2 Flags Command-specific flags (LE) -[12-15] 4 RandomID Random identifier or metadata -``` - -### 4.2 Disco Request (0x0601) - 80 bytes total - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 Header IOTC Frame Header (cmd=0x0601) -[16-35] 20 UID Device UID (null-padded ASCII) -[36-51] 16 Reserved Zero-filled -[52-59] 8 RandomID 8 random bytes for session -[60] 1 Stage 1=broadcast, 2=direct -[61-71] 11 Reserved Zero-filled -[72-79] 8 AuthKey Calculated auth key -``` - -### 4.3 Session Request (0x0402) - 52 bytes total - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 Header IOTC Frame Header (cmd=0x0402) -[16-35] 20 UID Device UID (null-padded ASCII) -[36-43] 8 RandomID Same as Disco -[44-47] 4 Reserved Zero-filled -[48-51] 4 Timestamp Unix timestamp (LE) -``` - -### 4.4 Data TX (0x0407) - Variable - -Wraps DTLS records for transmission: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 Header IOTC Frame Header (cmd=0x0407) -[16-17] 2 RandomID[0:2] -[18] 1 Channel 0=Main (DTLS client), 1=Back (DTLS server) -[19] 1 Marker Always 0x01 -[20-23] 4 Const Always 0x0000000C -[24-31] 8 RandomID Full 8-byte random ID -[32+] var Payload DTLS record data -``` - ---- - -## 5. DTLS Transport - -DTLS records are wrapped in IOTC DATA_TX (0x0407) packets for transmission and extracted from DATA_RX (0x0408) packets on reception. - -### PSK Callback - -``` -Identity: "AUTHPWD_admin" -PSK: SHA256(ENR_string) → variable length (see below) -``` - -#### PSK Length Determination - -**CRITICAL**: The TUTK SDK treats the binary PSK as a NULL-terminated C string. -This means the effective PSK length is determined by the first `0x00` byte in the SHA256 hash: - -``` -hash = SHA256(ENR) -psk_length = position of first 0x00 byte in hash (or 32 if no 0x00) -psk = hash[0:psk_length] + zeros[psk_length:32] -``` - -**Example 1** - No NULL byte in hash (full 32-byte PSK): -``` -ENR: "aKzdqckqZ8HUHFe5" -SHA256: 3e5b96b8d6fc7264b531e1633de9526929d453cb47606c55d574a6e0ef5eb95f - ^^ No 0x00 byte → PSK length = 32 -``` - -**Example 2** - NULL byte at position 11 (11-byte PSK): -``` -ENR: "GkB9S7cX38GgzSC6" -SHA256: 16549c533b4e9812808f91|00|95f6edf00365266f09ea1e0328df3eee1ce127ed - ^^ 0x00 at position 11 → PSK length = 11 -PSK: 16549c533b4e9812808f91000000000000000000000000000000000000000000 -``` - -### Nonce Construction - -``` -nonce[12] = IV[12] XOR (epoch[2] || sequenceNumber[6] || padding[4]) -``` - -### AEAD Additional Data - -``` -additional_data = epoch[2] || sequenceNumber[6] || contentType[1] || version[2] || payloadLength[2] -``` - ---- - -## 6. AV Login - -After DTLS handshake, two login packets establish the AV session. - -### AV Login Packet #1 (570 bytes) - -``` -Offset Size Field Value/Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0x0000 (LE) -[2-3] 2 Version 0x000C (12) -[4-15] 12 Reserved Zero-filled -[16-17] 2 PayloadSize 0x0222 (546) -[18-19] 2 Flags 0x0001 -[20-23] 4 RandomID 4 random bytes -[24-279] 256 Username "admin" (null-padded) -[280-535] 256 Password ENR string (null-padded) -[536-539] 4 Resend 0=disabled, 1=enabled (see 9.6) -[540-543] 4 SecurityMode 0x00000002 (AV_SECURITY_AUTO) -[544-547] 4 AuthType 0x00000000 (PASSWORD) -[548-551] 4 SyncRecvData 0x00000000 -[552-555] 4 Capabilities 0x001F07FB -[556-569] 14 Reserved Zero-filled -``` - -### AV Login Packet #2 (572 bytes) - -Same structure as #1 with: -- Magic = 0x2000 -- PayloadSize = 0x0224 (548) -- Flags = 0x0000 -- RandomID[0] incremented by 1 - -### AV Login Response (0x2100) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0x2100 -[2-3] 2 Version 0x000C -[4] 1 ResponseType 0x10 = success -[5-15] 11 Reserved -[16-19] 4 PayloadSize 0x00000024 (36) -[20-23] 4 Checksum Echo from request -[24-27] 4 Reserved -[28] 1 Flag1 -[29] 1 EnableFlag 0x01 if enabled -[30] 1 Flag2 -[31] 1 TwoWayAudio 0x01 if intercom supported -[32-35] 4 Reserved -[36-39] 4 BufferConfig 0x00000004 -[40-43] 4 Capabilities 0x001F07FB -[44-57] 14 Reserved -``` - ---- - -## 7. K-Command Authentication - -K-Commands use the "HL" header format and are sent inside IOCTRL frames. - -### IOCTRL Frame Wrapper (40+ bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0x000C -[2-3] 2 Version 0x000C -[4-7] 4 AVSeq AV sequence number (LE) -[8-15] 8 Reserved Zero-filled -[16-17] 2 IOCTRLMagic 0x7000 -[18-19] 2 SubChannel Command sequence (increments) -[20-23] 4 IOCTRLSeq Always 0x00000001 -[24-27] 4 PayloadSize HL payload size + 4 -[28-31] 4 Flag Matches SubChannel -[32-35] 4 Reserved -[36-37] 2 IOType 0x0100 -[38-39] 2 Reserved -[40+] var HLPayload K-Command data -``` - -### HL Header (16 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic "HL" (0x48 0x4C) -[2] 1 Version 5 -[3] 1 Reserved 0x00 -[4-5] 2 CommandID 10000, 10001, 10002, etc. (LE) -[6-7] 2 PayloadLen Payload length after header (LE) -[8-15] 8 Reserved Zero-filled -[16+] var Payload Command-specific data -``` - -### K10000 - Auth Request (16 + JSON bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10000, PayloadLen = len(JSON) -[16+] var JSONPayload Audio codec preferences -``` - -**JSON Payload:** -```json -{"cameraInfo":{"audioEncoderList":[137,138,140]}} -``` - -Where audioEncoderList contains supported codec IDs: 137=PCMU, 138=PCMA, 140=PCM. - -### K10001 - Challenge (33+ bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10001 -[16] 1 Status Key selection: 1, 3, or 6 -[17-32] 16 Challenge XXTEA-encrypted challenge bytes -``` - -**Status Interpretation:** -| Status | Key Source | -|--------|------------| -| 1 | Default key: 16 x 0xFF | -| 3 | ENR[0:16] | -| 6 | Double decrypt: first ENR[0:16], then ENR[16:32] | - -### K10002 - Challenge Response (38 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10002, PayloadLen = 22 -[16-31] 16 Response XXTEA-decrypted challenge -[32-35] 4 SessionID Random 4-byte session identifier -[36] 1 VideoFlag 1 = enable video stream -[37] 1 AudioFlag 1 = enable audio stream -``` - -### K10003 - Auth Result - -Variable length, contains JSON payload: - -```json -{ - "connectionRes": "1", - "cameraInfo": { - "basicInfo": { - "firmware": "4.52.9.4188", - "mac": "AABBCCDDEEFF", - "model": "HL_CAM4" - }, - "channelResquestResult": { - "audio": "1", - "video": "1" - } - } -} -``` - -After K10003, video/audio streaming begins automatically. - ---- - -## 8. K-Command Control - -### K10010 - Control Channel (18 bytes) - -Start or stop media streams: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10010, PayloadLen = 2 -[16] 1 MediaType 1=Video, 2=Audio, 3=ReturnAudio -[17] 1 Enable 1=Enable, 2=Disable -``` - -**Media Types:** -| Value | Type | Description | -|-------|------|-------------| -| 1 | Video | Main video stream | -| 2 | Audio | Audio from camera | -| 3 | ReturnAudio | Intercom (audio to camera) | -| 4 | RDT | Raw data transfer | - -### K10056 - Set Resolution (21 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10056, PayloadLen = 5 -[16] 1 FrameSize Resolution + 1 (see table) -[17-18] 2 Bitrate KB/s value (LE) -[19-20] 2 FPS Frames per second, 0 = auto -``` - -**Frame Sizes:** -| Value | Resolution | -|-------|------------| -| 1 | 1080P (1920x1080) | -| 2 | 360P (640x360) | -| 3 | 720P (1280x720) | -| 4 | 2K (2560x1440) | - -**Bitrate Values:** -| Value | Rate | -|-------|------| -| 0xF0 (240) | Maximum | -| 0x3C (60) | SD quality | - -### K10052 - Set Resolution Doorbell (22 bytes) - -Used by doorbell models (WYZEDB3, WVOD1, HL_WCO2, WYZEC1) instead of K10056: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10052, PayloadLen = 6 -[16-17] 2 Bitrate KB/s value (LE) -[18] 1 FrameSize Resolution + 1 (see table above) -[19] 1 FPS Frames per second, 0 = auto -[20-21] 2 Reserved Zero-filled -``` - -**Note:** K10052 has a different field order than K10056 (bitrate before frameSize). - ---- - -## 9. AV Frame Structure - -### 9.1 Channels - -| Value | Name | Description | -|-------|------|-------------| -| 0x03 | Audio | Audio frames (always single-packet) | -| 0x05 | I-Video | Keyframes (can be multi-packet) | -| 0x07 | P-Video | Predictive frames (can be multi-packet) | - -### 9.2 Frame Types - -| Type | Name | Header Size | Has FRAMEINFO | -|------|------|-------------|---------------| -| 0x00 | Cont | 28 bytes | No | -| 0x01 | EndSingle | 28 bytes | Yes (40B) | -| 0x04 | ContAlt | 28 bytes | No | -| 0x05 | EndMulti | 28 bytes | Yes (40B) | -| 0x08 | Start | 36 bytes | No | -| 0x09 | StartAlt | 36 bytes | Yes if pkt_total=1 | -| 0x0D | EndExt | 36 bytes | Yes (40B) | - -### 9.3 28-Byte Header Layout - -Used by: Cont (0x00), EndSingle (0x01), ContAlt (0x04), EndMulti (0x05) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Channel 0x03/0x05/0x07 -[1] 1 FrameType 0x00/0x01/0x04/0x05 -[2-3] 2 Version 0x000B (11) -[4-5] 2 TxSequence Global incrementing sequence (LE) -[6-7] 2 Magic 0x507E ("P~") -[8] 1 Channel Duplicate of [0] -[9] 1 StreamIndex 0x00 normal, 0x01 for End packets -[10-11] 2 PacketCounter Running counter (does NOT reset per frame) -[12-13] 2 pkt_total Total packets in this frame (LE) -[14-15] 2 pkt_idx/Marker Packet index OR 0x0028 = FRAMEINFO present -[16-17] 2 PayloadSize Payload bytes (LE) -[18-19] 2 Reserved 0x0000 -[20-23] 4 PrevFrameNo Previous frame number (LE) -[24-27] 4 FrameNo Current frame number (LE) → USE FOR REASSEMBLY -``` - -### 9.4 36-Byte Header Layout - -Used by: Start (0x08), StartAlt (0x09), EndExt (0x0D) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Channel 0x03/0x05/0x07 -[1] 1 FrameType 0x08/0x09/0x0D -[2-3] 2 Version 0x000B (11) -[4-5] 2 TxSequence Global incrementing sequence (LE) -[6-7] 2 Magic 0x507E ("P~") -[8-11] 4 TimestampOrID Variable (not reliable) -[12-15] 4 Flags Variable -[16] 1 Channel Duplicate of [0] -[17] 1 StreamIndex 0x00 normal, 0x01 for End/Audio -[18-19] 2 ChannelFrameIdx Per-channel index (NOT for reassembly) -[20-21] 2 pkt_total Total packets in this frame (LE) -[22-23] 2 pkt_idx/Marker Packet index OR 0x0028 = FRAMEINFO present -[24-25] 2 PayloadSize Payload bytes (LE) -[26-27] 2 Reserved 0x0000 -[28-31] 4 PrevFrameNo Previous frame number (LE) -[32-35] 4 FrameNo Current frame number (LE) → USE FOR REASSEMBLY -``` - -### 9.5 FRAMEINFO Marker (0x0028) - -The value at offset [14-15] (28-byte) or [22-23] (36-byte) has dual meaning: - -| Condition | Interpretation | -|-----------|----------------| -| End packet AND value == 0x0028 | FRAMEINFO present (40 bytes at payload end) | -| Otherwise | Actual packet index within frame | - -**Note:** 0x0028 hex = 40 decimal. For non-End packets, this could be pkt_idx=40. - -### 9.6 Resend Mode - -The `resend` field in the AV Login packet (offset [536-539]) controls the packet format used for streaming. Setting this value determines whether retransmission support is enabled: - -#### resend=0: Direct Format (Simpler) - -``` -[channel][frameType][version 2B][seq 2B]...[payload] -``` - -Example: -``` -0000: 05 00 0b 00 6d 00 81 4e 05 00 63 00 86 00 00 00 - ^^ ^^ - | frameType=0x00 (continuation) - channel=0x05 (I-Video) -``` - -**Characteristics:** -- First byte is channel: 0x03=Audio, 0x05=I-Video, 0x07=P-Video -- No 0x0c wrapper overhead -- No Frame Index packets (1080 bytes) -- Simpler parsing, less bandwidth - -#### resend=1: Wrapped Format (With Resend Support) - -``` -[0x0c][variant][version 2B][seq 2B]...[channel at offset 16/24] -``` - -Example: -``` -0000: 0c 05 0b 00 e4 00 64 00 0a 00 00 14 01 00 00 00 - ^^ ^^ - | variant=0x05 - 0x0c wrapper (resend marker) -0010: 07 01 c8 00 01 00 28 00 ... - ^^ - channel=0x07 (P-Video) at offset 16 -``` - -**Characteristics:** -- First byte is always 0x0c (resend wrapper) -- Channel byte at offset 16 (variant < 0x08) or 24 (variant >= 0x08) -- Additional 1080-byte Frame Index packets sent periodically -- Enables packet retransmission for reliable delivery - -#### Header Size Rule - -| Variant | Header Size | Channel Offset | -|---------|-------------|----------------| -| < 0x08 | 36 bytes | 16 | -| >= 0x08 | 44 bytes | 24 | - -### 9.7 Frame Index Packets (Inner Byte 0x0c) - -When using `resend=1`, the camera sends periodic **Frame Index** packets (also called Resend Buffer Status). - -#### Packet Structure (1080 bytes total) - -``` -OUTER HEADER (16 bytes): -0000: 0c 00 0b 00 [seq 2B] [sub 2B] [counter 2B] 14 14 01 00 00 00 - ^^^^ ^^^^^ - cmd=0x0c magic - -INNER HEADER (20 bytes): -0010: 0c 00 00 00 00 00 00 00 14 04 00 00 00 00 00 00 00 00 00 00 - ^^^^ ^^^^^ - inner cmd payload_size = 0x0414 = 1044 bytes - -PAYLOAD DATA (starting at offset 0x20): -0020: 00 00 00 00 // 4 zero bytes -0024: [ch] [ft] // channel + frame type -0026: [data 2B] [data 2B] // varies by packet type -... -0030: [prev_frame 4B LE] // previous frame number -0034: [curr_frame 4B LE] // current frame number -``` - -#### Key Offsets - -| Offset | Size | Field | -|--------|------|-------| -| 0x24 (36) | 1 | Channel (0x05=I-Video, 0x07=P-Video) | -| 0x25 (37) | 1 | Frame type | -| 0x30 (48) | 4 | Previous frame number (LE) | -| 0x34 (52) | 4 | Current frame number (LE) | - -#### Packet Types - -| Channel | Description | -|---------|-------------| -| 0x05 | I-Video Frame Index - consecutive frame numbers for GOP sync | -| 0x07 | P-Video - buffer window status (oldest/newest buffered frame) | - ---- - -## 10. FRAMEINFO Structure - -### 10.1 RX FRAMEINFO (40 bytes) - From Camera - -Appended to the end of End packets (0x01, 0x05, 0x0D, or 0x09 when pkt_total=1): - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 codec_id Video: 0x4E (H.264), 0x50 (H.265) - Audio: 0x90 (AAC), 0x89 (G.711μ), etc. -[2] 1 flags Video: 0x00=P-frame, 0x01=I-frame (keyframe) - Audio: (sr_idx << 2) | (bits16 << 1) | stereo -[3] 1 cam_index Camera index (usually 0) -[4] 1 online_num Number of viewers -[5] 1 framerate FPS (e.g., 20, 30) -[6] 1 frame_size 0=1080P, 1=SD, 2=360P, 4=2K -[7] 1 bitrate Bitrate value -[8-11] 4 timestamp_us Microseconds within second (0-999999) -[12-15] 4 timestamp Unix timestamp in seconds (LE) -[16-19] 4 payload_size Total payload size for validation (LE) -[20-23] 4 frame_no Absolute frame counter (LE) -[24-39] 16 device_id MAC address as ASCII + padding -``` - -### 10.2 TX FRAMEINFO (16 bytes) - To Camera - -Used for audio backchannel (intercom): - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 codec_id 0x0090 (AAC Wyze), 0x0089 (G.711μ), etc. -[2] 1 flags (sr_idx << 2) | (bits16 << 1) | stereo -[3] 1 cam_index 0 -[4] 1 online_num 1 (for TX) -[5] 1 tags 0 -[6-11] 6 reserved Zero-filled -[12-15] 4 timestamp_ms Cumulative: (frame_no - 1) * frame_duration_ms -``` - -### 10.3 Audio Flags Encoding - -``` -flags = (sample_rate_index << 2) | (bits16 << 1) | stereo - -Example: 16kHz, 16-bit, Mono - sr_idx=3, bits16=1, stereo=0 - flags = (3 << 2) | (1 << 1) | 0 = 0x0E -``` - ---- - -## 11. Codec Reference - -### 11.1 Video Codecs - -| ID (Hex) | ID (Dec) | Name | -|----------|----------|------| -| 0x4C | 76 | MPEG-4 | -| 0x4D | 77 | H.263 | -| 0x4E | 78 | H.264/AVC | -| 0x4F | 79 | MJPEG | -| 0x50 | 80 | H.265/HEVC | - -### 11.2 Audio Codecs - -| ID (Hex) | ID (Dec) | Name | -|----------|----------|------| -| 0x86 | 134 | AAC Raw | -| 0x87 | 135 | AAC ADTS | -| 0x88 | 136 | AAC LATM | -| 0x89 | 137 | G.711 μ-law (PCMU) | -| 0x8A | 138 | G.711 A-law (PCMA) | -| 0x8B | 139 | ADPCM | -| 0x8C | 140 | PCM 16-bit LE | -| 0x8D | 141 | Speex | -| 0x8E | 142 | MP3 | -| 0x8F | 143 | G.726 | -| 0x90 | 144 | AAC Wyze | -| 0x92 | 146 | Opus | - -### 11.3 Sample Rate Index - -| Index | Frequency | -|-------|-----------| -| 0x00 | 8000 Hz | -| 0x01 | 11025 Hz | -| 0x02 | 12000 Hz | -| 0x03 | 16000 Hz | -| 0x04 | 22050 Hz | -| 0x05 | 24000 Hz | -| 0x06 | 32000 Hz | -| 0x07 | 44100 Hz | -| 0x08 | 48000 Hz | - ---- - -## 12. Two-Way Audio (Backchannel) - -### 12.1 Activation Flow - -1. Send K10010 with MediaType=3 (ReturnAudio), Enable=1 -2. Wait for K10011 response confirming activation -3. Camera initiates DTLS connection back (we become DTLS **server**) -4. Use Channel 1 (IOTCChannelBack) for audio transmission - -### 12.2 Audio TX Frame Format - -All audio TX uses 0x09 single-packet frames with 36-byte header: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Channel 0x03 (Audio) -[1] 1 FrameType 0x09 (StartAlt/Single) -[2-3] 2 Version 0x000C (12) -[4-7] 4 TxSeq Audio TX sequence number (LE) -[8-11] 4 TimestampUS Timestamp in microseconds (LE) -[12-15] 4 Flags 0x00000001 (first), 0x00100001 (subsequent) -[16] 1 Channel 0x03 -[17] 1 FrameType 0x01 (EndSingle) -[18-19] 2 PrevFrameNo prev_frame_no (16-bit, LE) -[20-21] 2 pkt_total 0x0001 (always single packet) -[22-23] 2 Flags 0x0010 -[24-27] 4 PayloadSize audio_len + 16 (includes FRAMEINFO) -[28-31] 4 PrevFrameNo prev_frame_no (32-bit, LE) -[32-35] 4 FrameNo Current frame number (LE) -[36...] AudioPayload AAC/G.711/Opus data -[end-16] 16 FRAMEINFO TX FRAMEINFO (16 bytes) -``` - ---- - -## 13. Frame Reassembly - -### Algorithm - -``` -1. Parse packet header to extract: - - channel, frameType, pkt_idx, pkt_total, frame_no - -2. Detect frame transition: - - If frame_no changed from previous packet: - - Emit previous frame if complete - - Log incomplete frames - -3. Store packet data: - - Key: pkt_idx (0 to pkt_total-1) - - Value: payload bytes (COPY - buffer is reused!) - -4. Store FRAMEINFO if present: - - Only in End packets (0x01, 0x05, 0x0D) - - Or 0x09 when pkt_total == 1 - -5. Check completion: - - All pkt_total packets received? - - FRAMEINFO present? - -6. Assemble frame: - - Concatenate: packets[0] + packets[1] + ... + packets[pkt_total-1] - - Validate size against FRAMEINFO.payload_size - - Emit to consumer -``` - -### Example: Multi-Packet I-Frame (14 packets) - -``` -Packet 1: ch=0x05 type=0x08 pkt=0/14 frame=1 ← Start (36B header) -Packet 2: ch=0x05 type=0x00 pkt=1/14 frame=1 ← Cont (28B header) -Packet 3: ch=0x05 type=0x00 pkt=2/14 frame=1 ← Cont -... -Packet 13: ch=0x05 type=0x00 pkt=12/14 frame=1 ← Cont -Packet 14: ch=0x05 type=0x05 pkt=13/14 frame=1 ← EndMulti + FRAMEINFO -``` - -### Example: Single-Packet P-Frame - -``` -Packet 1: ch=0x07 type=0x01 pkt=0/1 frame=42 ← EndSingle + FRAMEINFO -``` - ---- - -## 14. Wyze Cloud API - -### 14.1 Authentication - -**Endpoint:** `POST https://auth-prod.api.wyze.com/api/user/login` - -**Password Hashing:** Triple MD5 -``` -hash = password -for i in range(3): - hash = MD5(hash).hex() -``` - -**Request Headers:** -``` -Content-Type: application/json -X-API-Key: WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ -Phone-Id: -User-Agent: wyze_ios_2.50.0 -``` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "" -} -``` - -**Response:** -```json -{ - "access_token": "...", - "refresh_token": "...", - "user_id": "..." -} -``` - -### 14.2 Device List - -**Endpoint:** `POST https://api.wyzecam.com/app/v2/home_page/get_object_list` - -**Request Body:** -```json -{ - "access_token": "", - "phone_id": "", - "app_name": "com.hualai.WyzeCam", - "app_ver": "com.hualai.WyzeCam___2.50.0", - "app_version": "2.50.0", - "phone_system_type": 1, - "sc": "9f275790cab94a72bd206c8876429f3c", - "sv": "9d74946e652647e9b6c9d59326aef104", - "ts": -} -``` - -**Response (filtered for cameras):** -```json -{ - "device_list": [ - { - "mac": "AABBCCDDEEFF", - "p2p_id": "HSBJYB5HSETGCDWD111A", - "enr": "roTRg3tiuL3TjXhm...", - "ip": "192.168.1.100", - "nickname": "Front Door", - "product_model": "HL_CAM4", - "dtls": 1, - "firmware_ver": "4.52.9.4188" - } - ] -} -``` - ---- - -## 15. Cryptography Details - -### 15.1 XXTEA Algorithm - -Block cipher used for K-Auth challenge-response: - -``` -Constants: - DELTA = 0x9E3779B9 - -Function mx(sum, y, z, p, e, k): - return (((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ - ((sum ^ y) + (k[(p & 3) ^ e] ^ z)) - -Decrypt(data, key): - v = data as uint32[] (little-endian) - k = key as uint32[] - n = len(v) - rounds = 6 + 52/n - sum = rounds * DELTA - - for round in range(rounds): - e = (sum >> 2) & 3 - for p in range(n-1, 0, -1): - z = v[p-1] - v[p] -= mx(sum, y=v[(p+1) mod n], z, p, e, k) - y = v[p] - z = v[n-1] - v[0] -= mx(sum, y=v[1], z, 0, e, k) - y = v[0] - sum -= DELTA - - return v as bytes -``` - -### 15.2 TransCode ("Charlie" Cipher) - -Obfuscation cipher for IOTC packets: - -``` -Magic string: "Charlie is the designer of P2P!!" - -Process in 16-byte blocks: - 1. XOR each byte with corresponding position in magic string - 2. Treat as 4 x uint32, rotate left by varying amounts - 3. Apply byte permutation pattern - -Permutation for 16-byte block: - [11, 9, 8, 15, 13, 10, 12, 14, 2, 1, 5, 0, 6, 4, 7, 3] -``` - -### 15.3 AuthKey Calculation - -``` -input = ENR + uppercase(MAC) -hash = SHA256(input) -raw = hash[0:6] -b64 = Base64Encode(raw) -authkey = b64.replace('+', 'Z').replace('/', '9').replace('=', 'A') -``` - ---- - -## 16. Constants Reference - -### 16.1 IOTC Commands - -| Command | Value | Description | -|---------|-------|-------------| -| CmdDiscoReq | 0x0601 | Discovery request | -| CmdDiscoRes | 0x0602 | Discovery response | -| CmdSessionReq | 0x0402 | Session request | -| CmdSessionRes | 0x0404 | Session response | -| CmdDataTX | 0x0407 | Data transmission | -| CmdDataRX | 0x0408 | Data reception | -| CmdKeepaliveReq | 0x0427 | Keepalive request | -| CmdKeepaliveRes | 0x0428 | Keepalive response | - -### 16.2 Magic Values - -| Magic | Value | Description | -|-------|-------|-------------| -| MagicAVLogin1 | 0x0000 | AV Login packet 1 | -| MagicAVLogin2 | 0x2000 | AV Login packet 2 | -| MagicAVLoginResp | 0x2100 | AV Login response | -| MagicIOCtrl | 0x7000 | IOCTRL frame | -| MagicChannelMsg | 0x1000 | Channel message | -| MagicACK | 0x0009 | ACK frame | - -### 16.3 K-Commands - -| Command | ID | Description | -|---------|-----|-------------| -| KCmdAuth | 10000 | Auth request (with JSON) | -| KCmdChallenge | 10001 | Challenge from camera | -| KCmdChallengeResp | 10002 | Challenge response | -| KCmdAuthResult | 10003 | Auth result (JSON) | -| KCmdControlChannel | 10010 | Start/stop media | -| KCmdControlChannelResp | 10011 | Control response | -| KCmdSetResolutionDB | 10052 | Set resolution (doorbell) | -| KCmdSetResolutionDBResp | 10053 | Resolution response (doorbell) | -| KCmdSetResolution | 10056 | Set resolution/bitrate | -| KCmdSetResolutionResp | 10057 | Resolution response | - -### 16.4 IOTYPE Values - -| Type | Value | Description | -|------|-------|-------------| -| IOTypeVideoStart | 0x01FF | Start video | -| IOTypeVideoStop | 0x02FF | Stop video | -| IOTypeAudioStart | 0x0300 | Start audio | -| IOTypeAudioStop | 0x0301 | Stop audio | -| IOTypeSpeakerStart | 0x0350 | Start intercom | -| IOTypeSpeakerStop | 0x0351 | Stop intercom | -| IOTypeDevInfoReq | 0x0340 | Device info request | -| IOTypeDevInfoRes | 0x0341 | Device info response | -| IOTypePTZCommand | 0x1001 | PTZ control | -| IOTypeReceiveFirstFrame | 0x1002 | Request keyframe | - -### 16.5 Protocol Constants - -| Constant | Value | Description | -|----------|-------|-------------| -| DefaultPort | 32761 | TUTK discovery port | -| ProtocolVersion | 0x000C | Version 12 | -| DefaultCapabilities | 0x001F07FB | Standard caps | -| MaxPacketSize | 2048 | Max UDP packet | -| IOTCChannelMain | 0 | Main channel (DTLS client) | -| IOTCChannelBack | 1 | Backchannel (DTLS server) | - -### 16.6 NEW Protocol Constants - -| Constant | Value | Description | -|----------|-------|-------------| -| MagicNewProto | 0xCC51 | NEW protocol magic (LE) | -| CmdNewProtoDiscovery | 0x1002 | Discovery command | -| CmdNewProtoDTLS | 0x1502 | DTLS data command | -| NewProtoPayloadSize | 0x0028 | 40 bytes payload | -| NewProtoPacketSize | 52 | Total discovery packet size | -| NewProtoHeaderSize | 28 | DTLS packet header size | -| NewProtoAuthSize | 20 | Auth bytes (HMAC-SHA1) | - ---- - -## 17. NEW Protocol (0xCC51) Overview - -The NEW protocol (magic 0xCC51) is used by Wyze Cam v4 with firmware 4.52.9.5332 and later. It replaces the TransCode cipher layer with HMAC-SHA1 authentication and simplifies the discovery process. - -### Key Differences from OLD Protocol - -| Aspect | OLD Protocol | NEW Protocol | -|--------|--------------|--------------| -| Magic | TransCode encoded | 0xCC51 | -| Discovery | 0x0601/0x0602 + 0x0402/0x0404 | 0x1002 (4-packet handshake) | -| Encryption | TransCode + DTLS | HMAC-SHA1 + DTLS | -| DTLS Wrapper | DATA_TX 0x0407 | 0x1502 with auth bytes | -| P2P Servers | Required for relay | Not required (LAN only) | - -### Authentication - -All NEW protocol packets include a 20-byte HMAC-SHA1 authentication field: - -```go -// Key derivation -authKey := CalculateAuthKey(enr, mac) // 8-byte key from ENR + MAC -key := append([]byte(uid), authKey...) // UID (20 bytes) + AuthKey (8 bytes) - -// HMAC-SHA1 calculation -h := hmac.New(sha1.New, key) -h.Write(packetHeader) // Header bytes before auth field -authBytes := h.Sum(nil) // 20 bytes -``` - ---- - -## 18. NEW Protocol Discovery - -Discovery uses command 0x1002 with a 4-packet handshake sequence. - -### 18.1 Discovery Packet Structure (52 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0xCC51 (little-endian) -[2-3] 2 Flags 0x0000 (constant) -[4-5] 2 Command 0x1002 (Discovery) -[6-7] 2 PayloadSize 0x0028 (40 bytes) -[8-9] 2 Direction 0x0000=Request, 0xFFFF=Response -[10-11] 2 Reserved 0x0000 -[12-13] 2 Sequence 0, 1, 2, or 3 -[14-15] 2 Ticket 0x0000 initially, then from camera -[16-23] 8 SessionID Random[2] + Constant[6] -[24-31] 8 Capabilities 0x00 08 03 04 1d 00 00 00 -[32-51] 20 AuthBytes HMAC-SHA1(key, header[0:32]) -``` - -### 18.2 Handshake Sequence - -``` -Step Direction Seq Ticket Description -──────────────────────────────────────────────────────────────── -1 Client→Cam 0 0x0000 Discovery request (broadcast) -2 Cam→Client 1 T Discovery response (ticket assigned) -3 Client→Cam 2 T Echo request (confirms ticket) -4 Cam→Client 3 T Echo ACK (handshake complete) -``` - -### 18.3 SessionID Generation - -```go -sessionID := make([]byte, 8) -rand.Read(sessionID[:2]) // Random prefix -copy(sessionID[2:], []byte{0x76, 0x0a, 0x9d, 0x24, 0x88, 0xba}) // Constant suffix -``` - ---- - -## 19. NEW Protocol DTLS Wrapper - -After discovery, DTLS records are wrapped in command 0x1502 frames with HMAC-SHA1 authentication. - -### 19.1 DTLS Wrapper Structure (variable size) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0xCC51 (little-endian) -[2-3] 2 Flags 0x0000 -[4-5] 2 Command 0x1502 (DTLS) -[6-7] 2 PayloadSize 16 + dtls_len + 20 -[8-9] 2 Direction 0x0000=Request -[10-11] 2 Reserved 0x0000 -[12-13] 2 Sequence 0x0010 (fixed for DTLS) -[14-15] 2 Ticket From discovery handshake -[16-23] 8 SessionID 8 bytes from discovery -[24-27] 4 Channel 1=Main (client), 2=Back (server) -[28-N] var DTLSPayload Raw DTLS record -[N:N+20] 20 AuthBytes HMAC-SHA1(key, bytes[0:N]) -``` - -### 19.2 PayloadSize Calculation - -``` -PayloadSize = 16 + len(DTLSPayload) + 20 - -Where: - 16 = seq(2) + ticket(2) + sessionID(8) + channel(4) - 20 = AuthBytes (HMAC-SHA1) -``` - -### 19.3 TX/RX Processing - -**Transmit (TX):** -1. Build header with magic, command, payload size -2. Append session fields (seq, ticket, sessionID, channel) -3. Append DTLS payload -4. Calculate HMAC-SHA1 over entire packet (excluding auth bytes position) -5. Append auth bytes - -**Receive (RX):** -1. Verify magic == 0xCC51 -2. Extract DTLS payload from position 28 to (length - 20) -3. Strip 20 auth bytes from end -4. Pass DTLS payload to DTLS layer diff --git a/pkg/wyze/tutk/proto.go b/pkg/wyze/tutk/proto.go deleted file mode 100644 index 5614d643..00000000 --- a/pkg/wyze/tutk/proto.go +++ /dev/null @@ -1,281 +0,0 @@ -package tutk - -type AVLoginResponse struct { - ServerType uint32 - Resend int32 - TwoWayStreaming int32 - SyncRecvData int32 - SecurityMode uint32 - VideoOnConnect int32 - AudioOnConnect int32 -} - -const ( - CodecUnknown uint16 = 0x00 - CodecMPEG4 uint16 = 0x4C // 76 - CodecH263 uint16 = 0x4D // 77 - CodecH264 uint16 = 0x4E // 78 - CodecMJPEG uint16 = 0x4F // 79 - CodecH265 uint16 = 0x50 // 80 -) - -const ( - AudioCodecAACRaw uint16 = 0x86 // 134 - AudioCodecAACADTS uint16 = 0x87 // 135 - AudioCodecAACLATM uint16 = 0x88 // 136 - AudioCodecG711U uint16 = 0x89 // 137 - AudioCodecG711A uint16 = 0x8A // 138 - AudioCodecADPCM uint16 = 0x8B // 139 - AudioCodecPCM uint16 = 0x8C // 140 - AudioCodecSPEEX uint16 = 0x8D // 141 - AudioCodecMP3 uint16 = 0x8E // 142 - AudioCodecG726 uint16 = 0x8F // 143 - AudioCodecAACWyze uint16 = 0x90 // 144 - AudioCodecOpus uint16 = 0x92 // 146 -) - -const ( - SampleRate8K uint8 = 0x00 - SampleRate11K uint8 = 0x01 - SampleRate12K uint8 = 0x02 - SampleRate16K uint8 = 0x03 - SampleRate22K uint8 = 0x04 - SampleRate24K uint8 = 0x05 - SampleRate32K uint8 = 0x06 - SampleRate44K uint8 = 0x07 - SampleRate48K uint8 = 0x08 -) - -var sampleRates = map[uint8]int{ - SampleRate8K: 8000, - SampleRate11K: 11025, - SampleRate12K: 12000, - SampleRate16K: 16000, - SampleRate22K: 22050, - SampleRate24K: 24000, - SampleRate32K: 32000, - SampleRate44K: 44100, - SampleRate48K: 48000, -} - -var samplesPerFrame = map[uint16]uint32{ - AudioCodecAACRaw: 1024, - AudioCodecAACADTS: 1024, - AudioCodecAACLATM: 1024, - AudioCodecAACWyze: 1024, - AudioCodecG711U: 160, - AudioCodecG711A: 160, - AudioCodecPCM: 160, - AudioCodecADPCM: 160, - AudioCodecSPEEX: 160, - AudioCodecMP3: 1152, - AudioCodecG726: 160, - AudioCodecOpus: 960, -} - -const ( - IOTypeVideoStart = 0x01FF - IOTypeVideoStop = 0x02FF - IOTypeAudioStart = 0x0300 - IOTypeAudioStop = 0x0301 - IOTypeSpeakerStart = 0x0350 - IOTypeSpeakerStop = 0x0351 - IOTypeGetAudioOutFormatReq = 0x032A - IOTypeGetAudioOutFormatRes = 0x032B - IOTypeSetStreamCtrlReq = 0x0320 - IOTypeSetStreamCtrlRes = 0x0321 - IOTypeGetStreamCtrlReq = 0x0322 - IOTypeGetStreamCtrlRes = 0x0323 - IOTypeDevInfoReq = 0x0340 - IOTypeDevInfoRes = 0x0341 - IOTypeGetSupportStreamReq = 0x0344 - IOTypeGetSupportStreamRes = 0x0345 - IOTypeSetRecordReq = 0x0310 - IOTypeSetRecordRes = 0x0311 - IOTypeGetRecordReq = 0x0312 - IOTypeGetRecordRes = 0x0313 - IOTypePTZCommand = 0x1001 - IOTypeReceiveFirstFrame = 0x1002 - IOTypeGetEnvironmentReq = 0x030A - IOTypeGetEnvironmentRes = 0x030B - IOTypeSetVideoModeReq = 0x030C - IOTypeSetVideoModeRes = 0x030D - IOTypeGetVideoModeReq = 0x030E - IOTypeGetVideoModeRes = 0x030F - IOTypeSetTimeReq = 0x0316 - IOTypeSetTimeRes = 0x0317 - IOTypeGetTimeReq = 0x0318 - IOTypeGetTimeRes = 0x0319 - IOTypeSetWifiReq = 0x0102 - IOTypeSetWifiRes = 0x0103 - IOTypeGetWifiReq = 0x0104 - IOTypeGetWifiRes = 0x0105 - IOTypeListWifiAPReq = 0x0106 - IOTypeListWifiAPRes = 0x0107 - IOTypeSetMotionDetectReq = 0x0306 - IOTypeSetMotionDetectRes = 0x0307 - IOTypeGetMotionDetectReq = 0x0308 - IOTypeGetMotionDetectRes = 0x0309 -) - -// OLD Protocol (IOTC/TransCode) -const ( - CmdDiscoReq uint16 = 0x0601 - CmdDiscoRes uint16 = 0x0602 - CmdSessionReq uint16 = 0x0402 - CmdSessionRes uint16 = 0x0404 - CmdDataTX uint16 = 0x0407 - CmdDataRX uint16 = 0x0408 - CmdKeepaliveReq uint16 = 0x0427 - CmdKeepaliveRes uint16 = 0x0428 - - OldHeaderSize = 16 - OldDiscoBodySize = 72 - OldDiscoSize = OldHeaderSize + OldDiscoBodySize - OldSessionBody = 36 - OldSessionSize = OldHeaderSize + OldSessionBody -) - -// NEW Protocol (0xCC51) -const ( - MagicNewProto uint16 = 0xCC51 - CmdNewDisco uint16 = 0x1002 - CmdNewKeepalive uint16 = 0x1202 - CmdNewClose uint16 = 0x1302 - CmdNewDTLS uint16 = 0x1502 - NewPayloadSize uint16 = 0x0028 - NewPacketSize = 52 - NewHeaderSize = 28 - NewAuthSize = 20 - NewKeepaliveSize = 48 -) - -const ( - UIDSize = 20 - RandIDSize = 8 -) - -const ( - MagicAVLoginResp uint16 = 0x2100 - MagicIOCtrl uint16 = 0x7000 - MagicChannelMsg uint16 = 0x1000 - MagicACK uint16 = 0x0009 - MagicAVLogin1 uint16 = 0x0000 - MagicAVLogin2 uint16 = 0x2000 -) - -const ( - ProtoVersion uint16 = 0x000c - DefaultCaps uint32 = 0x001f07fb -) - -const ( - IOTCChannelMain = 0 // Main AV (we = DTLS Client) - IOTCChannelBack = 1 // Backchannel (we = DTLS Server) -) - -const ( - PSKIdentity = "AUTHPWD_admin" - DefaultUser = "admin" - DefaultPort = 32761 -) - -func CodecName(id uint16) string { - switch id { - case CodecH264: - return "H264" - case CodecH265: - return "H265" - case CodecMPEG4: - return "MPEG4" - case CodecH263: - return "H263" - case CodecMJPEG: - return "MJPEG" - default: - return "Unknown" - } -} - -func AudioCodecName(id uint16) string { - switch id { - case AudioCodecG711U: - return "PCMU" - case AudioCodecG711A: - return "PCMA" - case AudioCodecPCM: - return "PCM" - case AudioCodecAACLATM, AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACWyze: - return "AAC" - case AudioCodecOpus: - return "Opus" - case AudioCodecSPEEX: - return "Speex" - case AudioCodecMP3: - return "MP3" - case AudioCodecG726: - return "G726" - case AudioCodecADPCM: - return "ADPCM" - default: - return "Unknown" - } -} - -func SampleRateValue(idx uint8) int { - if rate, ok := sampleRates[idx]; ok { - return rate - } - return 16000 -} - -func SampleRateIndex(hz uint32) uint8 { - switch hz { - case 8000: - return SampleRate8K - case 11025: - return SampleRate11K - case 12000: - return SampleRate12K - case 16000: - return SampleRate16K - case 22050: - return SampleRate22K - case 24000: - return SampleRate24K - case 32000: - return SampleRate32K - case 44100: - return SampleRate44K - case 48000: - return SampleRate48K - default: - return SampleRate16K - } -} - -func BuildAudioFlags(sampleRate uint32, bits16, stereo bool) uint8 { - flags := SampleRateIndex(sampleRate) << 2 - if bits16 { - flags |= 0x02 - } - if stereo { - flags |= 0x01 - } - return flags -} - -func IsVideoCodec(id uint16) bool { - return id >= CodecMPEG4 && id <= CodecH265 -} - -func IsAudioCodec(id uint16) bool { - return id >= AudioCodecAACRaw && id <= AudioCodecOpus -} - -func GetSamplesPerFrame(codecID uint16) uint32 { - if samples, ok := samplesPerFrame[codecID]; ok { - return samples - } - return 1024 -} diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index a35592d4..242fda3d 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -107,7 +107,7 @@ func (c *Client) ReadPacket() (hdr, payload []byte, err error) { switch hdr[0] { case tutk.CodecH264, tutk.CodecH265: payload, err = DecodeVideo(payload, c.key) - case tutk.CodecAAC: + case tutk.CodecAACLATM: payload, err = crypto.Decode(payload, c.key) } } diff --git a/pkg/xiaomi/legacy/producer.go b/pkg/xiaomi/legacy/producer.go index 5c1f795d..92375faf 100644 --- a/pkg/xiaomi/legacy/producer.go +++ b/pkg/xiaomi/legacy/producer.go @@ -98,7 +98,7 @@ func probe(client *Client) ([]*core.Media, error) { if acodec == nil { acodec = &core.Codec{Name: core.CodecPCML, ClockRate: 8000} } - case tutk.CodecAAC: + case tutk.CodecAACLATM: if acodec == nil { acodec = aac.ADTSToCodec(payload) if acodec != nil { @@ -187,7 +187,7 @@ func (c *Producer) Start() error { audioTS += uint32(n / 2) // because 16bit } - case tutk.CodecAAC: + case tutk.CodecAACLATM: pkt = &core.Packet{ Header: rtp.Header{ SequenceNumber: audioSeq, From af90b4c12c67c476e739793485efe16cac3589a7 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 20:31:25 +0100 Subject: [PATCH 227/241] update readme --- README.md | 241 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 124 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 3e4a4668..5d484218 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - devices: `alsa` (Linux audio), `v4l2` (Linux video) - files: `adts`, `flv`, `h264`, `hevc`, `hls`, `mjpeg`, `mpegts`, `mp4`, `wav` -- network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `y2m` (yuv4mpegpipe) -- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `wyze`, `xiaomi` (Mi Home) +- network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `yuv4mpegpipe` +- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `multitrans` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `wyze`, `xiaomi` (Mi Home) - webrtc related: `creality`, `kinesis` (Amazon), `openipc`, `switchbot`, `whep`, `whip`, `wyze` - other: `ascii`, `echo`, `exec`, `expr`, `ffmpeg` @@ -64,38 +64,39 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [go2rtc: Dev version](#go2rtc-dev-version) * [Configuration](#configuration) * [Module: Streams](#module-streams) - * [Two way audio](#two-way-audio) - * [Source: RTSP](#source-rtsp) - * [Source: RTMP](#source-rtmp) - * [Source: HTTP](#source-http) - * [Source: ONVIF](#source-onvif) - * [Source: FFmpeg](#source-ffmpeg) - * [Source: FFmpeg Device](#source-ffmpeg-device) - * [Source: Exec](#source-exec) - * [Source: Echo](#source-echo) - * [Source: Expr](#source-expr) - * [Source: HomeKit](#source-homekit) - * [Source: Bubble](#source-bubble) - * [Source: DVRIP](#source-dvrip) - * [Source: Tapo](#source-tapo) - * [Source: Kasa](#source-kasa) - * [Source: Tuya](#source-tuya) - * [Source: Xiaomi](#source-xiaomi) - * [Source: Wyze](#source-wyze) - * [Source: GoPro](#source-gopro) - * [Source: Ivideon](#source-ivideon) - * [Source: Hass](#source-hass) - * [Source: ISAPI](#source-isapi) - * [Source: Nest](#source-nest) - * [Source: Ring](#source-ring) - * [Source: Roborock](#source-roborock) - * [Source: Doorbird](#source-doorbird) - * [Source: WebRTC](#source-webrtc) - * [Source: WebTorrent](#source-webtorrent) - * [Incoming sources](#incoming-sources) - * [Stream to camera](#stream-to-camera) - * [Publish stream](#publish-stream) - * [Preload stream](#preload-stream) + * [Two way audio](#two-way-audio) + * [Source: RTSP](#source-rtsp) + * [Source: RTMP](#source-rtmp) + * [Source: HTTP](#source-http) + * [Source: ONVIF](#source-onvif) + * [Source: FFmpeg](#source-ffmpeg) + * [Source: FFmpeg Device](#source-ffmpeg-device) + * [Source: Exec](#source-exec) + * [Source: Echo](#source-echo) + * [Source: Expr](#source-expr) + * [Source: HomeKit](#source-homekit) + * [Source: Bubble](#source-bubble) + * [Source: DVRIP](#source-dvrip) + * [Source: Tapo](#source-tapo) + * [Source: Kasa](#source-kasa) + * [Source: Multitrans](#source-multitrans) + * [Source: Tuya](#source-tuya) + * [Source: Xiaomi](#source-xiaomi) + * [Source: Wyze](#source-wyze) + * [Source: GoPro](#source-gopro) + * [Source: Ivideon](#source-ivideon) + * [Source: Hass](#source-hass) + * [Source: ISAPI](#source-isapi) + * [Source: Nest](#source-nest) + * [Source: Ring](#source-ring) + * [Source: Roborock](#source-roborock) + * [Source: Doorbird](#source-doorbird) + * [Source: WebRTC](#source-webrtc) + * [Source: WebTorrent](#source-webtorrent) + * [Incoming sources](#incoming-sources) + * [Stream to camera](#stream-to-camera) + * [Publish stream](#publish-stream) + * [Preload stream](#preload-stream) * [Module: API](#module-api) * [Module: RTSP](#module-rtsp) * [Module: RTMP](#module-rtmp) @@ -115,9 +116,8 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [Projects using go2rtc](#projects-using-go2rtc) * [Camera experience](#cameras-experience) * [TIPS](#tips) -* [FAQ](#faq) -## Fast start +# Fast start 1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or Home Assistant [Add-on](#go2rtc-home-assistant-add-on) or [Integration](#go2rtc-home-assistant-integration) 2. Open web interface: `http://localhost:1984/` @@ -132,7 +132,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - write your own [web interface](#module-api) - integrate [web api](#module-api) into your smart home platform -### go2rtc: Binary +## go2rtc: Binary Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): @@ -154,11 +154,11 @@ Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac. PS. The application is compiled with the latest versions of the Go language for maximum speed and security. Therefore, the [minimum OS versions](https://go.dev/wiki/MinimumRequirements) depend on the Go language. -### go2rtc: Docker +## go2rtc: Docker The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant Add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg) and [Python](#source-echo). -### go2rtc: Home Assistant Add-on +## go2rtc: Home Assistant Add-on [![](https://my.home-assistant.io/badges/supervisor_addon.svg)](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons) @@ -167,11 +167,11 @@ The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) - go2rtc > Install > Start 2. Setup [Integration](#module-hass) -### go2rtc: Home Assistant Integration +## go2rtc: Home Assistant Integration [WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom component can be used on any [Home Assistant installation](https://www.home-assistant.io/installation/), including [HassWP](https://github.com/AlexxIT/HassWP) on Windows. It can automatically download and use the latest version of go2rtc. Or it can connect to an existing version of go2rtc. Addon installation in this case is optional. -### go2rtc: Dev version +## go2rtc: Dev version Latest, but maybe unstable version: @@ -179,7 +179,7 @@ Latest, but maybe unstable version: - Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions - Hass Add-on: `go2rtc master` or `go2rtc master hardware` versions -## Configuration +# Configuration - by default go2rtc will search `go2rtc.yaml` in the current work directory - `api` server will start on default **1984 port** (TCP) @@ -203,7 +203,7 @@ Available modules: - [hass](#module-hass) - Home Assistant integration - [log](#module-log) - logs config -### Module: Streams +## Module: Streams **go2rtc** supports different stream source types. You can config one or multiple links of any type as a stream source. @@ -239,7 +239,7 @@ Available source types: Read more about [incoming sources](#incoming-sources) -#### Two-way audio +## Two-way audio Supported sources: @@ -260,7 +260,7 @@ Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. T go2rtc also supports [play audio](#stream-to-camera) files and live streams on this cameras. -#### Source: RTSP +## Source: RTSP ```yaml streams: @@ -304,7 +304,7 @@ streams: dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket ``` -#### Source: RTMP +## Source: RTMP You can get a stream from an RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). @@ -313,7 +313,7 @@ streams: rtmp_stream: rtmp://192.168.1.123/live/camera1 ``` -#### Source: HTTP +## Source: HTTP Support Content-Type: @@ -344,7 +344,7 @@ streams: **PS.** Dahua camera has a bug: if you select MJPEG codec for RTSP second stream, snapshot won't work. -#### Source: ONVIF +## Source: ONVIF *[New in v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)* @@ -359,7 +359,7 @@ streams: tapo1: onvif://admin:password@192.168.1.123:2020 ``` -#### Source: FFmpeg +## Source: FFmpeg You can get any stream, file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. @@ -389,7 +389,7 @@ streams: rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90 ``` -All transcoding formats have [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. +All transcoding formats have [built-in templates](internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. But you can override them via YAML config. You can also add your own formats to the config and use them with source params. @@ -420,7 +420,7 @@ Read more about [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/H **PS.** It is recommended to check the available hardware in the WebUI add page. -#### Source: FFmpeg Device +## Source: FFmpeg Device You can get video from any USB camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. @@ -441,7 +441,7 @@ streams: **PS.** It is recommended to check the available devices in the WebUI add page. -#### Source: Exec +## Source: Exec Exec source can run any external application and expect data from it. Two transports are supported - **pipe** (*from [v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)*) and **RTSP**. @@ -474,7 +474,7 @@ streams: play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1 ``` -#### Source: Echo +## Source: Echo Some sources may have a dynamic link. And you will need to get it using a Bash or Python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams). @@ -487,13 +487,15 @@ streams: apple_hls: echo:python3 hls.py https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html ``` -#### Source: Expr +## Source: Expr *[New in v1.8.2](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.2)* -Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/expr/README.md)). +Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language. -#### Source: HomeKit +*[read more](internal/expr/README.md)* + +## Source: HomeKit **Important:** @@ -526,7 +528,7 @@ RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g **This source is in active development!** Tested only with [Aqara Camera Hub G3](https://www.aqara.com/eu/product/camera-hub-g3) (both EU and CN versions). -#### Source: Bubble +## Source: Bubble *[New in v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)* @@ -540,7 +542,7 @@ streams: camera1: bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0 ``` -#### Source: DVRIP +## Source: DVRIP *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* @@ -560,7 +562,7 @@ streams: - dvrip://username:password@192.168.1.123:34567?backchannel=1 ``` -#### Source: EseeCloud +## Source: EseeCloud *[New in v1.9.10](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10)* @@ -569,7 +571,7 @@ streams: camera1: eseecloud://user:pass@192.168.1.123:80/livestream/12 ``` -#### Source: Tapo +## Source: Tapo *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* @@ -599,7 +601,7 @@ echo -n "cloud password" | md5 | awk '{print toupper($0)}' echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}' ``` -#### Source: Kasa +## Source: Kasa *[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)* @@ -615,29 +617,41 @@ streams: Tested: KD110, KC200, KC401, KC420WS, EC71. -#### Source: Tuya +## Source: Multitrans + +Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-link.com.cn/list_2549.html). + +*[read more](internal/multitrans/README.md)* + +## Source: Tuya *[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/tuya/README.md). +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. -#### Source: Xiaomi +*[read more](internal/tuya/README.md)* + +## Source: Xiaomi *[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* -This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/xiaomi/README.md). +This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. -#### Source: Wyze +## Source: Wyze This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol - no docker-wyze-bridge required. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/wyze/README.md). -#### Source: GoPro +*[read more](internal/xiaomi/README.md)* + +## Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* -Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. [Read more](https://github.com/AlexxIT/go2rtc/tree/master/internal/gopro). +Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. -#### Source: Ivideon +*[read more](internal/gopro/README.md)* + +## Source: Ivideon Support public cameras from the service [Ivideon](https://tv.ivideon.com/). @@ -646,7 +660,7 @@ streams: quailcam: ivideon:100-tu5dkUPct39cTp9oNEN2B6/0 ``` -#### Source: Hass +## Source: Hass Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files: @@ -682,7 +696,7 @@ streams: By default, the Home Assistant API does not allow you to get a dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others, can also be imported using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). -#### Source: ISAPI +## Source: ISAPI *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -695,7 +709,7 @@ streams: - isapi://admin:password@192.168.1.123:80/ ``` -#### Source: Nest +## Source: Nest *[New in v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)* @@ -708,7 +722,7 @@ streams: nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** ``` -#### Source: Ring +## Source: Ring This source type support Ring cameras with [two way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras. @@ -718,7 +732,7 @@ streams: ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot ``` -#### Source: Roborock +## Source: Roborock *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -732,22 +746,13 @@ Source supports loading Roborock credentials from Home Assistant [custom integra If you have a graphic PIN for your vacuum, add it as a numeric PIN (lines: 123, 456, 789) to the end of the `roborock` link. -#### Source: Doorbird +## Source: Doorbird -*[New in v1.9.11](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)* +This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio. -This source type supports Doorbird devices including MJPEG stream, audio stream as well as two-way audio. +*[read more](internal/doorbird/README.md)* -```yaml -streams: - doorbird1: - - rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream - - doorbird://admin:password@192.168.1.123?media=video # MJPEG stream - - doorbird://admin:password@192.168.1.123?media=audio # audio stream - - doorbird://admin:password@192.168.1.123 # two-way audio -``` - -#### Source: WebRTC +## Source: WebRTC *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -789,7 +794,7 @@ streams: **PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language. -#### Source: WebTorrent +## Source: WebTorrent *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -800,7 +805,7 @@ streams: webtorrent1: webtorrent:?share=huofssuxaty00izc&pwd=k3l2j9djeg8v8r7e ``` -#### Incoming sources +## Incoming sources By default, go2rtc establishes a connection to the source when any client requests it. Go2rtc drops the connection to the source when it has no clients left. @@ -829,7 +834,7 @@ By default, go2rtc establishes a connection to the source when any client reques ffmpeg -re -i BigBuckBunny.mp4 -c copy -f mpegts http://localhost:1984/api/stream.ts?dst=camera1 ``` -#### Incoming: Browser +### Incoming: Browser *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -841,7 +846,7 @@ You can turn the browser of any PC or mobile into an IP camera with support for 4. Select `camera+microphone` or `display+speaker` option 5. Open `webrtc` local page (your go2rtc **should work over HTTPS!**) or `share link` via [WebTorrent](#module-webtorrent) technology (work over HTTPS by default) -#### Incoming: WebRTC/WHIP +### Incoming: WebRTC/WHIP *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -849,7 +854,7 @@ You can use **OBS Studio** or any other broadcast software with [WHIP](https://w - Settings > Stream > Service: WHIP > http://192.168.1.123:1984/api/webrtc?dst=camera1 -#### Stream to camera +## Stream to camera *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -871,7 +876,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com - you can stop active playback by calling the API with the empty `src` parameter - you will see one active producer and one active consumer in go2rtc WebUI info page during streaming -### Publish stream +## Publish stream *[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)* @@ -909,7 +914,7 @@ streams: - **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. - **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key. -### Preload stream +## Preload stream You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. @@ -929,13 +934,13 @@ streams: - ffmpeg:camera3#video=h264#audio=opus#hardware ``` -### Module: API +## Module: API The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. **Important!** go2rtc passes requests from localhost and from Unix sockets without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to the API. If not properly configured, an attacker can gain access to your cameras and even your server. -[API description](https://github.com/AlexxIT/go2rtc/tree/master/api). +[API description](api/README.md). **Module config** @@ -971,7 +976,7 @@ api: - MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446) - MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4 -### Module: RTSP +## Module: RTSP You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}` @@ -994,7 +999,7 @@ By default go2rtc provide RTSP-stream with only one first video and only one fir Read more about [codecs filters](#codecs-filters). -### Module: RTMP +## Module: RTMP *[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)* @@ -1007,7 +1012,7 @@ rtmp: listen: ":1935" # by default - disabled! ``` -### Module: WebRTC +## Module: WebRTC In most cases, [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses a direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP. It **can't pass** media data through your Nginx or Cloudflare or [Nabu Casa](https://www.nabucasa.com/) HTTP TCP connection! @@ -1065,7 +1070,7 @@ webrtc: credential: your_pass ``` -### Module: HomeKit +## Module: HomeKit *[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)* @@ -1119,7 +1124,7 @@ homekit: aqara1: # same stream ID from streams list ``` -### Module: WebTorrent +## Module: WebTorrent *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -1145,11 +1150,13 @@ webtorrent: Link example: https://go2rtc.org/webtorrent/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio -### Module: ngrok +## Module: ngrok -With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/ngrok/README.md)). +With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address. -### Module: Hass +*[read more](internal/ngrok/README.md)* + +## Module: Hass The best and easiest way to use go2rtc inside Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom Lovelace card. @@ -1187,7 +1194,7 @@ streams: **PS.** There is also another nice card with go2rtc support - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card). -### Module: MP4 +## Module: MP4 Provides several features: @@ -1210,7 +1217,7 @@ Read more about [codecs filters](#codecs-filters). **PS.** Rotate and scale params don't use transcoding and change video using metadata. -### Module: HLS +## Module: HLS *[New in v1.1.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.1.0)* @@ -1225,7 +1232,7 @@ API examples: Read more about [codecs filters](#codecs-filters). -### Module: MJPEG +## Module: MJPEG **Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. @@ -1253,11 +1260,11 @@ API examples: - You can use `rotate` param with `90`, `180`, `270` or `-90` values - You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) -**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/mjpeg/README.md)): +**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](internal/mjpeg/README.md)). [![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) -### Module: Log +## Module: Log You can set different log levels for different modules. @@ -1271,7 +1278,7 @@ log: webrtc: fatal ``` -## Security +# Security > [!IMPORTANT] > If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server. @@ -1318,7 +1325,7 @@ If you need web interface protection without the Home Assistant add-on, you need PS. Additionally, WebRTC will try to use the 8555 UDP port to transmit encrypted media. It works without problems on the local network, and sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. -## Codecs filters +# Codecs filters go2rtc can automatically detect which codecs your device supports for [WebRTC](#module-webrtc) and [MSE](#module-mp4) technologies. @@ -1341,7 +1348,7 @@ Some examples: - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12) - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non-standard audio codecs, won't work on some players -## Codecs madness +# Codecs madness `AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. @@ -1382,7 +1389,7 @@ Some examples: - AAC = MPEG4-GENERIC - MP3 = MPEG-1 Audio Layer III or MPEG-2 Audio Layer III -## Built-in transcoding +# Built-in transcoding There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support. @@ -1411,7 +1418,7 @@ PCMU/xxx => PCMU/8000 => WebRTC - FLAC codec not supported in an RTSP stream. If you are using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio, you should set up transcoding to the AAC codec. - PCMA and PCMU are VERY low-quality codecs. They support only 256! different sounds. Use them only when you have no other options. -## Codecs negotiation +# Codecs negotiation For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser. @@ -1440,7 +1447,7 @@ streams: **PS.** You can select `PCMU` or `PCMA` codec in camera settings and not use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. -## Projects using go2rtc +# Projects using go2rtc - [Home Assistant](https://www.home-assistant.io/) [2024.11+](https://www.home-assistant.io/integrations/go2rtc/) - top open-source smart home project - [Frigate](https://frigate.video/) [0.12+](https://docs.frigate.video/guides/configuring_go2rtc/) - open-source NVR built around real-time AI object detection @@ -1464,7 +1471,7 @@ streams: - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) -## Camera experience +# Camera experience - [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients - [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol implementation, many bugs in SDP @@ -1474,7 +1481,7 @@ streams: - [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss? - Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usually have `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol implementation, low stream quality, few settings, packet loss? -## TIPS +# TIPS **Using apps for low RTSP delay** From c4930878765da835fe6e1ac56cf4d831f3565f13 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 20:50:57 +0100 Subject: [PATCH 228/241] fix intercom --- pkg/wyze/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 6e691a25..4e04d302 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -201,7 +201,11 @@ func (c *Client) StartAudio() error { } func (c *Client) StartIntercom() error { - if c.conn == nil || !c.conn.IsBackchannelReady() { + if c.conn == nil { + return fmt.Errorf("connection is nil") + } + + if c.conn.IsBackchannelReady() { return nil } From b220959e41130e23f8544aa26a74ad0af5e24866 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 22:12:36 +0100 Subject: [PATCH 229/241] cleanup --- pkg/tutk/conn_dtls.go | 171 +++++++++++++++++++++++------------------- pkg/tutk/helpers.go | 9 +++ pkg/wyze/client.go | 4 +- 3 files changed, 103 insertions(+), 81 deletions(-) diff --git a/pkg/tutk/conn_dtls.go b/pkg/tutk/conn_dtls.go index eccd985f..294990c2 100644 --- a/pkg/tutk/conn_dtls.go +++ b/pkg/tutk/conn_dtls.go @@ -232,19 +232,60 @@ func (c *DTLSConn) AVServStart() error { return fmt.Errorf("dtls: server handshake failed: %w", err) } + if c.verbose { + fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack) + fmt.Printf("[SERVER] Waiting for AV Login request from camera...\n") + } + + // Wait for AV Login request from camera + buf := make([]byte, 1024) + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := conn.Read(buf) + if err != nil { + go conn.Close() + return fmt.Errorf("read av login: %w", err) + } + + if c.verbose { + fmt.Printf("[SERVER] AV Login request len=%d data:\n%s", n, hexDump(buf[:n])) + } + + if n < 24 { + go conn.Close() + return fmt.Errorf("av login too short: %d bytes", n) + } + + checksum := binary.LittleEndian.Uint32(buf[20:]) + resp := c.msgAVLoginResponse(checksum) + + if c.verbose { + fmt.Printf("[SERVER] Sending AV Login response: %d bytes\n", len(resp)) + } + + if _, err = conn.Write(resp); err != nil { + go conn.Close() + return fmt.Errorf("write av login response: %w", err) + } + + // Camera may resend, respond again + conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if n, _ = conn.Read(buf); n > 0 { + if c.verbose { + fmt.Printf("[SERVER] Received AV Login resend: %d bytes\n", n) + } + conn.Write(resp) + } + + conn.SetReadDeadline(time.Time{}) + + if c.verbose { + fmt.Printf("[SERVER] AV Login complete, ready for two way streaming\n") + } + c.mu.Lock() c.serverConn = conn c.mu.Unlock() - if c.verbose { - fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack) - } - - // Wait for and respond to AV Login request from camera - if err := c.handleSpeakerAVLogin(); err != nil { - return fmt.Errorf("speaker av login failed: %w", err) - } - return nil } @@ -284,7 +325,7 @@ func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint3 conn := c.serverConn if conn == nil { c.mu.Unlock() - return fmt.Errorf("speaker channel not connected") + return fmt.Errorf("av server not ready") } frame := c.msgAudioFrame(payload, timestampUS, codec, sampleRate, channels) @@ -294,9 +335,9 @@ func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint3 n, err := conn.Write(frame) if c.verbose { if err != nil { - fmt.Printf("[SPEAKER TX] DTLS Write ERROR: %v\n", err) + fmt.Printf("[SERVER TX] DTLS Write ERROR: %v\n", err) } else { - fmt.Printf("[SPEAKER TX] len=%d, data:\n%s", n, hexDump(frame)) + fmt.Printf("[SERVER TX] len=%d, data:\n%s", n, hexDump(frame)) } } return err @@ -322,6 +363,11 @@ func (c *DTLSConn) WriteDTLS(payload []byte, channel byte) error { return c.Write(frame) } +func (c *DTLSConn) WriteIOCtrl(payload []byte) error { + _, err := c.conn.Write(c.msgIOCtrl(payload)) + return err +} + func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, error) { var t *time.Timer t = time.AfterFunc(1, func() { @@ -386,8 +432,14 @@ func (c *DTLSConn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint ack := c.msgACK() c.clientConn.Write(ack) - if len(data) >= 6 { - if binary.LittleEndian.Uint16(data[4:]) == expectCmd { + if gotCmd, payload, ok := ParseHL(data); ok { + if c.verbose { + fmt.Printf("[DTLS RX] Got rawCmd K%d, expecting K%d, payload=%d bytes\n", gotCmd, expectCmd, len(payload)) + if gotCmd != expectCmd && len(payload) > 0 { + fmt.Printf("[DTLS RX] K%d payload:\n%s", gotCmd, hexDump(payload)) + } + } + if gotCmd == expectCmd { return data, nil } } @@ -423,9 +475,13 @@ func (c *DTLSConn) Close() error { c.cancel() c.mu.Lock() - if c.clientConn != nil { - c.clientConn.Close() + if conn := c.serverConn; conn != nil { + c.serverConn = nil + go conn.Close() + } + if conn := c.clientConn; conn != nil { c.clientConn = nil + go conn.Close() } if c.frames != nil { c.frames.Close() @@ -554,27 +610,33 @@ func (c *DTLSConn) worker() { data := buf[:n] magic := binary.LittleEndian.Uint16(data) + if c.verbose { + fmt.Printf("[DTLS RX] magic=0x%04x len=%d\n", magic, n) + } + switch magic { case magicAVLoginResp: c.queue(c.rawCmd, data) case magicIOCtrl: - if len(data) >= 32 { - for i := 32; i+2 < len(data); i++ { - if data[i] == 'H' && data[i+1] == 'L' { - c.queue(c.rawCmd, data[i:]) - break + if hlData := FindHL(data, 32); hlData != nil { + if c.verbose { + if cmd, _, ok := ParseHL(hlData); ok { + fmt.Printf("[DTLS RX] IOCtrl HL command K%d\n", cmd) } } + c.queue(c.rawCmd, hlData) } case magicChannelMsg: if len(data) >= 36 && data[16] == 0x00 { - for i := 36; i+2 < len(data); i++ { - if data[i] == 'H' && data[i+1] == 'L' { - c.queue(c.rawCmd, data[i:]) - break + if hlData := FindHL(data, 36); hlData != nil { + if c.verbose { + if cmd, _, ok := ParseHL(hlData); ok { + fmt.Printf("[DTLS RX] ChannelMsg HL command K%d\n", cmd) + } } + c.queue(c.rawCmd, hlData) } } @@ -591,13 +653,13 @@ func (c *DTLSConn) worker() { } // Check for HL command response - if len(data) >= 36 { - for i := 32; i+2 < len(data); i++ { - if data[i] == 'H' && data[i+1] == 'L' { - c.queue(c.rawCmd, data[i:]) - break + if hlData := FindHL(data, 32); hlData != nil { + if c.verbose { + if cmd, _, ok := ParseHL(hlData); ok { + fmt.Printf("[DTLS RX] ProtoVersion HL command K%d\n", cmd) } } + c.queue(c.rawCmd, hlData) } } @@ -711,55 +773,6 @@ func (c *DTLSConn) queue(ch chan []byte, data []byte) { } } -func (c *DTLSConn) handleSpeakerAVLogin() error { - if c.verbose { - fmt.Printf("[SPEAK] Waiting for AV Login request from camera...\n") - } - - buf := make([]byte, 1024) - c.serverConn.SetReadDeadline(time.Now().Add(2 * time.Second)) - n, err := c.serverConn.Read(buf) - if err != nil { - return fmt.Errorf("read av login: %w", err) - } - - if c.verbose { - fmt.Printf("[SPEAK] AV Login request len=%d data:\n%s", n, hexDump(buf[:n])) - } - - if n < 24 { - return fmt.Errorf("av login too short: %d bytes", n) - } - - checksum := binary.LittleEndian.Uint32(buf[20:]) - resp := c.msgAVLoginResponse(checksum) - - if c.verbose { - fmt.Printf("[SPEAK] Sending AV Login response: %d bytes\n", len(resp)) - } - - if _, err = c.serverConn.Write(resp); err != nil { - return fmt.Errorf("write AV login response: %w", err) - } - - // Camera may resend, respond again - c.serverConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - if n, _ = c.serverConn.Read(buf); n > 0 { - if c.verbose { - fmt.Printf("[SPEAK] Received AV Login resend: %d bytes\n", n) - } - c.serverConn.Write(resp) - } - - c.serverConn.SetReadDeadline(time.Time{}) - - if c.verbose { - fmt.Printf("[SPEAK] AV Login complete, ready for audio\n") - } - - return nil -} - func (c *DTLSConn) msgDisco(stage byte) []byte { b := make([]byte, discoSize) copy(b, "\x04\x02\x1a\x02") // marker + mode diff --git a/pkg/tutk/helpers.go b/pkg/tutk/helpers.go index b3623b9e..93bf4b5a 100644 --- a/pkg/tutk/helpers.go +++ b/pkg/tutk/helpers.go @@ -60,3 +60,12 @@ func ParseHL(data []byte) (cmdID uint16, payload []byte, ok bool) { } return cmdID, payload, true } + +func FindHL(data []byte, offset int) []byte { + for i := offset; i+16 <= len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + return data[i:] + } + } + return nil +} diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 4e04d302..6515c49b 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -211,7 +211,7 @@ func (c *Client) StartIntercom() error { k10010 := c.buildK10010(MediaTypeReturnAudio, true) if _, err := c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second); err != nil { - return err + return fmt.Errorf("enable return audio: %w", err) } return c.conn.AVServStart() @@ -223,7 +223,7 @@ func (c *Client) StopIntercom() error { } k10010 := c.buildK10010(MediaTypeReturnAudio, false) - c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second) + c.conn.WriteIOCtrl(k10010) return c.conn.AVServStop() } From 9365fef7b36e9fabcf5e566cc9d27a646acd2689 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 22:44:09 +0100 Subject: [PATCH 230/241] move HL extraction to wyze client --- pkg/tutk/conn_dtls.go | 65 +++++++++++-------------------------------- pkg/wyze/client.go | 47 +++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 58 deletions(-) diff --git a/pkg/tutk/conn_dtls.go b/pkg/tutk/conn_dtls.go index 294990c2..61e716ea 100644 --- a/pkg/tutk/conn_dtls.go +++ b/pkg/tutk/conn_dtls.go @@ -176,7 +176,7 @@ func (c *DTLSConn) AVClientStart(timeout time.Duration) error { return fmt.Errorf("av login 1 failed: %w", err) } - time.Sleep(50 * time.Millisecond) + time.Sleep(10 * time.Millisecond) if _, err := c.clientConn.Write(pkt2); err != nil { return fmt.Errorf("av login 2 failed: %w", err) @@ -239,7 +239,7 @@ func (c *DTLSConn) AVServStart() error { // Wait for AV Login request from camera buf := make([]byte, 1024) - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) n, err := conn.Read(buf) if err != nil { go conn.Close() @@ -267,6 +267,10 @@ func (c *DTLSConn) AVServStart() error { return fmt.Errorf("write av login response: %w", err) } + if c.verbose { + fmt.Printf("[SERVER] AV Login response sent, waiting for possible resend...\n") + } + // Camera may resend, respond again conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) if n, _ = conn.Read(buf); n > 0 { @@ -377,7 +381,7 @@ func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, e }) defer t.Stop() - _ = c.conn.SetDeadline(time.Now().Add(5000 * time.Millisecond)) + _ = c.conn.SetDeadline(time.Now().Add(5 * time.Second)) defer c.conn.SetDeadline(time.Time{}) buf := make([]byte, 2048) @@ -404,7 +408,7 @@ func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, e } } -func (c *DTLSConn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, timeout time.Duration) ([]byte, error) { +func (c *DTLSConn) WriteAndWaitIOCtrl(payload []byte, match func([]byte) bool, timeout time.Duration) ([]byte, error) { frame := c.msgIOCtrl(payload) var t *time.Timer t = time.AfterFunc(1, func() { @@ -432,19 +436,11 @@ func (c *DTLSConn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint ack := c.msgACK() c.clientConn.Write(ack) - if gotCmd, payload, ok := ParseHL(data); ok { - if c.verbose { - fmt.Printf("[DTLS RX] Got rawCmd K%d, expecting K%d, payload=%d bytes\n", gotCmd, expectCmd, len(payload)) - if gotCmd != expectCmd && len(payload) > 0 { - fmt.Printf("[DTLS RX] K%d payload:\n%s", gotCmd, hexDump(payload)) - } - } - if gotCmd == expectCmd { - return data, nil - } + if match(data) { + return data, nil } case <-timer.C: - return nil, fmt.Errorf("timeout waiting for K%d", expectCmd) + return nil, fmt.Errorf("timeout waiting for response") } } } @@ -507,7 +503,7 @@ func (c *DTLSConn) discovery() error { pktCC51 := c.msgDiscoCC51(0, 0, false) buf := make([]byte, 2048) - deadline := time.Now().Add(5000 * time.Millisecond) + deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { c.conn.WriteToUDP(pktIOTC, c.addr) @@ -618,50 +614,21 @@ func (c *DTLSConn) worker() { case magicAVLoginResp: c.queue(c.rawCmd, data) - case magicIOCtrl: - if hlData := FindHL(data, 32); hlData != nil { - if c.verbose { - if cmd, _, ok := ParseHL(hlData); ok { - fmt.Printf("[DTLS RX] IOCtrl HL command K%d\n", cmd) - } - } - c.queue(c.rawCmd, hlData) - } - - case magicChannelMsg: - if len(data) >= 36 && data[16] == 0x00 { - if hlData := FindHL(data, 36); hlData != nil { - if c.verbose { - if cmd, _, ok := ParseHL(hlData); ok { - fmt.Printf("[DTLS RX] ChannelMsg HL command K%d\n", cmd) - } - } - c.queue(c.rawCmd, hlData) - } - } + case magicIOCtrl, magicChannelMsg: + c.queue(c.rawCmd, data) case protoVersion: + // Seq-Tracking if len(data) >= 8 { - // Extract seq number at byte 4-5 (uint16 of uint32 AVSeq) seq := binary.LittleEndian.Uint16(data[4:]) if !c.rxSeqInit { c.rxSeqInit = true } - // Track highest received sequence if seq > c.rxSeqEnd || c.rxSeqEnd == 0xffff { c.rxSeqEnd = seq } - - // Check for HL command response - if hlData := FindHL(data, 32); hlData != nil { - if c.verbose { - if cmd, _, ok := ParseHL(hlData); ok { - fmt.Printf("[DTLS RX] ProtoVersion HL command K%d\n", cmd) - } - } - c.queue(c.rawCmd, hlData) - } } + c.queue(c.rawCmd, data) case magicACK: c.mu.RLock() diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 6515c49b..46c996e0 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -179,24 +179,24 @@ func (c *Client) SetResolution(quality byte) error { // Use K10052 (doorbell format) for certain models if c.useDoorbellResolution() { k10052 := c.buildK10052(frameSize, bitrate) - _, err := c.conn.WriteAndWaitIOCtrl(KCmdSetResolutionDB, k10052, KCmdSetResolutionDBRes, 5*time.Second) + _, err := c.conn.WriteAndWaitIOCtrl(k10052, c.matchHL(KCmdSetResolutionDBRes), 5*time.Second) return err } k10056 := c.buildK10056(frameSize, bitrate) - _, err := c.conn.WriteAndWaitIOCtrl(KCmdSetResolution, k10056, KCmdSetResolutionResp, 5*time.Second) + _, err := c.conn.WriteAndWaitIOCtrl(k10056, c.matchHL(KCmdSetResolutionResp), 5*time.Second) return err } func (c *Client) StartVideo() error { k10010 := c.buildK10010(MediaTypeVideo, true) - _, err := c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second) + _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) return err } func (c *Client) StartAudio() error { k10010 := c.buildK10010(MediaTypeAudio, true) - _, err := c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second) + _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) return err } @@ -210,10 +210,14 @@ func (c *Client) StartIntercom() error { } k10010 := c.buildK10010(MediaTypeReturnAudio, true) - if _, err := c.conn.WriteAndWaitIOCtrl(KCmdControlChannel, k10010, KCmdControlChannelResp, 5*time.Second); err != nil { + if _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second); err != nil { return fmt.Errorf("enable return audio: %w", err) } + if c.verbose { + fmt.Printf("[Wyze] Speaker channel enabled, waiting for readiness...\n") + } + return c.conn.AVServStart() } @@ -329,12 +333,13 @@ func (c *Client) doAVLogin() error { func (c *Client) doKAuth() error { // Step 1: K10000 -> K10001 (Challenge) - data, err := c.conn.WriteAndWaitIOCtrl(KCmdAuth, c.buildK10000(), KCmdChallenge, 10*time.Second) + data, err := c.conn.WriteAndWaitIOCtrl(c.buildK10000(), c.matchHL(KCmdChallenge), 5*time.Second) if err != nil { return fmt.Errorf("wyze: K10001 failed: %w", err) } - challenge, status, err := c.parseK10001(data) + hlData := c.extractHL(data) + challenge, status, err := c.parseK10001(hlData) if err != nil { return fmt.Errorf("wyze: K10001 parse failed: %w", err) } @@ -344,13 +349,14 @@ func (c *Client) doKAuth() error { } // Step 2: K10002 -> K10003 (Auth) - data, err = c.conn.WriteAndWaitIOCtrl(KCmdChallengeResp, c.buildK10002(challenge, status), KCmdAuthResult, 10*time.Second) + data, err = c.conn.WriteAndWaitIOCtrl(c.buildK10002(challenge, status), c.matchHL(KCmdAuthResult), 5*time.Second) if err != nil { return fmt.Errorf("wyze: K10002 failed: %w", err) } + hlData = c.extractHL(data) // Parse K10003 response - authResp, err := c.parseK10003(data) + authResp, err := c.parseK10003(hlData) if err != nil { return fmt.Errorf("wyze: K10003 parse failed: %w", err) } @@ -548,6 +554,29 @@ func (c *Client) isFloodlight() bool { return c.model == "HL_CFL2" } +func (c *Client) matchHL(expectCmd uint16) func([]byte) bool { + return func(data []byte) bool { + hlData := c.extractHL(data) + if hlData == nil { + return false + } + cmd, _, ok := tutk.ParseHL(hlData) + return ok && cmd == expectCmd + } +} + +func (c *Client) extractHL(data []byte) []byte { + // Try offset 32 (magicIOCtrl, protoVersion) + if hlData := tutk.FindHL(data, 32); hlData != nil { + return hlData + } + // Try offset 36 (magicChannelMsg) + if len(data) >= 36 && data[16] == 0x00 { + return tutk.FindHL(data, 36) + } + return nil +} + const ( statusDefault byte = 1 statusENR16 byte = 3 From d40f6064d9dfffe026205bb54511adc9785132f7 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 23:28:03 +0100 Subject: [PATCH 231/241] refactor dtls --- pkg/tutk/conn_dtls.go | 10 +++--- pkg/tutk/dtls.go | 71 +++++++++++++++++++++++++++---------------- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/pkg/tutk/conn_dtls.go b/pkg/tutk/conn_dtls.go index 61e716ea..bdeb4dbd 100644 --- a/pkg/tutk/conn_dtls.go +++ b/pkg/tutk/conn_dtls.go @@ -226,8 +226,7 @@ func (c *DTLSConn) AVClientStart(timeout time.Duration) error { } func (c *DTLSConn) AVServStart() error { - adapter := NewChannelAdapter(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf) - conn, err := NewDTLSServer(adapter, c.addr, c.psk) + conn, err := NewDTLSServer(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf, c.psk) if err != nil { return fmt.Errorf("dtls: server handshake failed: %w", err) } @@ -564,10 +563,9 @@ func (c *DTLSConn) discoDoneCC51() error { } func (c *DTLSConn) connect() error { - adapter := NewChannelAdapter(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf) - conn, err := NewDTLSClient(adapter, c.addr, c.psk) + conn, err := NewDTLSClient(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf, c.psk) if err != nil { - return fmt.Errorf("dtls: client create failed: %w", err) + return fmt.Errorf("dtls: client handshake failed: %w", err) } c.mu.Lock() @@ -575,7 +573,7 @@ func (c *DTLSConn) connect() error { c.mu.Unlock() if c.verbose { - fmt.Printf("[DTLS] Client created for channel %d\n", iotcChannelMain) + fmt.Printf("[DTLS] Client handshake complete on channel %d\n", iotcChannelMain) } return nil diff --git a/pkg/tutk/dtls.go b/pkg/tutk/dtls.go index e807e96f..9088a664 100644 --- a/pkg/tutk/dtls.go +++ b/pkg/tutk/dtls.go @@ -9,18 +9,47 @@ import ( "github.com/pion/dtls/v3" ) -type DTLSConfig struct { - PSK []byte - Identity string - IsServer bool +func NewDTLSClient(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, false) } -func NewDTLSClient(adapter net.PacketConn, addr net.Addr, psk []byte) (*dtls.Conn, error) { - return dtls.Client(adapter, addr, buildDTLSConfig(psk, false)) +func NewDTLSServer(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, true) } -func NewDTLSServer(adapter net.PacketConn, addr net.Addr, psk []byte) (*dtls.Conn, error) { - return dtls.Server(adapter, addr, buildDTLSConfig(psk, true)) +func dialDTLS(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte, isServer bool) (*dtls.Conn, error) { + adapter := &channelAdapter{ + ctx: ctx, + channel: channel, + addr: addr, + writeFn: writeFn, + readChan: readChan, + } + + var conn *dtls.Conn + var err error + + if isServer { + conn, err = dtls.Server(adapter, addr, buildDTLSConfig(psk, true)) + } else { + conn, err = dtls.Client(adapter, addr, buildDTLSConfig(psk, false)) + } + if err != nil { + return nil, err + } + + timeout := 5 * time.Second + adapter.SetReadDeadline(time.Now().Add(timeout)) + hsCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := conn.HandshakeContext(hsCtx); err != nil { + go conn.Close() + return nil, err + } + + adapter.SetReadDeadline(time.Time{}) + return conn, nil } func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config { @@ -45,7 +74,7 @@ func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config { return config } -type ChannelAdapter struct { +type channelAdapter struct { ctx context.Context channel uint8 writeFn func([]byte, uint8) error @@ -55,17 +84,7 @@ type ChannelAdapter struct { readDeadline time.Time } -func NewChannelAdapter(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte) *ChannelAdapter { - return &ChannelAdapter{ - ctx: ctx, - channel: channel, - addr: addr, - writeFn: writeFn, - readChan: readChan, - } -} - -func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { +func (a *channelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { a.mu.Lock() deadline := a.readDeadline a.mu.Unlock() @@ -97,28 +116,28 @@ func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { } } -func (a *ChannelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { +func (a *channelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { if err := a.writeFn(p, a.channel); err != nil { return 0, err } return len(p), nil } -func (a *ChannelAdapter) Close() error { return nil } -func (a *ChannelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } -func (a *ChannelAdapter) SetDeadline(t time.Time) error { +func (a *channelAdapter) Close() error { return nil } +func (a *channelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } +func (a *channelAdapter) SetDeadline(t time.Time) error { a.mu.Lock() a.readDeadline = t a.mu.Unlock() return nil } -func (a *ChannelAdapter) SetReadDeadline(t time.Time) error { +func (a *channelAdapter) SetReadDeadline(t time.Time) error { a.mu.Lock() a.readDeadline = t a.mu.Unlock() return nil } -func (a *ChannelAdapter) SetWriteDeadline(time.Time) error { return nil } +func (a *channelAdapter) SetWriteDeadline(time.Time) error { return nil } type timeoutError struct{} From 66225973ae469033ad58c8f3add481b7521418e3 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 18 Jan 2026 07:54:48 +0300 Subject: [PATCH 232/241] Code refactoring for onvif device discovery #1991 --- internal/onvif/onvif.go | 12 +++++------ pkg/onvif/helpers.go | 45 +++++++++++++++++------------------------ 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/internal/onvif/onvif.go b/internal/onvif/onvif.go index 6d9e2262..38ba6581 100644 --- a/internal/onvif/onvif.go +++ b/internal/onvif/onvif.go @@ -7,7 +7,6 @@ import ( "net/url" "os" "strconv" - "strings" "time" "github.com/AlexxIT/go2rtc/internal/api" @@ -186,12 +185,11 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) { u.Path = "" } - var info string - info = strings.TrimSpace(device.Name + " " + device.Hardware) - if info == "" { - info = "ONVIF Device" - } - items = append(items, &api.Source{Name: u.Host, URL: u.String(), Info: info}) + items = append(items, &api.Source{ + Name: u.Host, + URL: u.String(), + Info: device.Name + " " + device.Hardware, + }) } } else { client, err := onvif.NewClient(src) diff --git a/pkg/onvif/helpers.go b/pkg/onvif/helpers.go index d4fa92f6..8fac9ac4 100644 --- a/pkg/onvif/helpers.go +++ b/pkg/onvif/helpers.go @@ -33,7 +33,7 @@ func UUID() string { return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] } -// return list of tuple (onvif_url, name, hardware) +// DiscoveryStreamingDevices return list of tuple (onvif_url, name, hardware) func DiscoveryStreamingDevices() ([]DiscoveryDevice, error) { conn, err := net.ListenUDP("udp4", nil) if err != nil { @@ -68,9 +68,7 @@ func DiscoveryStreamingDevices() ([]DiscoveryDevice, error) { return nil, err } - if err = conn.SetReadDeadline(time.Now().Add(time.Second * 3)); err != nil { - return nil, err - } + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) var devices []DiscoveryDevice @@ -88,42 +86,37 @@ func DiscoveryStreamingDevices() ([]DiscoveryDevice, error) { continue } - url := FindTagValue(b[:n], "XAddrs") - if url == "" { + device := DiscoveryDevice{ + URL: FindTagValue(b[:n], "XAddrs"), + } + + if device.URL == "" { continue } // fix some buggy cameras // http://0.0.0.0:8080/onvif/device_service - if strings.HasPrefix(url, "http://0.0.0.0") { - url = "http://" + addr.IP.String() + url[14:] + if s, ok := strings.CutPrefix(device.URL, "http://0.0.0.0"); ok { + device.URL = "http://" + addr.IP.String() + s } // try to find the camera name and model (hardware) scopes := FindTagValue(b[:n], "Scopes") - var name, hardware string - for _, scope := range strings.Split(scopes, " ") { - if strings.HasPrefix(scope, "onvif://www.onvif.org/name/") { - name = strings.TrimPrefix(scope, "onvif://www.onvif.org/name/") - } else if strings.HasPrefix(scope, "onvif://www.onvif.org/hardware/") { - hardware = strings.TrimPrefix(scope, "onvif://www.onvif.org/hardware/") - } - } - // Some cameras has the hardware copied into the name field or vice versa - // this is to avoid duplication - if name != "" && hardware != "" { - if strings.Contains(hardware, name) { - name = "" - } else if strings.Contains(name, hardware) { - hardware = "" - } - } - devices = append(devices, DiscoveryDevice{URL: url, Name: name, Hardware: hardware}) + device.Name = findScope(scopes, "onvif://www.onvif.org/name/") + device.Hardware = findScope(scopes, "onvif://www.onvif.org/hardware/") + + devices = append(devices, device) } return devices, nil } +func findScope(s, prefix string) string { + s = core.Between(s, prefix, " ") + s, _ = url.QueryUnescape(s) + return s +} + func atoi(s string) int { if s == "" { return 0 From 514188201a4b4ab4c847d2c13efe6c1321d0a6b8 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 18 Jan 2026 08:38:38 +0300 Subject: [PATCH 233/241] Move tutk dtls to separate package #2011 --- README.md | 6 +++-- pkg/tutk/codec.go | 9 +++++++ pkg/tutk/{ => dtls}/auth.go | 2 +- pkg/tutk/{ => dtls}/cipher.go | 2 +- pkg/tutk/{ => dtls}/conn_dtls.go | 43 ++++++++++++++------------------ pkg/tutk/{ => dtls}/dtls.go | 2 +- pkg/wyze/client.go | 7 +++--- 7 files changed, 39 insertions(+), 32 deletions(-) rename pkg/tutk/{ => dtls}/auth.go (98%) rename pkg/tutk/{ => dtls}/cipher.go (99%) rename pkg/tutk/{ => dtls}/conn_dtls.go (96%) rename pkg/tutk/{ => dtls}/dtls.go (99%) diff --git a/README.md b/README.md index 5d484218..c70e65bd 100644 --- a/README.md +++ b/README.md @@ -637,11 +637,13 @@ Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-li This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. +*[read more](internal/xiaomi/README.md)* + ## Source: Wyze -This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol - no docker-wyze-bridge required. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/wyze/README.md). +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol - no `docker-wyze-bridge` required. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. -*[read more](internal/xiaomi/README.md)* +*[read more](internal/wyze/README.md)* ## Source: GoPro diff --git a/pkg/tutk/codec.go b/pkg/tutk/codec.go index 68ca72ca..9ec7d8cb 100644 --- a/pkg/tutk/codec.go +++ b/pkg/tutk/codec.go @@ -26,6 +26,15 @@ const ( var sampleRates = [9]uint32{8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000} +func GetSampleRateIndex(sampleRate uint32) uint8 { + for i, rate := range sampleRates { + if rate == sampleRate { + return uint8(i) + } + } + return 3 // default 16kHz +} + func GetSamplesPerFrame(codecID byte) uint32 { switch codecID { case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: diff --git a/pkg/tutk/auth.go b/pkg/tutk/dtls/auth.go similarity index 98% rename from pkg/tutk/auth.go rename to pkg/tutk/dtls/auth.go index 8dca29aa..7354428d 100644 --- a/pkg/tutk/auth.go +++ b/pkg/tutk/dtls/auth.go @@ -1,4 +1,4 @@ -package tutk +package dtls import ( "crypto/sha256" diff --git a/pkg/tutk/cipher.go b/pkg/tutk/dtls/cipher.go similarity index 99% rename from pkg/tutk/cipher.go rename to pkg/tutk/dtls/cipher.go index 0a238fa3..e987ff8e 100644 --- a/pkg/tutk/cipher.go +++ b/pkg/tutk/dtls/cipher.go @@ -1,4 +1,4 @@ -package tutk +package dtls import ( "crypto/cipher" diff --git a/pkg/tutk/conn_dtls.go b/pkg/tutk/dtls/conn_dtls.go similarity index 96% rename from pkg/tutk/conn_dtls.go rename to pkg/tutk/dtls/conn_dtls.go index bdeb4dbd..c1d5f6ce 100644 --- a/pkg/tutk/conn_dtls.go +++ b/pkg/tutk/dtls/conn_dtls.go @@ -1,4 +1,4 @@ -package tutk +package dtls import ( "context" @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/AlexxIT/go2rtc/pkg/tutk" "github.com/pion/dtls/v3" ) @@ -70,7 +71,7 @@ const ( type DTLSConn struct { conn *net.UDPConn addr *net.UDPAddr - frames *FrameHandler + frames *tutk.FrameHandler err error verbose bool ctx context.Context @@ -150,7 +151,7 @@ func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*D c.clientBuf = make(chan []byte, 64) c.serverBuf = make(chan []byte, 64) c.rawCmd = make(chan []byte, 16) - c.frames = NewFrameHandler(c.verbose) + c.frames = tutk.NewFrameHandler(c.verbose) c.wg.Add(1) go c.reader() @@ -167,7 +168,7 @@ func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*D } func (c *DTLSConn) AVClientStart(timeout time.Duration) error { - randomID := GenSessionID() + randomID := tutk.GenSessionID() pkt1 := c.msgAVLogin(magicAVLogin1, 570, 0x0001, randomID) pkt2 := c.msgAVLogin(magicAVLogin2, 572, 0x0000, randomID) pkt2[20]++ // pkt2 has randomID incremented by 1 @@ -311,7 +312,7 @@ func (c *DTLSConn) AVServStop() error { return nil } -func (c *DTLSConn) AVRecvFrameData() (*Packet, error) { +func (c *DTLSConn) AVRecvFrameData() (*tutk.Packet, error) { select { case pkt, ok := <-c.frames.Recv(): if !ok { @@ -351,7 +352,7 @@ func (c *DTLSConn) Write(data []byte) error { _, err := c.conn.WriteToUDP(data, c.addr) return err } - _, err := c.conn.WriteToUDP(TransCodeBlob(data), c.addr) + _, err := c.conn.WriteToUDP(tutk.TransCodeBlob(data), c.addr) return err } @@ -397,7 +398,7 @@ func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, e if c.isCC51 { res = buf[:n] } else { - res = ReverseTransCodeBlob(buf[:n]) + res = tutk.ReverseTransCodeBlob(buf[:n]) } if ok(res) { @@ -496,9 +497,9 @@ func (c *DTLSConn) Error() error { } func (c *DTLSConn) discovery() error { - c.sid = GenSessionID() + c.sid = tutk.GenSessionID() - pktIOTC := TransCodeBlob(c.msgDisco(1)) + pktIOTC := tutk.TransCodeBlob(c.msgDisco(1)) pktCC51 := c.msgDiscoCC51(0, 0, false) buf := make([]byte, 2048) @@ -530,7 +531,7 @@ func (c *DTLSConn) discovery() error { } // IOTC Protocol (Basis) - data := ReverseTransCodeBlob(buf[:n]) + data := tutk.ReverseTransCodeBlob(buf[:n]) if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == cmdDiscoRes { c.addr, c.isCC51 = addr, false return c.discoDone() @@ -638,7 +639,7 @@ func (c *DTLSConn) worker() { default: channel := data[0] - if channel == ChannelAudio || channel == ChannelIVideo || channel == ChannelPVideo { + if channel == tutk.ChannelAudio || channel == tutk.ChannelIVideo || channel == tutk.ChannelPVideo { c.frames.Handle(data) } } @@ -700,7 +701,7 @@ func (c *DTLSConn) reader() { } // IOTC Protocol (Basis) - data := ReverseTransCodeBlob(buf[:n]) + data := tutk.ReverseTransCodeBlob(buf[:n]) if len(data) < 16 { continue } @@ -843,8 +844,8 @@ func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, b := make([]byte, 36+totalPayload) // Outer header (36 bytes) - b[0] = ChannelAudio // 0x03 - b[1] = FrameTypeStartAlt // 0x09 + b[0] = tutk.ChannelAudio // 0x03 + b[1] = tutk.FrameTypeStartAlt // 0x09 binary.LittleEndian.PutUint16(b[2:], protoVersion) binary.LittleEndian.PutUint32(b[4:], c.audioSeq) binary.LittleEndian.PutUint32(b[8:], timestampUS) @@ -855,8 +856,8 @@ func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, } // Inner header - b[16] = ChannelAudio - b[17] = FrameTypeEndSingle + b[16] = tutk.ChannelAudio + b[17] = tutk.FrameTypeEndSingle binary.LittleEndian.PutUint16(b[18:], uint16(prevFrame)) binary.LittleEndian.PutUint16(b[20:], 0x0001) // pkt_total binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags @@ -868,19 +869,13 @@ func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, fi[0] = codec // Codec ID (low byte) fi[1] = 0 // Codec ID (high byte, unused) // Audio flags: [3:2]=sampleRateIdx [1]=16bit [0]=stereo - var srIdx uint8 = 3 // default 16kHz - for i, rate := range sampleRates { - if rate == sampleRate { - srIdx = uint8(i) - break - } - } + srIdx := tutk.GetSampleRateIndex(sampleRate) fi[2] = (srIdx << 2) | 0x02 // 16-bit always set if channels == 2 { fi[2] |= 0x01 } fi[4] = 1 // online - binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*GetSamplesPerFrame(codec)*1000/sampleRate) + binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*tutk.GetSamplesPerFrame(codec)*1000/sampleRate) return b } diff --git a/pkg/tutk/dtls.go b/pkg/tutk/dtls/dtls.go similarity index 99% rename from pkg/tutk/dtls.go rename to pkg/tutk/dtls/dtls.go index 9088a664..3b0573ae 100644 --- a/pkg/tutk/dtls.go +++ b/pkg/tutk/dtls/dtls.go @@ -1,4 +1,4 @@ -package tutk +package dtls import ( "context" diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 46c996e0..0fe878ee 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -13,6 +13,7 @@ import ( "time" "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk/dtls" ) const ( @@ -49,7 +50,7 @@ const ( ) type Client struct { - conn *tutk.DTLSConn + conn *dtls.DTLSConn host string uid string @@ -97,7 +98,7 @@ func Dial(rawURL string) (*Client, error) { verbose: query.Get("verbose") == "true", } - c.authKey = string(tutk.CalculateAuthKey(c.enr, c.mac)) + c.authKey = string(dtls.CalculateAuthKey(c.enr, c.mac)) if c.verbose { fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid) @@ -303,7 +304,7 @@ func (c *Client) connect() error { host = host[:idx] } - conn, err := tutk.DialDTLS(host, port, c.uid, c.authKey, c.enr, c.verbose) + conn, err := dtls.DialDTLS(host, port, c.uid, c.authKey, c.enr, c.verbose) if err != nil { return fmt.Errorf("wyze: connect failed: %w", err) } From 38cc05c22d82a61f1f4e52328625acbc6d54f256 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sun, 18 Jan 2026 14:23:50 +0300 Subject: [PATCH 234/241] feat(jpeg): Add keyframe caching with expiration mechanism to JPEG http handler (#1155) * feat(mjpeg): add keyframe caching with expiration and cleanup goroutine * mjpeg: make keyframe cache duration and default usage configurable * mjpeg: document and add config options for MJPEG snapshot caching * mjpeg: fix errors after rebase * Code refactoring for frame.jpeg cache #1155 --------- Co-authored-by: Alex X --- README.md | 32 ++++---------------------- internal/mjpeg/README.md | 42 ++++++++++++++++++++++++++++++++++ internal/mjpeg/init.go | 49 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index c70e65bd..a34b5a5a 100644 --- a/README.md +++ b/README.md @@ -1236,35 +1236,11 @@ Read more about [codecs filters](#codecs-filters). ## Module: MJPEG -**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. +- This module can provide and receive streams in MJPEG format. +- This module is also responsible for receiving snapshots in JPEG format. +- This module also supports streaming to the server console (terminal) in the **animated ASCII art** format. -You can receive an MJPEG stream in several ways: - -- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) -- some cameras have an HTTP link with [MJPEG stream](#source-http) -- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) -- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) - -With this example, your stream will have both H264 and MJPEG codecs: - -```yaml -streams: - camera1: - - rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 - - ffmpeg:camera1#video=mjpeg -``` - -API examples: - -- MJPEG stream: `http://192.168.1.123:1984/api/stream.mjpeg?src=camera1` -- JPEG snapshots: `http://192.168.1.123:1984/api/frame.jpeg?src=camera1` - - You can use `width`/`w` and/or `height`/`h` params - - You can use `rotate` param with `90`, `180`, `270` or `-90` values - - You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) - -**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](internal/mjpeg/README.md)). - -[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) +*[read more](internal/mjpeg/README.md)* ## Module: Log diff --git a/internal/mjpeg/README.md b/internal/mjpeg/README.md index a09e59c4..1d701de1 100644 --- a/internal/mjpeg/README.md +++ b/internal/mjpeg/README.md @@ -1,3 +1,45 @@ +# MJPEG + +**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. + +You can receive an MJPEG stream in several ways: + +- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) +- some cameras have an HTTP link with [MJPEG stream](#source-http) +- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) +- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) + +With this example, your stream will have both H264 and MJPEG codecs: + +```yaml +streams: + camera1: + - rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 + - ffmpeg:camera1#video=mjpeg +``` + +## API examples + +**MJPEG stream** + +``` +http://192.168.1.123:1984/api/stream.mjpeg?src=camera1` +``` + +**JPEG snapshots** + +``` +http://192.168.1.123:1984/api/frame.jpeg?src=camera1 +``` + +- You can use `width`/`w` and/or `height`/`h` params. +- You can use `rotate` param with `90`, `180`, `270` or `-90` values. +- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration). +- You can use `cache` param (`1m`, `10s`, etc.) to get a cached snapshot. + - The snapshot is cached only when requested with the `cache` parameter. + - A cached snapshot will be used if its time is not older than the time specified in the `cache` parameter. + - The `cache` parameter does not check the image sizes from the cache and those specified in the query. + ## Stream as ASCII to Terminal [![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index 2fa9fa32..b2f3e1f4 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/AlexxIT/go2rtc/internal/api" @@ -36,12 +37,41 @@ func Init() { var log zerolog.Logger func handlerKeyframe(w http.ResponseWriter, r *http.Request) { - stream, _ := streams.GetOrPatch(r.URL.Query()) + query := r.URL.Query() + stream, _ := streams.GetOrPatch(query) if stream == nil { http.Error(w, api.StreamNotFound, http.StatusNotFound) return } + var b []byte + + if s := query.Get("cache"); s != "" { + if timeout, err := time.ParseDuration(s); err == nil { + src := query.Get("src") + + cacheMu.Lock() + entry, found := cache[src] + cacheMu.Unlock() + + if found && time.Since(entry.timestamp) < timeout { + writeJPEGResponse(w, entry.payload) + return + } + + defer func() { + entry = cacheEntry{payload: b, timestamp: time.Now()} + cacheMu.Lock() + if cache == nil { + cache = map[string]cacheEntry{src: entry} + } else { + cache[src] = entry + } + cacheMu.Unlock() + }() + } + } + cons := magic.NewKeyframe() cons.WithRequest(r) @@ -52,7 +82,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { once := &core.OnceBuffer{} // init and first frame _, _ = cons.WriteTo(once) - b := once.Buffer() + b = once.Buffer() stream.RemoveConsumer(cons) @@ -60,7 +90,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { case core.CodecH264, core.CodecH265: ts := time.Now() var err error - if b, err = ffmpeg.JPEGWithQuery(b, r.URL.Query()); err != nil { + if b, err = ffmpeg.JPEGWithQuery(b, query); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -69,6 +99,19 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { b = mjpeg.FixJPEG(b) } + writeJPEGResponse(w, b) +} + +var cache map[string]cacheEntry +var cacheMu sync.Mutex + +// cacheEntry represents a cached keyframe with its timestamp +type cacheEntry struct { + payload []byte + timestamp time.Time +} + +func writeJPEGResponse(w http.ResponseWriter, b []byte) { h := w.Header() h.Set("Content-Type", "image/jpeg") h.Set("Content-Length", strconv.Itoa(len(b))) From 54eafe9d0aa9fe31c0b313b8632df762720cfbac Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 18 Jan 2026 15:48:56 +0300 Subject: [PATCH 235/241] Skip snapshot caching in case of error --- internal/mjpeg/{init.go => mjpeg.go} | 3 +++ 1 file changed, 3 insertions(+) rename internal/mjpeg/{init.go => mjpeg.go} (99%) diff --git a/internal/mjpeg/init.go b/internal/mjpeg/mjpeg.go similarity index 99% rename from internal/mjpeg/init.go rename to internal/mjpeg/mjpeg.go index b2f3e1f4..e9f973aa 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/mjpeg.go @@ -60,6 +60,9 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { } defer func() { + if b == nil { + return + } entry = cacheEntry{payload: b, timestamp: time.Now()} cacheMu.Lock() if cache == nil { From 159fb4675c37690855a7623b07b4d819dc6c442a Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 18 Jan 2026 16:07:09 +0300 Subject: [PATCH 236/241] Add starttimeout setting to exec source #1846 --- README.md | 1 + internal/exec/exec.go | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a34b5a5a..c803b7a3 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,7 @@ Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`): - `killsignal` - signal which will be sent to stop the process (numeric form) - `killtimeout` - time in seconds for forced termination with sigkill - `backchannel` - enable backchannel for two-way audio +- `starttimeout` - time in seconds for waiting first byte from RTSP ```yaml streams: diff --git a/internal/exec/exec.go b/internal/exec/exec.go index bf99168f..e428aefb 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -88,6 +88,7 @@ func execHandle(rawURL string) (prod core.Producer, err error) { } if allowPaths != nil && !slices.Contains(allowPaths, cmd.Args[0]) { + _ = cmd.Close() return nil, errors.New("exec: bin not in allow_paths: " + cmd.Args[0]) } @@ -107,10 +108,17 @@ func execHandle(rawURL string) (prod core.Producer, err error) { return pcm.NewBackchannel(cmd, query.Get("audio")) } + var timeout time.Duration + if s := query.Get("starttimeout"); s != "" { + timeout = time.Duration(core.Atoi(s)) * time.Second + } else { + timeout = 30 * time.Second + } + if path == "" { prod, err = handlePipe(rawURL, cmd) } else { - prod, err = handleRTSP(rawURL, cmd, path) + prod, err = handleRTSP(rawURL, cmd, path, timeout) } if err != nil { @@ -159,7 +167,7 @@ func handlePipe(source string, cmd *shell.Command) (core.Producer, error) { return prod, nil } -func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer, error) { +func handleRTSP(source string, cmd *shell.Command, path string, timeout time.Duration) (core.Producer, error) { if log.Trace().Enabled() { cmd.Stdout = os.Stdout } @@ -185,11 +193,11 @@ func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer, return nil, err } - timeout := time.NewTimer(30 * time.Second) - defer timeout.Stop() + timer := time.NewTimer(timeout) + defer timer.Stop() select { - case <-timeout.C: + case <-timer.C: // haven't received data from app in timeout log.Error().Str("source", source).Msg("[exec] timeout") return nil, errors.New("exec: timeout") From af819952e8e9771a196d9829c4479b923165fcc6 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 18 Jan 2026 21:50:26 +0300 Subject: [PATCH 237/241] Improve ONVIF server support #1299 --- internal/onvif/onvif.go | 14 ++- pkg/onvif/envelope.go | 14 +-- pkg/onvif/server.go | 191 ++++++++++++++++++++++------------------ 3 files changed, 115 insertions(+), 104 deletions(-) diff --git a/internal/onvif/onvif.go b/internal/onvif/onvif.go index 65f8599a..c305b706 100644 --- a/internal/onvif/onvif.go +++ b/internal/onvif/onvif.go @@ -74,8 +74,10 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { log.Trace().Msgf("[onvif] server request %s %s:\n%s", r.Method, r.RequestURI, b) switch operation { - case onvif.DeviceGetNetworkInterfaces, // important for Hass + case onvif.ServiceGetServiceCapabilities, // important for Hass + onvif.DeviceGetNetworkInterfaces, // important for Hass onvif.DeviceGetSystemDateAndTime, // important for Hass + onvif.DeviceSetSystemDateAndTime, // return just OK onvif.DeviceGetDiscoveryMode, onvif.DeviceGetDNS, onvif.DeviceGetHostname, @@ -83,8 +85,10 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { onvif.DeviceGetNetworkProtocols, onvif.DeviceGetNTP, onvif.DeviceGetScopes, + onvif.MediaGetVideoEncoderConfiguration, onvif.MediaGetVideoEncoderConfigurations, onvif.MediaGetAudioEncoderConfigurations, + onvif.MediaGetVideoEncoderConfigurationOptions, onvif.MediaGetAudioSources, onvif.MediaGetAudioSourceConfigurations: b = onvif.StaticResponse(operation) @@ -100,11 +104,6 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { // important for Hass: SerialNumber (unique server ID) b = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host) - case onvif.ServiceGetServiceCapabilities: - // important for Hass - // TODO: check path links to media - b = onvif.GetMediaServiceCapabilitiesResponse() - case onvif.DeviceSystemReboot: b = onvif.StaticResponse(operation) @@ -134,8 +133,7 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { case onvif.MediaGetStreamUri: host, _, err := net.SplitHostPort(r.Host) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + host = r.Host // in case of Host without port } uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken") diff --git a/pkg/onvif/envelope.go b/pkg/onvif/envelope.go index f0e1b29c..76a41260 100644 --- a/pkg/onvif/envelope.go +++ b/pkg/onvif/envelope.go @@ -15,14 +15,9 @@ type Envelope struct { } const ( - prefix1 = ` - -` - prefix2 = ` -` - suffix = ` - -` + prefix1 = `` + prefix2 = `` + suffix = `` ) func NewEnvelope() *Envelope { @@ -54,8 +49,7 @@ func NewEnvelopeWithUser(user *url.Userinfo) *Envelope { %s - -`, +`, user.Username(), base64.StdEncoding.EncodeToString(h.Sum(nil)), base64.StdEncoding.EncodeToString([]byte(nonce)), diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go index 54272798..94e42fc7 100644 --- a/pkg/onvif/server.go +++ b/pkg/onvif/server.go @@ -21,21 +21,24 @@ const ( DeviceGetScopes = "GetScopes" DeviceGetServices = "GetServices" DeviceGetSystemDateAndTime = "GetSystemDateAndTime" + DeviceSetSystemDateAndTime = "SetSystemDateAndTime" DeviceSystemReboot = "SystemReboot" ) const ( - MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" - MediaGetAudioSources = "GetAudioSources" - MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations" - MediaGetProfile = "GetProfile" - MediaGetProfiles = "GetProfiles" - MediaGetSnapshotUri = "GetSnapshotUri" - MediaGetStreamUri = "GetStreamUri" - MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" - MediaGetVideoSources = "GetVideoSources" - MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration" - MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations" + MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" + MediaGetAudioSources = "GetAudioSources" + MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations" + MediaGetProfile = "GetProfile" + MediaGetProfiles = "GetProfiles" + MediaGetSnapshotUri = "GetSnapshotUri" + MediaGetStreamUri = "GetStreamUri" + MediaGetVideoEncoderConfiguration = "GetVideoEncoderConfiguration" + MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" + MediaGetVideoEncoderConfigurationOptions = "GetVideoEncoderConfigurationOptions" + MediaGetVideoSources = "GetVideoSources" + MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration" + MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations" ) func GetRequestAction(b []byte) string { @@ -54,13 +57,13 @@ func GetRequestAction(b []byte) string { func GetCapabilitiesResponse(host string) []byte { e := NewEnvelope() - e.Append(` + e.Appendf(` - http://`, host, `/onvif/device_service + http://%s/onvif/device_service - http://`, host, `/onvif/media_service + http://%s/onvif/media_service false false @@ -68,24 +71,24 @@ func GetCapabilitiesResponse(host string) []byte { -`) +`, host, host) return e.Bytes() } func GetServicesResponse(host string) []byte { e := NewEnvelope() - e.Append(` + e.Appendf(` http://www.onvif.org/ver10/device/wsdl - http://`, host, `/onvif/device_service + http://%s/onvif/device_service 25 http://www.onvif.org/ver10/media/wsdl - http://`, host, `/onvif/media_service + http://%s/onvif/media_service 25 -`) +`, host, host) return e.Bytes() } @@ -120,30 +123,19 @@ func GetSystemDateAndTimeResponse() []byte { func GetDeviceInformationResponse(manuf, model, firmware, serial string) []byte { e := NewEnvelope() - e.Append(` - `, manuf, ` - `, model, ` - `, firmware, ` - `, serial, ` + e.Appendf(` + %s + %s + %s + %s 1.00 -`) - return e.Bytes() -} - -func GetMediaServiceCapabilitiesResponse() []byte { - e := NewEnvelope() - e.Append(` - - - -`) +`, manuf, model, firmware, serial) return e.Bytes() } func GetProfilesResponse(names []string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) for _, name := range names { appendProfile(e, "Profiles", name) } @@ -153,38 +145,40 @@ func GetProfilesResponse(names []string) []byte { func GetProfileResponse(name string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) appendProfile(e, "Profile", name) e.Append(``) return e.Bytes() } func appendProfile(e *Envelope, tag, name string) { - // empty `RateControl` important for UniFi Protect - e.Append(` - `, name, ` - - VSC - `, name, ` - - - - VEC - H264 - 19201080 - - - -`) + // go2rtc name = ONVIF Profile Name = ONVIF Profile token + e.Appendf(``, tag, name) + e.Appendf(`%s`, name) + appendVideoSourceConfiguration(e, "VideoSourceConfiguration", name) + appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration") + e.Appendf(``, tag) +} + +func GetVideoSourcesResponse(names []string) []byte { + // go2rtc name = ONVIF VideoSource token + e := NewEnvelope() + e.Append(``) + for _, name := range names { + e.Appendf(` + 30.000000 + 19201080 +`, name) + } + e.Append(``) + return e.Bytes() } func GetVideoSourceConfigurationsResponse(names []string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) for _, name := range names { - appendProfile(e, "Configurations", name) + appendVideoSourceConfiguration(e, "Configurations", name) } e.Append(``) return e.Bytes() @@ -192,46 +186,56 @@ func GetVideoSourceConfigurationsResponse(names []string) []byte { func GetVideoSourceConfigurationResponse(name string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) appendVideoSourceConfiguration(e, "Configuration", name) e.Append(``) return e.Bytes() } func appendVideoSourceConfiguration(e *Envelope, tag, name string) { - e.Append(` + // go2rtc name = ONVIF VideoSourceConfiguration token + e.Appendf(` VSC - `, name, ` + %s - -`) +`, tag, name, name, tag) } -func GetVideoSourcesResponse(names []string) []byte { +func GetVideoEncoderConfigurationsResponse() []byte { e := NewEnvelope() - e.Append(` -`) - for _, name := range names { - e.Append(` - 30.000000 - 19201080 - -`) - } - e.Append(``) + e.Append(``) + appendVideoEncoderConfiguration(e, "VideoEncoderConfigurations") + e.Append(``) return e.Bytes() } +func GetVideoEncoderConfigurationResponse() []byte { + e := NewEnvelope() + e.Append(``) + appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration") + e.Append(``) + return e.Bytes() +} + +func appendVideoEncoderConfiguration(e *Envelope, tag string) { + // empty `RateControl` important for UniFi Protect + e.Appendf(` + VEC + H264 + 19201080 + + `, tag, tag) +} + func GetStreamUriResponse(uri string) []byte { e := NewEnvelope() - e.Append(``, uri, ``) + e.Appendf(`%s`, uri) return e.Bytes() } func GetSnapshotUriResponse(uri string) []byte { e := NewEnvelope() - e.Append(``, uri, ``) + e.Appendf(`%s`, uri) return e.Bytes() } @@ -239,6 +243,10 @@ func StaticResponse(operation string) []byte { switch operation { case DeviceGetSystemDateAndTime: return GetSystemDateAndTimeResponse() + case MediaGetVideoEncoderConfiguration: + return GetVideoEncoderConfigurationResponse() + case MediaGetVideoEncoderConfigurations: + return GetVideoEncoderConfigurationsResponse() } e := NewEnvelope() @@ -247,11 +255,18 @@ func StaticResponse(operation string) []byte { } var responses = map[string]string{ + ServiceGetServiceCapabilities: ` + + + +`, + DeviceGetDiscoveryMode: `Discoverable`, DeviceGetDNS: ``, DeviceGetHostname: ``, DeviceGetNetworkDefaultGateway: ``, DeviceGetNTP: ``, + DeviceSetSystemDateAndTime: ``, DeviceSystemReboot: `OK`, DeviceGetNetworkInterfaces: ``, @@ -263,16 +278,20 @@ var responses = map[string]string{ Fixedonvif://www.onvif.org/type/Network_Video_Transmitter `, - MediaGetVideoEncoderConfigurations: ` - - VEC - H264 - 19201080 - - -`, - MediaGetAudioEncoderConfigurations: ``, MediaGetAudioSources: ``, MediaGetAudioSourceConfigurations: ``, + + MediaGetVideoEncoderConfigurationOptions: ` + + 16 + + 19201080 + 0100 + 130 + 1100 + Main + + +`, } From fc22b20896b182510debd2783a25f26aa89002c2 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 18 Jan 2026 22:43:12 +0300 Subject: [PATCH 238/241] Fix ONVIF server support for Unifi Protect #1994 --- pkg/onvif/server.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go index 94e42fc7..fe3ba8b3 100644 --- a/pkg/onvif/server.go +++ b/pkg/onvif/server.go @@ -221,9 +221,13 @@ func appendVideoEncoderConfiguration(e *Envelope, tag string) { // empty `RateControl` important for UniFi Protect e.Appendf(` VEC + 1 H264 19201080 - + 0 + 3018192 + 10Main + PT10S `, tag, tag) } From c64fcc55a5e76a7653e136b2839ed028a00c9a7c Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 19 Jan 2026 11:28:09 +0300 Subject: [PATCH 239/241] Update dependencies --- go.mod | 38 ++++++++++++++++++++------------------ go.sum | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 1e649cae..485509e6 100644 --- a/go.mod +++ b/go.mod @@ -5,26 +5,27 @@ go 1.24.0 require ( github.com/asticode/go-astits v1.14.0 github.com/eclipse/paho.mqtt.golang v1.5.1 - github.com/expr-lang/expr v1.17.6 + github.com/expr-lang/expr v1.17.7 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-isatty v0.0.20 - github.com/miekg/dns v1.1.69 - github.com/pion/ice/v4 v4.1.0 - github.com/pion/interceptor v0.1.42 + github.com/miekg/dns v1.1.70 + github.com/pion/dtls/v3 v3.0.10 + github.com/pion/ice/v4 v4.2.0 + github.com/pion/interceptor v0.1.43 github.com/pion/rtcp v1.2.16 - github.com/pion/rtp v1.8.26 - github.com/pion/sdp/v3 v3.0.16 - github.com/pion/srtp/v3 v3.0.9 - github.com/pion/stun/v3 v3.0.2 - github.com/pion/webrtc/v4 v4.1.8 + github.com/pion/rtp v1.10.0 + github.com/pion/sdp/v3 v3.0.17 + github.com/pion/srtp/v3 v3.0.10 + github.com/pion/stun/v3 v3.1.1 + github.com/pion/webrtc/v4 v4.2.3 github.com/rs/zerolog v1.34.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.11.1 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.46.0 - golang.org/x/net v0.48.0 + golang.org/x/crypto v0.47.0 + golang.org/x/net v0.49.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -33,18 +34,19 @@ require ( github.com/davecgh/go-spew v1.1.1 // 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 - github.com/pion/dtls/v3 v3.0.9 // indirect + github.com/pion/datachannel v1.6.0 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/sctp v1.8.41 // indirect + github.com/pion/sctp v1.9.2 // indirect github.com/pion/transport/v3 v3.1.1 // indirect - github.com/pion/turn/v4 v4.1.3 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.31.0 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index d251618d..897bb8a2 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -32,14 +34,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA= +github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM= github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os= +github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= +github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU= github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4= +github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw= +github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4= github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ= github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= +github.com/pion/interceptor v0.1.43 h1:6hmRfnmjogSs300xfkR0JxYFZ9k5blTEvCD7wxEDuNQ= +github.com/pion/interceptor v0.1.43/go.mod h1:BSiC1qKIJt1XVr3l3xQ2GEmCFStk9tx8fwtCZxxgR7M= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= @@ -50,20 +62,36 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc= github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w= +github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs= github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY= +github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= +github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= +github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo= +github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY= github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA= github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk= github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s= +github.com/pion/webrtc/v4 v4.2.3 h1:RtdWDnkenNQGxUrZqWa5gSkTm5ncsLg5d+zu0M4cXt4= +github.com/pion/webrtc/v4 v4.2.3/go.mod h1:7vsyFzRzaKP5IELUnj8zLcglPyIT6wWwqTppBZ1k6Kc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= @@ -88,10 +116,16 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -99,10 +133,17 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From b5948cfb25404cc5cb37b166ecaa2dca20b11d4b Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 19 Jan 2026 11:59:09 +0300 Subject: [PATCH 240/241] Update version to 1.9.14 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index def7ee35..ca70e84d 100644 --- a/main.go +++ b/main.go @@ -52,7 +52,7 @@ import ( func main() { // version will be set later from -buildvcs info, this used only as fallback - app.Version = "1.9.13" + app.Version = "1.9.14" type module struct { name string From a37fdf38d8c23251e7ab6b261c317e5910733cb5 Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 19 Jan 2026 18:17:38 +0300 Subject: [PATCH 241/241] Add support xiaomi chuangmi.camera.v2 on old firmwares --- pkg/xiaomi/legacy/client.go | 63 +++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index 242fda3d..ab17e031 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -33,7 +33,7 @@ func NewClient(rawURL string) (*Client, error) { `{"public_key":"%s","sign":"%s","account":"admin"}`, query.Get("client_public"), query.Get("sign"), ) - } else if model == ModelXiaobai { + } else if model == ModelMijia || model == ModelXiaobai { username = "admin" password = query.Get("password") } else if model == ModelXiaofang { @@ -114,17 +114,49 @@ func (c *Client) ReadPacket() (hdr, payload []byte, err error) { return } +const ( + cmdVideoStart = 0x01ff + cmdVideoStop = 0x02ff + cmdAudioStart = 0x0300 + cmdAudioStop = 0x0301 + cmdStreamCtrlReq = 0x0320 +) + +var empty = []byte(`{}`) + func (c *Client) StartMedia(video, audio string) error { switch c.model { case ModelAqaraG2: - return c.WriteCommand(0x01ff, []byte(`{}`)) + return c.WriteCommand(cmdVideoStart, empty) + + case ModelMijia: + // 0 - auto, 1 - low, 3 - hd + switch video { + case "", "hd": + video = "3" + case "sd": + video = "1" // 2 is also low quality + case "auto": + video = "0" + } + s := fmt.Sprintf(`{"videoquality":%s}`, video) + + // quality after start + return errors.Join( + c.WriteCommand(cmdAudioStart, empty), + c.WriteCommand(cmdVideoStart, empty), + c.WriteCommand(cmdStreamCtrlReq, []byte(s)), + ) case ModelXiaobai: // 00030000 7b7d audio on // 01030000 7b7d audio off - if err := c.WriteCommand(0x0300, []byte(`{}`)); err != nil { - return err - } + // 20030000 0000000001000000 fhd (1920x1080) + // 20030000 0000000002000000 hd (1280x720) + // 20030000 0000000004000000 low (640x360) + // 20030000 00000000ff000000 auto (1920x1080) + // ff010000 7b7d video tart + // ff020000 7b7d video stop var b byte switch video { @@ -137,17 +169,13 @@ func (c *Client) StartMedia(video, audio string) error { case "auto": b = 0xff } - // 20030000 0000000001000000 fhd (1920x1080) - // 20030000 0000000002000000 hd (1280x720) - // 20030000 0000000004000000 low (640x360) - // 20030000 00000000ff000000 auto (1920x1080) - if err := c.WriteCommand(0x0320, []byte{0, 0, 0, 0, b, 0, 0, 0}); err != nil { - return err - } - // ff010000 7b7d video tart - // ff020000 7b7d video stop - return c.WriteCommand(0x01ff, []byte(`{}`)) + // quality before start + return errors.Join( + c.WriteCommand(cmdAudioStart, empty), + c.WriteCommand(cmdStreamCtrlReq, []byte{0, 0, 0, 0, b, 0, 0, 0}), + c.WriteCommand(cmdVideoStart, empty), + ) case ModelXiaofang: // 00010000 4943414d 95010400000000000000000600000000000000d20400005a07 - 90k bitrate @@ -170,8 +198,8 @@ func (c *Client) StartMedia(video, audio string) error { func (c *Client) StopMedia() error { return errors.Join( - c.WriteCommand(0x02ff, []byte(`{}`)), - c.WriteCommand(0x02ff, make([]byte, 8)), + c.WriteCommand(cmdVideoStop, empty), + c.WriteCommand(cmdVideoStop, make([]byte, 8)), ) } @@ -205,6 +233,7 @@ func DecodeVideo(data, key []byte) ([]byte, error) { const ( ModelAqaraG2 = "lumi.camera.gwagl01" ModelLoockV1 = "loock.cateye.v01" + ModelMijia = "chuangmi.camera.v2" // support miss format for new fw and legacy format for old fw ModelXiaobai = "chuangmi.camera.xiaobai" ModelXiaofang = "isa.camera.isc5" )