This commit is contained in:
seydx
2026-01-12 19:57:38 +01:00
parent 3a587c9cee
commit 039e916030
5 changed files with 145 additions and 141 deletions
+33 -32
View File
@@ -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 | | | | | |
| 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 | | | ? | | |
+1 -1
View File
@@ -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 {
+109 -80
View File
@@ -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
}
+2 -2
View File
@@ -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 {
-26
View File
@@ -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")
}