From a42ab88dbdee415b51820e567beaa35e69351334 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 1 Jan 2026 05:24:45 +0100 Subject: [PATCH 01/42] 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 f47a041ece68464a24e7ca89edd0999c0e7288a8 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 1 Jan 2026 11:37:44 +0100 Subject: [PATCH 02/42] 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 03/42] 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 04/42] 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 c74a39a30d76ee627414299fd8b982969c86b800 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 2 Jan 2026 16:51:23 +0100 Subject: [PATCH 05/42] 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 06/42] 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 07/42] 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 08/42] 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 84e13d9d2242716aad9e246be5855e951c11b709 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 Jan 2026 02:42:47 +0100 Subject: [PATCH 09/42] 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 10/42] 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 11/42] 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 12/42] 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 13/42] 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 659a042c424aac4e462f2a6d16789ca893578fb5 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 12 Jan 2026 02:13:00 +0100 Subject: [PATCH 14/42] 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 15/42] 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 16/42] 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 17/42] 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 18/42] 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 19/42] 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 20/42] 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 21/42] 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 bc0c8d5577a0dbe928b73b258764ce18cbd2be5a Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 13 Jan 2026 16:48:41 +0100 Subject: [PATCH 22/42] 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 23/42] 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 a99590823b301d930533128ace8868e28e6062a9 Mon Sep 17 00:00:00 2001 From: seydx Date: Wed, 14 Jan 2026 19:11:53 +0100 Subject: [PATCH 24/42] 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 25/42] 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 26/42] 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 27/42] 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 28/42] 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 29/42] 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 30/42] 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 31/42] 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 32/42] 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 33/42] 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 34/42] 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 c03cd9f1561eedb6933b01dc78498c1050ec81ba Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 12:09:38 +0100 Subject: [PATCH 35/42] 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 160695857e2a7c3d9e774e5cf7713f0fe224913b Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 Jan 2026 20:22:08 +0100 Subject: [PATCH 36/42] 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 37/42] 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 38/42] 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 39/42] 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 40/42] 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 41/42] 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 514188201a4b4ab4c847d2c13efe6c1321d0a6b8 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 18 Jan 2026 08:38:38 +0300 Subject: [PATCH 42/42] 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) }