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 });