diff --git a/pkg/wyze/README.md b/pkg/wyze/README.md index fbbc0bc3..654ce2d9 100644 --- a/pkg/wyze/README.md +++ b/pkg/wyze/README.md @@ -33,7 +33,7 @@ wyze: password: "yourpassword" # or MD5 triple-hash with "md5:" prefix streams: - wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&dtls=true + wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&model=HL_CAM4&dtls=true ``` ## Stream URL Format @@ -41,7 +41,7 @@ streams: The stream URL is automatically generated when you add cameras via the WebUI: ``` -wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&dtls=true +wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&model=[MODEL]&subtype=[hd|sd]&dtls=true ``` | Parameter | Description | @@ -50,18 +50,20 @@ wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&dtls=true | `uid` | P2P identifier (20 chars) | | `enr` | Encryption key for DTLS | | `mac` | Device MAC address | +| `model` | Camera model (e.g., HL_CAM4) | | `dtls` | Enable DTLS encryption (default: true) | +| `subtype` | Camera resolution: `hd` or `sd` (default: `hd`) | ## Configuration ### Resolution -You can change the camera's resolution using the `quality` parameter: +You can change the camera's resolution using the `subtype` parameter: ```yaml streams: - wyze_hd: wyze://...&quality=hd - wyze_sd: wyze://...&quality=sd + wyze_hd: wyze://...&subtype=hd + wyze_sd: wyze://...&subtype=sd ``` ### Two-Way Audio @@ -74,30 +76,29 @@ Two-way audio (intercom) is supported automatically. When a consumer sends audio |------|-------|----------|----------|------------|--------| | Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | hevc, aac | | | | 4.52.9.5332 | TUTK | HMAC-SHA1 | hevc, aac | -| Wyze Cam v3 Pro | | | | | | -| Wyze Cam v3 | | | | | | -| Wyze Cam v2 | | | | | | -| Wyze Cam v1 | | | | | | -| Wyze Cam Pan v4 | | | | | | -| Wyze Cam Pan v3 | | | | | | -| Wyze Cam Pan v2 | | | | | | -| Wyze Cam Pan v1 | | | | | | -| Wyze Cam OG | | | | | | -| Wyze Cam OG Telephoto | | | | | | -| Wyze Cam OG (2025) | | | | | | -| Wyze Cam Outdoor v2 | | | | | | -| Wyze Cam Outdoor v1 | | | | | | -| Wyze Cam Outdoor Base Station | | | | | | -| Wyze Cam Floodlight Pro | | | | | | -| Wyze Cam Floodlight v2 | | | | | | -| Wyze Cam Floodlight | | | | | | -| Wyze Video Doorbell v2 | | | | | | -| Wyze Video Doorbell v1 | | | | | | -| Wyze Video Doorbell Pro | | | | | | -| Wyze Battery Video Doorbell | | | | | | -| Wyze Duo Cam Doorbell | | | | | | -| Wyze Battery Cam Pro | | | | | | -| Wyze Solar Cam Pan | | | | | | -| Wyze Duo Cam Pan | | | | | | -| Wyze Window Cam | | | | | | -| Wyze Bulb Cam | | | | | | \ No newline at end of file +| Wyze Cam v3 Pro | | | TUTK | | | +| Wyze Cam v3 | | | TUTK | | | +| Wyze Cam v2 | | | TUTK | | | +| Wyze Cam v1 | | | TUTK | | | +| Wyze Cam Pan v4 | | | Gwell | | | +| Wyze Cam Pan v3 | | | TUTK | | | +| Wyze Cam Pan v2 | | | TUTK | | | +| Wyze Cam Pan v1 | | | TUTK | | | +| Wyze Cam OG | | | Gwell | | | +| Wyze Cam OG Telephoto | | | Gwell | | | +| Wyze Cam OG (2025) | | | Gwell | | | +| Wyze Cam Outdoor v2 | | | TUTK | | | +| Wyze Cam Outdoor v1 | | | TUTK | | | +| Wyze Cam Floodlight Pro | | | ? | | | +| Wyze Cam Floodlight v2 | | | TUTK | | | +| Wyze Cam Floodlight | | | TUTK | | | +| Wyze Video Doorbell v2 | | | TUTK | | | +| Wyze Video Doorbell v1 | | | TUTK | | | +| Wyze Video Doorbell Pro | | | ? | | | +| Wyze Battery Video Doorbell | | | ? | | | +| Wyze Duo Cam Doorbell | | | ? | | | +| Wyze Battery Cam Pro | | | ? | | | +| Wyze Solar Cam Pan | | | ? | | | +| Wyze Duo Cam Pan | | | ? | | | +| Wyze Window Cam | | | ? | | | +| Wyze Bulb Cam | | | ? | | | \ No newline at end of file diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index e1414e53..6ead2372 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -300,7 +300,7 @@ func (c *Client) doAVLogin() error { } if err := c.conn.AVClientStart(5 * time.Second); err != nil { - return fmt.Errorf("wyze: AV login failed: %w", err) + return fmt.Errorf("wyze: av login failed: %w", err) } if c.verbose { diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index 4c38973c..539a5a66 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -31,6 +31,12 @@ type Conn struct { conn *net.UDPConn addr *net.UDPAddr + // DTLS + clientConn *dtls.Conn + serverConn *dtls.Conn + clientBuf chan []byte + serverBuf chan []byte + // Identity uid string authKey string @@ -45,25 +51,17 @@ type Conn struct { avResp *AVLoginResponse // Protocol - newProto bool - seq uint16 - seqCmd uint16 - avSeq uint32 - kaSeq uint32 - - // DTLS - main *dtls.Conn - speaker *dtls.Conn - mainBuf chan []byte - speakBuf chan []byte + newProto bool + seq uint16 + seqCmd uint16 + avSeq uint32 + kaSeq uint32 + audioSeq uint32 + audioFrameNo uint32 // Channels rawCmd chan []byte - // Audio TX - audioSeq uint32 - audioFrame uint32 - // Frame assembly frames *FrameHandler ackFlags uint16 @@ -91,12 +89,6 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { ctx, cancel := context.WithCancel(context.Background()) psk := derivePSK(enr) - if verbose { - hash := sha256.Sum256([]byte(enr)) - fmt.Printf("[PSK] ENR: %q → SHA256: %x\n", enr, hash) - fmt.Printf("[PSK] PSK: %x\n", psk) - } - c := &Conn{ conn: udp, addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, @@ -116,8 +108,8 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { return nil, err } - c.mainBuf = make(chan []byte, 64) - c.speakBuf = make(chan []byte, 64) + c.clientBuf = make(chan []byte, 64) + c.serverBuf = make(chan []byte, 64) c.rawCmd = make(chan []byte, 16) c.frames = NewFrameHandler(c.verbose) @@ -141,14 +133,14 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { pkt2 := c.buildAVLoginPacket(MagicAVLogin2, 572, 0x0000, randomID) pkt2[20]++ // pkt2 has randomID incremented by 1 - if _, err := c.main.Write(pkt1); err != nil { - return fmt.Errorf("AV login 1 failed: %w", err) + if _, err := c.clientConn.Write(pkt1); err != nil { + return fmt.Errorf("av login 1 failed: %w", err) } time.Sleep(50 * time.Millisecond) - if _, err := c.main.Write(pkt2); err != nil { - return fmt.Errorf("AV login 2 failed: %w", err) + if _, err := c.clientConn.Write(pkt2); err != nil { + return fmt.Errorf("av login 2 failed: %w", err) } // Wait for response @@ -167,12 +159,8 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { TwoWayStreaming: int32(data[31]), } - if c.verbose { - fmt.Printf("[TUTK] AV Login Response: two_way_streaming=%d\n", c.avResp.TwoWayStreaming) - } - ack := c.buildACK() - c.main.Write(ack) + c.clientConn.Write(ack) return nil } @@ -195,7 +183,7 @@ func (c *Conn) AVServStart() error { } c.mu.Lock() - c.speaker = conn + c.serverConn = conn c.mu.Unlock() if c.verbose { @@ -204,7 +192,7 @@ func (c *Conn) AVServStart() error { // Wait for and respond to AV Login request from camera if err := c.handleSpeakerAVLogin(); err != nil { - return fmt.Errorf("speaker AV login failed: %w", err) + return fmt.Errorf("speaker av login failed: %w", err) } return nil @@ -216,11 +204,11 @@ func (c *Conn) AVServStop() error { // Reset audio TX state c.audioSeq = 0 - c.audioFrame = 0 + c.audioFrameNo = 0 - if c.speaker != nil { - err := c.speaker.Close() - c.speaker = nil + if c.serverConn != nil { + err := c.serverConn.Close() + c.serverConn = nil return err } return nil @@ -240,7 +228,7 @@ func (c *Conn) AVRecvFrameData() (*Packet, error) { func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { c.mu.Lock() - conn := c.speaker + conn := c.serverConn if conn == nil { c.mu.Unlock() return fmt.Errorf("speaker channel not connected") @@ -253,15 +241,19 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, n, err := conn.Write(frame) if c.verbose { if err != nil { - fmt.Printf("[AUDIO TX] DTLS Write ERROR: %v\n", err) + fmt.Printf("[SPEAKER TX] DTLS Write ERROR: %v\n", err) } else { - fmt.Printf("[AUDIO TX] DTLS Write OK: %d bytes\n", n) + fmt.Printf("[SPEAKER TX] len=%d, data:\n%s", n, hexDump(frame)) } } return err } func (c *Conn) Write(data []byte) error { + if c.verbose { + fmt.Printf("[UDP TX] to=%s, len=%d, data:\n%s", c.addr.String(), len(data), hexDump(data)) + } + if c.newProto { _, err := c.conn.WriteToUDP(data, c.addr) return err @@ -277,6 +269,11 @@ func (c *Conn) WriteDTLS(payload []byte, channel byte) error { } else { frame = c.buildTxData(payload, channel) } + + if c.verbose { + fmt.Printf("[DTLS TX] to=%s, len=%d, channel=%d, data:\n%s", c.addr.String(), len(frame), channel, hexDump(frame)) + } + return c.Write(frame) } @@ -320,7 +317,7 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, frame := c.buildIOCtrlFrame(payload) var t *time.Timer t = time.AfterFunc(1, func() { - if _, err := c.main.Write(frame); err == nil && t != nil { + if _, err := c.clientConn.Write(frame); err == nil && t != nil { t.Reset(time.Second) } }) @@ -337,7 +334,7 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, } ack := c.buildACK() - c.main.Write(ack) + c.clientConn.Write(ack) if len(data) >= 6 { if binary.LittleEndian.Uint16(data[4:]) == expectCmd { @@ -357,7 +354,7 @@ func (c *Conn) GetAVLoginResponse() *AVLoginResponse { func (c *Conn) IsBackchannelReady() bool { c.mu.RLock() defer c.mu.RUnlock() - return c.speaker != nil + return c.serverConn != nil } func (c *Conn) RemoteAddr() *net.UDPAddr { @@ -376,13 +373,13 @@ func (c *Conn) Close() error { c.cancel() c.mu.Lock() - if c.main != nil { - c.main.Close() - c.main = nil + if c.clientConn != nil { + c.clientConn.Close() + c.clientConn = nil } - if c.speaker != nil { - c.speaker.Close() - c.speaker = nil + if c.serverConn != nil { + c.serverConn.Close() + c.serverConn = nil } if c.frames != nil { c.frames.Close() @@ -449,7 +446,6 @@ func (c *Conn) discovery() error { func (c *Conn) oldDiscoDone() error { c.Write(c.buildDisco(2)) time.Sleep(100 * time.Millisecond) - _, err := c.WriteAndWait(c.buildSession(), SessionTimeout, func(res []byte) bool { return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == CmdSessionRes }) @@ -482,7 +478,7 @@ func (c *Conn) connect() error { } c.mu.Lock() - c.main = conn + c.clientConn = conn c.mu.Unlock() if c.verbose { @@ -504,7 +500,7 @@ func (c *Conn) worker() { default: } - n, err := c.main.Read(buf) + n, err := c.clientConn.Read(buf) if err != nil { c.err = err return @@ -517,6 +513,10 @@ func (c *Conn) worker() { data := buf[:n] magic := binary.LittleEndian.Uint16(data) + if c.verbose { + fmt.Printf("[DTLS RX] from=%s, len=%d, data:\n%s", c.addr.String(), n, hexDump(data)) + } + switch magic { case MagicAVLoginResp: c.queue(c.rawCmd, data) @@ -578,7 +578,14 @@ func (c *Conn) reader() { return } + if c.verbose { + fmt.Printf("[UDP RX] from=%s, len=%d, data:\n%s", addr.String(), n, hexDump(buf[:n])) + } + if !addr.IP.Equal(c.addr.IP) { + if c.verbose { + fmt.Printf("Ignored packet from unknown IP: %s\n", addr.IP.String()) + } continue } if addr.Port != c.addr.Port { @@ -599,9 +606,9 @@ func (c *Conn) reader() { dtls := buf[NewHeaderSize : n-NewAuthSize] switch ch { case IOTCChannelMain: - c.queue(c.mainBuf, dtls) + c.queue(c.clientBuf, dtls) case IOTCChannelBack: - c.queue(c.speakBuf, dtls) + c.queue(c.serverBuf, dtls) } } } @@ -624,9 +631,9 @@ func (c *Conn) reader() { ch := data[14] switch ch { case IOTCChannelMain: - c.queue(c.mainBuf, data[28:]) + c.queue(c.clientBuf, data[28:]) case IOTCChannelBack: - c.queue(c.speakBuf, data[28:]) + c.queue(c.serverBuf, data[28:]) } } } @@ -653,18 +660,18 @@ func (c *Conn) handleSpeakerAVLogin() error { } buf := make([]byte, 1024) - c.speaker.SetReadDeadline(time.Now().Add(5 * time.Second)) - n, err := c.speaker.Read(buf) + c.serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, err := c.serverConn.Read(buf) if err != nil { - return fmt.Errorf("read AV login: %w", err) + return fmt.Errorf("read av login: %w", err) } if c.verbose { - fmt.Printf("[SPEAK] Received AV Login request: %d bytes\n", n) + fmt.Printf("[SPEAK] AV Login request len=%d data:\n%s", n, hexDump(buf[:n])) } if n < 24 { - return fmt.Errorf("AV login too short: %d bytes", n) + return fmt.Errorf("av login too short: %d bytes", n) } checksum := binary.LittleEndian.Uint32(buf[20:]) @@ -674,20 +681,20 @@ func (c *Conn) handleSpeakerAVLogin() error { fmt.Printf("[SPEAK] Sending AV Login response: %d bytes\n", len(resp)) } - if _, err = c.speaker.Write(resp); err != nil { + if _, err = c.serverConn.Write(resp); err != nil { return fmt.Errorf("write AV login response: %w", err) } // Camera may resend, respond again - c.speaker.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - if n, _ = c.speaker.Read(buf); n > 0 { + c.serverConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if n, _ = c.serverConn.Read(buf); n > 0 { if c.verbose { fmt.Printf("[SPEAK] Received AV Login resend: %d bytes\n", n) } - c.speaker.Write(resp) + c.serverConn.Write(resp) } - c.speaker.SetReadDeadline(time.Time{}) + c.serverConn.SetReadDeadline(time.Time{}) if c.verbose { fmt.Printf("[SPEAK] AV Login complete, ready for audio\n") @@ -767,9 +774,10 @@ func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size binary.LittleEndian.PutUint16(b[18:], flags) copy(b[20:], randomID[:4]) - copy(b[24:], DefaultUser) // username - copy(b[280:], c.enr) // password (ENR) - binary.LittleEndian.PutUint32(b[540:], 2) // security_mode=AV_SECURITY_AUTO + copy(b[24:], DefaultUser) // username + copy(b[280:], c.enr) // password/ENR + // binary.LittleEndian.PutUint32(b[536:], 1) // resend + binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? binary.LittleEndian.PutUint32(b[552:], DefaultCaps) // capabilities return b } @@ -792,10 +800,10 @@ func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { c.audioSeq++ - c.audioFrame++ + c.audioFrameNo++ prevFrame := uint32(0) - if c.audioFrame > 1 { - prevFrame = c.audioFrame - 1 + if c.audioFrameNo > 1 { + prevFrame = c.audioFrameNo - 1 } totalPayload := len(payload) + 16 // payload + frameinfo @@ -807,7 +815,7 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, binary.LittleEndian.PutUint16(b[2:], ProtoVersion) binary.LittleEndian.PutUint32(b[4:], c.audioSeq) binary.LittleEndian.PutUint32(b[8:], timestampUS) - if c.audioFrame == 1 { + if c.audioFrameNo == 1 { binary.LittleEndian.PutUint32(b[12:], 0x00000001) } else { binary.LittleEndian.PutUint32(b[12:], 0x00100001) @@ -821,13 +829,13 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload)) binary.LittleEndian.PutUint32(b[28:], prevFrame) - binary.LittleEndian.PutUint32(b[32:], c.audioFrame) + binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo) copy(b[36:], payload) // Payload + FrameInfo fi := b[36+len(payload):] binary.LittleEndian.PutUint16(fi, codec) fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) fi[4] = 1 // online - binary.LittleEndian.PutUint32(fi[12:], (c.audioFrame-1)*GetSamplesPerFrame(codec)*1000/sampleRate) + binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*GetSamplesPerFrame(codec)*1000/sampleRate) return b } @@ -916,10 +924,8 @@ func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { func derivePSK(enr string) []byte { // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) // contains a 0x00 byte, the PSK is truncated at that position. - // This matches iOS Wyze app behavior discovered via Frida instrumentation. - + // bytes after the first 0x00 are padded with zeros to make a 32-byte key. hash := sha256.Sum256([]byte(enr)) - pskLen := 32 for i := range 32 { if hash[i] == 0x00 { @@ -928,7 +934,6 @@ func derivePSK(enr string) []byte { } } - // bytes up to first 0x00, rest padded with zeros psk := make([]byte, 32) copy(psk[:pskLen], hash[:pskLen]) return psk @@ -939,3 +944,27 @@ func genRandomID() []byte { _, _ = rand.Read(b) return b } + +func hexDump(data []byte) string { + const maxBytes = 650 + totalLen := len(data) + truncated := totalLen > maxBytes + if truncated { + data = data[:maxBytes] + } + + var result string + for i := 0; i < len(data); i += 16 { + end := min(i+16, len(data)) + line := fmt.Sprintf(" %04x:", i) + for j := i; j < end; j++ { + line += fmt.Sprintf(" %02x", data[j]) + } + result += line + "\n" + } + + if truncated { + result += fmt.Sprintf(" ... (truncated, showing %d of %d bytes)\n", maxBytes, totalLen) + } + return result +} diff --git a/pkg/wyze/tutk/dtls.go b/pkg/wyze/tutk/dtls.go index e24425bd..e4e2b3ea 100644 --- a/pkg/wyze/tutk/dtls.go +++ b/pkg/wyze/tutk/dtls.go @@ -47,9 +47,9 @@ type ChannelAdapter struct { func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { var buf chan []byte if a.channel == IOTCChannelMain { - buf = a.conn.mainBuf + buf = a.conn.clientBuf } else { - buf = a.conn.speakBuf + buf = a.conn.serverBuf } select { diff --git a/pkg/wyze/tutk/frame.go b/pkg/wyze/tutk/frame.go index 3777f9fd..f3191b1f 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/wyze/tutk/frame.go @@ -258,19 +258,11 @@ func (h *FrameHandler) Handle(data []byte) { return } - if h.verbose { - h.logWireHeader(data, hdr) - } - payload, fi := h.extractPayload(data, hdr.Channel) if payload == nil { return } - if h.verbose { - h.logAVPacket(hdr.Channel, hdr.FrameType, payload, fi) - } - switch hdr.Channel { case ChannelAudio: h.handleAudio(payload, fi) @@ -485,21 +477,3 @@ func (h *FrameHandler) queue(pkt *Packet) { h.output <- pkt } } - -func (h *FrameHandler) logWireHeader(data []byte, hdr *PacketHeader) { - fmt.Printf("[WIRE] ch=0x%02x type=0x%02x len=%d pkt=%d/%d frame=%d\n", - hdr.Channel, hdr.FrameType, len(data), hdr.PktIdx, hdr.PktTotal, hdr.FrameNo) - fmt.Printf(" RAW[0..35]: ") - for i := 0; i < 36 && i < len(data); i++ { - fmt.Printf("%02x ", data[i]) - } - fmt.Printf("\n") -} - -func (h *FrameHandler) logAVPacket(channel, frameType byte, payload []byte, fi *FrameInfo) { - fmt.Printf("[AV] ch=0x%02x type=0x%02x len=%d", channel, frameType, len(payload)) - if fi != nil { - fmt.Printf(" fi={codec=0x%04x flags=0x%02x ts=%d}", fi.CodecID, fi.Flags, fi.Timestamp) - } - fmt.Printf("\n") -}