package wyze import ( "crypto/rand" "encoding/binary" "encoding/json" "fmt" "net" "net/url" "strconv" "strings" "sync" "time" "github.com/AlexxIT/go2rtc/pkg/tutk" "github.com/AlexxIT/go2rtc/pkg/tutk/dtls" ) const ( FrameSize1080P = 0 FrameSize360P = 1 FrameSize720P = 2 FrameSize2K = 3 FrameSizeFloodlight = 4 ) const ( BitrateMax uint16 = 0xF0 BitrateSD uint16 = 0x3C ) const ( MediaTypeVideo = 1 MediaTypeAudio = 2 MediaTypeReturnAudio = 3 MediaTypeRDT = 4 ) const ( KCmdAuth = 10000 KCmdChallenge = 10001 KCmdChallengeResp = 10002 KCmdAuthResult = 10003 KCmdControlChannel = 10010 KCmdControlChannelResp = 10011 KCmdSetResolutionDB = 10052 KCmdSetResolutionDBRes = 10053 KCmdSetResolution = 10056 KCmdSetResolutionResp = 10057 ) type Client struct { conn *dtls.DTLSConn host string uid string enr string mac string model string authKey string verbose bool closed bool closeMu sync.Mutex hasAudio bool hasIntercom bool audioCodecID byte audioSampleRate uint32 audioChannels uint8 } type AuthResponse struct { ConnectionRes string `json:"connectionRes"` CameraInfo map[string]any `json:"cameraInfo"` } func Dial(rawURL string) (*Client, error) { u, err := url.Parse(rawURL) if err != nil { 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"), model: query.Get("model"), verbose: query.Get("verbose") == "true", } c.authKey = string(dtls.CalculateAuthKey(c.enr, c.mac)) if c.verbose { fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid) } 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 byte, sampleRate uint32, channels uint8) { c.audioCodecID = codecID c.audioSampleRate = sampleRate c.audioChannels = channels } func (c *Client) GetBackchannelCodec() (codecID byte, sampleRate uint32, channels uint8) { return c.audioCodecID, c.audioSampleRate, c.audioChannels } func (c *Client) SetResolution(quality byte) error { var frameSize uint8 var bitrate uint16 switch quality { case 0: // Auto/HD - use model's best frameSize = c.hdFrameSize() bitrate = BitrateMax case FrameSize360P: // 1 = SD/360P frameSize = FrameSize360P bitrate = BitrateSD case FrameSize720P: // 2 = 720P frameSize = FrameSize720P bitrate = BitrateMax case FrameSize2K: // 3 = 2K if c.is2K() { frameSize = FrameSize2K } else { frameSize = c.hdFrameSize() } bitrate = BitrateMax case FrameSizeFloodlight: // 4 = Floodlight frameSize = c.hdFrameSize() bitrate = BitrateMax default: frameSize = quality bitrate = BitrateMax } if c.verbose { fmt.Printf("[Wyze] SetResolution: quality=%d frameSize=%d bitrate=%d model=%s\n", quality, frameSize, bitrate, c.model) } // Use K10052 (doorbell format) for certain models if c.useDoorbellResolution() { k10052 := c.buildK10052(frameSize, bitrate) _, err := c.conn.WriteAndWaitIOCtrl(k10052, c.matchHL(KCmdSetResolutionDBRes), 5*time.Second) return err } k10056 := c.buildK10056(frameSize, bitrate) _, err := c.conn.WriteAndWaitIOCtrl(k10056, c.matchHL(KCmdSetResolutionResp), 5*time.Second) return err } func (c *Client) StartVideo() error { k10010 := c.buildK10010(MediaTypeVideo, true) _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) return err } func (c *Client) StartAudio() error { k10010 := c.buildK10010(MediaTypeAudio, true) _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) return err } func (c *Client) StartIntercom() error { if c.conn == nil { return fmt.Errorf("connection is nil") } if c.conn.IsBackchannelReady() { return nil } k10010 := c.buildK10010(MediaTypeReturnAudio, true) if _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second); err != nil { return fmt.Errorf("enable return audio: %w", err) } if c.verbose { fmt.Printf("[Wyze] Speaker channel enabled, waiting for readiness...\n") } return c.conn.AVServStart() } func (c *Client) StopIntercom() error { if c.conn == nil || !c.conn.IsBackchannelReady() { return nil } k10010 := c.buildK10010(MediaTypeReturnAudio, false) c.conn.WriteIOCtrl(k10010) return c.conn.AVServStop() } func (c *Client) ReadPacket() (*tutk.Packet, error) { return c.conn.AVRecvFrameData() } func (c *Client) WriteAudio(codec byte, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { if !c.conn.IsBackchannelReady() { return fmt.Errorf("speaker channel not connected") } if c.verbose { fmt.Printf("[Wyze] WriteAudio: codec=0x%02x, 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") } c.StopIntercom() if c.conn != nil { c.conn.Close() } if c.verbose { fmt.Printf("[Wyze] Connection closed\n") } return nil } func (c *Client) connect() error { host := c.host port := 0 if idx := strings.Index(host, ":"); idx > 0 { if p, err := strconv.Atoi(host[idx+1:]); err == nil { port = p } host = host[:idx] } conn, err := dtls.DialDTLS(host, port, 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 { // Step 1: K10000 -> K10001 (Challenge) data, err := c.conn.WriteAndWaitIOCtrl(c.buildK10000(), c.matchHL(KCmdChallenge), 5*time.Second) if err != nil { return fmt.Errorf("wyze: K10001 failed: %w", err) } hlData := c.extractHL(data) challenge, status, err := c.parseK10001(hlData) if err != nil { return fmt.Errorf("wyze: K10001 parse failed: %w", err) } if c.verbose { fmt.Printf("[Wyze] K10001 challenge received, status=%d\n", status) } // Step 2: K10002 -> K10003 (Auth) data, err = c.conn.WriteAndWaitIOCtrl(c.buildK10002(challenge, status), c.matchHL(KCmdAuthResult), 5*time.Second) if err != nil { return fmt.Errorf("wyze: K10002 failed: %w", err) } hlData = c.extractHL(data) // Parse K10003 response authResp, err := c.parseK10003(hlData) if err != nil { return fmt.Errorf("wyze: K10003 parse failed: %w", err) } if c.verbose && authResp != nil { if jsonBytes, err := json.MarshalIndent(authResp, "", " "); err == nil { fmt.Printf("[Wyze] K10003 response:\n%s\n", jsonBytes) } } // Extract audio capability from cameraInfo if authResp != nil && authResp.CameraInfo != nil { if channelResult, ok := authResp.CameraInfo["channelRequestResult"].(map[string]any); ok { if audio, ok := channelResult["audio"].(string); ok { c.hasAudio = audio == "1" } else { c.hasAudio = true } } else { c.hasAudio = true } } else { c.hasAudio = true } if c.verbose { fmt.Printf("[Wyze] K10003 auth success\n") } c.hasIntercom = c.conn.HasTwoWayStreaming() if c.verbose { fmt.Printf("[Wyze] K-auth complete\n") } return nil } func (c *Client) buildK10000() []byte { json := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) // 137=PCMU, 138=PCMA, 140=PCM b := make([]byte, 16+len(json)) copy(b, "HL") // magic b[2] = 5 // version binary.LittleEndian.PutUint16(b[4:], KCmdAuth) // 10000 binary.LittleEndian.PutUint16(b[6:], uint16(len(json))) // payload len copy(b[16:], json) return b } func (c *Client) buildK10002(challenge []byte, status byte) []byte { resp := generateChallengeResponse(challenge, c.enr, status) sessionID := make([]byte, 4) rand.Read(sessionID) b := make([]byte, 38) copy(b, "HL") // magic b[2] = 5 // version binary.LittleEndian.PutUint16(b[4:], KCmdChallengeResp) // 10002 b[6] = 22 // payload len copy(b[16:], resp[:16]) // challenge response copy(b[32:], sessionID) // random session ID b[36] = 1 // video enabled/disabled b[37] = 1 // audio enabled/disabled return b } func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { b := make([]byte, 18) copy(b, "HL") // magic b[2] = 5 // version binary.LittleEndian.PutUint16(b[4:], KCmdControlChannel) // 10010 binary.LittleEndian.PutUint16(b[6:], 2) // payload len b[16] = mediaType // 1=video, 2=audio, 3=return audio b[17] = 1 // 1=enable, 2=disable if !enabled { b[17] = 2 } return b } func (c *Client) buildK10052(frameSize uint8, bitrate uint16) []byte { b := make([]byte, 22) copy(b, "HL") // magic b[2] = 5 // version binary.LittleEndian.PutUint16(b[4:], KCmdSetResolutionDB) // 10052 binary.LittleEndian.PutUint16(b[6:], 6) // payload len binary.LittleEndian.PutUint16(b[16:], bitrate) // bitrate (2 bytes) b[18] = frameSize + 1 // frame size (1 byte) // b[19] = fps, b[20:22] = zeros return b } func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { b := make([]byte, 21) copy(b, "HL") // magic b[2] = 5 // version binary.LittleEndian.PutUint16(b[4:], KCmdSetResolution) // 10056 binary.LittleEndian.PutUint16(b[6:], 5) // payload len b[16] = frameSize + 1 // frame size binary.LittleEndian.PutUint16(b[17:], bitrate) // bitrate // b[19:21] = FPS (0 = auto) return b } func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err error) { 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:]) if cmdID != 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) (*AuthResponse, error) { if c.verbose { fmt.Printf("[Wyze] parseK10003: received %d bytes\n", len(data)) } if len(data) < 16 { return &AuthResponse{}, nil } if data[0] != 'H' || data[1] != 'L' { return &AuthResponse{}, nil } cmdID := binary.LittleEndian.Uint16(data[4:]) textLen := binary.LittleEndian.Uint16(data[6:]) if cmdID != KCmdAuthResult { return &AuthResponse{}, nil } if len(data) > 16 && textLen > 0 { jsonData := data[16:] for i := range jsonData { if jsonData[i] == '{' { var resp AuthResponse if err := json.Unmarshal(jsonData[i:], &resp); err == nil { if c.verbose { fmt.Printf("[Wyze] parseK10003: parsed JSON\n") } return &resp, nil } break } } } return &AuthResponse{}, nil } func (c *Client) useDoorbellResolution() bool { switch c.model { case "WYZEDB3", "WVOD1", "HL_WCO2", "WYZEC1": return true } return false } func (c *Client) hdFrameSize() uint8 { if c.isFloodlight() { return FrameSizeFloodlight } if c.is2K() { return FrameSize2K } return FrameSize1080P } func (c *Client) is2K() bool { switch c.model { case "HL_CAM3P", "HL_PANP", "HL_CAM4", "HL_DB2", "HL_CFL2": return true } return false } func (c *Client) isFloodlight() bool { return c.model == "HL_CFL2" } func (c *Client) matchHL(expectCmd uint16) func([]byte) bool { return func(data []byte) bool { hlData := c.extractHL(data) if hlData == nil { return false } cmd, _, ok := tutk.ParseHL(hlData) return ok && cmd == expectCmd } } func (c *Client) extractHL(data []byte) []byte { // Try offset 32 (magicIOCtrl, protoVersion) if hlData := tutk.FindHL(data, 32); hlData != nil { return hlData } // Try offset 36 (magicChannelMsg) if len(data) >= 36 && data[16] == 0x00 { return tutk.FindHL(data, 36) } return nil } const ( statusDefault byte = 1 statusENR16 byte = 3 statusENR32 byte = 6 ) func generateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { var secretKey []byte switch status { case statusDefault: secretKey = []byte("FFFFFFFFFFFFFFFF") case statusENR16: if len(enr) >= 16 { secretKey = []byte(enr[:16]) } else { secretKey = make([]byte, 16) copy(secretKey, enr) } case statusENR32: if len(enr) >= 16 { firstKey := []byte(enr[:16]) challengeBytes = tutk.XXTEADecryptVar(challengeBytes, firstKey) } if len(enr) >= 32 { secretKey = []byte(enr[16:32]) } else if len(enr) > 16 { secretKey = make([]byte, 16) copy(secretKey, []byte(enr[16:])) } else { secretKey = []byte("FFFFFFFFFFFFFFFF") } default: secretKey = []byte("FFFFFFFFFFFFFFFF") } return tutk.XXTEADecryptVar(challengeBytes, secretKey) }