diff --git a/README.md b/README.md index 90109ddc..c70e65bd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - devices: `alsa` (Linux audio), `v4l2` (Linux video) - files: `adts`, `flv`, `h264`, `hevc`, `hls`, `mjpeg`, `mpegts`, `mp4`, `wav` - network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `yuv4mpegpipe` -- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `multitrans` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `xiaomi` (Mi Home) +- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `multitrans` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `wyze`, `xiaomi` (Mi Home) - webrtc related: `creality`, `kinesis` (Amazon), `openipc`, `switchbot`, `whep`, `whip`, `wyze` - other: `ascii`, `echo`, `exec`, `expr`, `ffmpeg` @@ -82,6 +82,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [Source: Multitrans](#source-multitrans) * [Source: Tuya](#source-tuya) * [Source: Xiaomi](#source-xiaomi) + * [Source: Wyze](#source-wyze) * [Source: GoPro](#source-gopro) * [Source: Ivideon](#source-ivideon) * [Source: Hass](#source-hass) @@ -234,6 +235,7 @@ Available source types: - [doorbird](#source-doorbird) - Doorbird cameras with [two way audio](#two-way-audio) support - [webrtc](#source-webrtc) - WebRTC/WHEP sources - [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc +- [wyze](#source-wyze) - Wyze cameras with [two way audio](#two-way-audio) support Read more about [incoming sources](#incoming-sources) @@ -250,6 +252,7 @@ Supported sources: - [Exec](#source-exec) audio on server - [Ring](#source-ring) cameras - [Tuya](#source-tuya) cameras +- [Wyze](#source-wyze) cameras - [Xiaomi](#source-xiaomi) cameras - [Any Browser](#incoming-browser) as IP-camera @@ -636,6 +639,12 @@ This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi *[read more](internal/xiaomi/README.md)* +## Source: Wyze + +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol - no `docker-wyze-bridge` required. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. + +*[read more](internal/wyze/README.md)* + ## Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* @@ -763,9 +772,9 @@ This format is only supported in go2rtc. Unlike WHEP, it supports asynchronous W Support connection to [OpenIPC](https://openipc.org/) cameras. -**wyze** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) +**wyze (via docker-wyze-bridge)** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) -Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use the [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials. +Legacy method to connect to [Wyze](https://www.wyze.com/) cameras using WebRTC protocol via [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge). For native P2P support without docker-wyze-bridge, see [Source: Wyze](#source-wyze). **kinesis** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) diff --git a/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/README.md b/internal/wyze/README.md new file mode 100644 index 00000000..ca7cf6c4 --- /dev/null +++ b/internal/wyze/README.md @@ -0,0 +1,106 @@ +# 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 firmware with DTLS**. Only cameras with DTLS-enabled firmware are supported. +3. Internet access is only needed when loading cameras from your account. After that, all streaming is local P2P. +4. Connection to the camera is local only (direct P2P to camera IP). + +**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&model=HL_CAM4&dtls=true +``` + +## 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]&model=[MODEL]&subtype=[hd|sd]&dtls=true +``` + +| Parameter | Description | +|-----------|-------------| +| `IP` | Camera's local IP address | +| `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 `subtype` parameter: + +```yaml +streams: + wyze_hd: wyze://...&subtype=hd + wyze_sd: wyze://...&subtype=sd +``` + +### Two-Way Audio + +Two-way audio (intercom) is supported automatically. When a consumer sends audio to the stream, it will be transmitted to the camera's speaker. + +## Camera Compatibility + +| Name | Model | Firmware | Protocol | Encryption | Codecs | +|------|-------|----------|----------|------------|--------| +| Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | h264, aac | +| | | 4.52.9.5332 | TUTK | HMAC-SHA1 | h264, aac | +| Wyze Cam v3 Pro | | | TUTK | | | +| Wyze Cam v3 | WYZE_CAKP2JFUS | 4.36.14.3497 | TUTK | TransCode | h264, pcm | +| Wyze Cam v2 | WYZEC1-JZ | 4.9.9.3006 | TUTK | TransCode | h264, pcmu | +| Wyze Cam v1 | | | TUTK | | | +| Wyze Cam Pan v4 | | | Gwell* | | | +| Wyze Cam Pan 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 | HL_DB2 | 4.51.3.4992 | TUTK | TransCode | h264, pcm | +| 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 | | | ? | | | + +_* Gwell based protocols are not yet supported._ diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go new file mode 100644 index 00000000..982a16ed --- /dev/null +++ b/internal/wyze/wyze.go @@ -0,0 +1,202 @@ +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" +) + +type AccountConfig struct { + APIKey string `yaml:"api_key"` + APIID string `yaml:"api_id"` + Password string `yaml:"password"` +} + +var accounts map[string]AccountConfig + +func Init() { + var v struct { + Cfg map[string]AccountConfig `yaml:"wyze"` + } + 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) +} + +func getCloud(email string) (*wyze.Cloud, error) { + cfg, ok := accounts[email] + if !ok { + return nil, fmt.Errorf("wyze: account not found: %s", email) + } + + if cfg.APIKey == "" || cfg.APIID == "" { + return nil, fmt.Errorf("wyze: api_key and api_id required for account: %s", email) + } + + cloud := wyze.NewCloud(cfg.APIKey, cfg.APIID) + + if err := cloud.Login(email, cfg.Password); err != nil { + return nil, err + } + + 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 == "" { + 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 { + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), + URL: buildStreamURL(cam), + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +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 == "" || apiKey == "" || apiID == "" { + http.Error(w, "email, password, api_key and api_id required", http.StatusBadRequest) + return + } + + // Try to login + cloud := wyze.NewCloud(apiKey, apiID) + + 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 + } + + cfg := map[string]string{ + "password": password, + "api_key": apiKey, + "api_id": apiID, + } + + if err := app.PatchConfig([]string{"wyze", email}, cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if accounts == nil { + accounts = make(map[string]AccountConfig) + } + accounts[email] = AccountConfig{ + APIKey: apiKey, + APIID: apiID, + Password: password, + } + + cameras, err := cloud.GetCameraList() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + for _, cam := range cameras { + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), + URL: buildStreamURL(cam), + }) + } + + api.ResponseSources(w, items) +} + +func buildStreamURL(cam *wyze.Camera) string { + query := url.Values{} + query.Set("uid", cam.P2PID) + query.Set("enr", cam.ENR) + query.Set("mac", cam.MAC) + query.Set("model", cam.ProductModel) + + if cam.DTLS == 1 { + query.Set("dtls", "true") + } + + return fmt.Sprintf("wyze://%s?%s", cam.IP, query.Encode()) +} + +func isAuthError(err error, target **wyze.AuthError) bool { + if e, ok := err.(*wyze.AuthError); ok { + *target = e + return true + } + return false +} diff --git a/main.go b/main.go index 02d11cd8..def7ee35 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,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" @@ -102,6 +103,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/tutk/codec.go b/pkg/tutk/codec.go new file mode 100644 index 00000000..9ec7d8cb --- /dev/null +++ b/pkg/tutk/codec.go @@ -0,0 +1,59 @@ +package tutk + +// https://github.com/seydx/tutk_wyze#11-codec-reference +const ( + CodecMPEG4 byte = 0x4C + CodecH263 byte = 0x4D + CodecH264 byte = 0x4E + CodecMJPEG byte = 0x4F + CodecH265 byte = 0x50 +) + +const ( + CodecAACRaw byte = 0x86 + CodecAACADTS byte = 0x87 + CodecAACLATM byte = 0x88 + CodecPCMU byte = 0x89 + CodecPCMA byte = 0x8A + CodecADPCM byte = 0x8B + CodecPCML byte = 0x8C + CodecSPEEX byte = 0x8D + CodecMP3 byte = 0x8E + CodecG726 byte = 0x8F + CodecAACAlt byte = 0x90 + CodecOpus byte = 0x92 +) + +var sampleRates = [9]uint32{8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000} + +func GetSampleRateIndex(sampleRate uint32) uint8 { + for i, rate := range sampleRates { + if rate == sampleRate { + return uint8(i) + } + } + return 3 // default 16kHz +} + +func GetSamplesPerFrame(codecID byte) uint32 { + switch codecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + return 1024 + case CodecPCMU, CodecPCMA, CodecPCML, CodecADPCM, CodecSPEEX, CodecG726: + return 160 + case CodecMP3: + return 1152 + case CodecOpus: + return 960 + default: + return 1024 + } +} + +func IsVideoCodec(id byte) bool { + return id >= CodecMPEG4 && id <= CodecH265 +} + +func IsAudioCodec(id byte) bool { + return id >= CodecAACRaw && id <= CodecOpus +} diff --git a/pkg/tutk/crypto.go b/pkg/tutk/crypto.go index 6b306255..469bd2bc 100644 --- a/pkg/tutk/crypto.go +++ b/pkg/tutk/crypto.go @@ -50,6 +50,34 @@ func ReverseTransCodePartial(dst, src []byte) []byte { return dst } +func ReverseTransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return ReverseTransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := ReverseTransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if dst[3]&1 != 0 { // Partial encryption (check decrypted header) + remaining := len(src) - 16 + decryptLen := min(remaining, 48) + if decryptLen > 0 { + decrypted := ReverseTransCodePartial(nil, src[16:16+decryptLen]) + copy(dst[16:], decrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full decryption + decrypted := ReverseTransCodePartial(nil, src[16:]) + copy(dst[16:], decrypted) + } + } + return dst +} + func TransCodePartial(dst, src []byte) []byte { n := len(src) tmp := make([]byte, n) @@ -92,6 +120,34 @@ func TransCodePartial(dst, src []byte) []byte { return dst } +func TransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return TransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := TransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if src[3]&1 != 0 { // Partial encryption + remaining := len(src) - 16 + encryptLen := min(remaining, 48) + if encryptLen > 0 { + encrypted := TransCodePartial(nil, src[16:16+encryptLen]) + copy(dst[16:], encrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full encryption + encrypted := TransCodePartial(nil, src[16:]) + copy(dst[16:], encrypted) + } + } + return dst +} + func swap(dst, src []byte, n int) { switch n { case 2: @@ -175,3 +231,49 @@ func XXTEADecrypt(dst, src, key []byte) { dst = dst[4:] } } + +func XXTEADecryptVar(data, key []byte) []byte { + if len(data) < 8 || len(key) < 16 { + return nil + } + + k := make([]uint32, 4) + for i := range 4 { + k[i] = binary.LittleEndian.Uint32(key[i*4:]) + } + + n := max(len(data)/4, 2) + v := make([]uint32, n) + for i := 0; i < len(data)/4; i++ { + v[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + + rounds := 6 + 52/n + sum := uint32(rounds) * delta + y := v[0] + + for rounds > 0 { + e := (sum >> 2) & 3 + for p := n - 1; p > 0; p-- { + z := v[p-1] + v[p] -= xxteaMX(sum, y, z, p, e, k) + y = v[p] + } + z := v[n-1] + v[0] -= xxteaMX(sum, y, z, 0, e, k) + y = v[0] + sum -= delta + rounds-- + } + + result := make([]byte, n*4) + for i := range n { + binary.LittleEndian.PutUint32(result[i*4:], v[i]) + } + + return result[:len(data)] +} + +func xxteaMX(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { + return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) +} diff --git a/pkg/tutk/dtls/auth.go b/pkg/tutk/dtls/auth.go new file mode 100644 index 00000000..7354428d --- /dev/null +++ b/pkg/tutk/dtls/auth.go @@ -0,0 +1,35 @@ +package dtls + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +func CalculateAuthKey(enr, mac string) []byte { + data := enr + strings.ToUpper(mac) + hash := sha256.Sum256([]byte(data)) + b64 := base64.StdEncoding.EncodeToString(hash[:6]) + b64 = strings.ReplaceAll(b64, "+", "Z") + b64 = strings.ReplaceAll(b64, "/", "9") + b64 = strings.ReplaceAll(b64, "=", "A") + return []byte(b64) +} + +func DerivePSK(enr string) []byte { + // DerivePSK derives the DTLS PSK from ENR + // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) + // contains a 0x00 byte, the PSK is truncated at that position. + hash := sha256.Sum256([]byte(enr)) + pskLen := 32 + for i := range 32 { + if hash[i] == 0x00 { + pskLen = i + break + } + } + + psk := make([]byte, 32) + copy(psk[:pskLen], hash[:pskLen]) + return psk +} diff --git a/pkg/tutk/dtls/cipher.go b/pkg/tutk/dtls/cipher.go new file mode 100644 index 00000000..e987ff8e --- /dev/null +++ b/pkg/tutk/dtls/cipher.go @@ -0,0 +1,218 @@ +package dtls + +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 := range chachaNonceLength { + 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/tutk/dtls/conn_dtls.go b/pkg/tutk/dtls/conn_dtls.go new file mode 100644 index 00000000..c1d5f6ce --- /dev/null +++ b/pkg/tutk/dtls/conn_dtls.go @@ -0,0 +1,987 @@ +package dtls + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/dtls/v3" +) + +const ( + magicCC51 = "\x51\xcc" // (wyze specific?) + sdkVersion42 = "\x01\x01\x02\x04" // 4.2.1.1 + sdkVersion43 = "\x00\x08\x03\x04" // 4.3.8.0 +) + +const ( + cmdDiscoReq uint16 = 0x0601 + cmdDiscoRes uint16 = 0x0602 + cmdSessionReq uint16 = 0x0402 + cmdSessionRes uint16 = 0x0404 + cmdDataTX uint16 = 0x0407 + cmdDataRX uint16 = 0x0408 + cmdKeepaliveReq uint16 = 0x0427 + cmdKeepaliveRes uint16 = 0x0428 + + headerSize = 16 + discoBodySize = 72 + discoSize = headerSize + discoBodySize + sessionBody = 36 + sessionSize = headerSize + sessionBody +) + +const ( + cmdDiscoCC51 uint16 = 0x1002 + cmdKeepaliveCC51 uint16 = 0x1202 + cmdDTLSCC51 uint16 = 0x1502 + payloadSizeCC51 uint16 = 0x0028 + packetSizeCC51 = 52 + headerSizeCC51 = 28 + authSizeCC51 = 20 + keepaliveSizeCC51 = 48 +) + +const ( + magicAVLoginResp uint16 = 0x2100 + magicIOCtrl uint16 = 0x7000 + magicChannelMsg uint16 = 0x1000 + magicACK uint16 = 0x0009 + magicAVLogin1 uint16 = 0x0000 + magicAVLogin2 uint16 = 0x2000 +) + +const ( + protoVersion uint16 = 0x000c + defaultCaps uint32 = 0x001f07fb +) + +const ( + iotcChannelMain = 0 // Main AV (we = DTLS Client) + iotcChannelBack = 1 // Backchannel (we = DTLS Server) +) + +type DTLSConn struct { + conn *net.UDPConn + addr *net.UDPAddr + frames *tutk.FrameHandler + err error + verbose bool + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex + + // DTLS + clientConn *dtls.Conn + serverConn *dtls.Conn + clientBuf chan []byte + serverBuf chan []byte + rawCmd chan []byte + + // Identity + uid string + authKey string + enr string + psk []byte + + // Session + sid []byte + ticket uint16 + hasTwoWayStreaming bool + + // Protocol + isCC51 bool + seq uint16 + seqCmd uint16 + avSeq uint32 + kaSeq uint32 + audioSeq uint32 + audioFrameNo uint32 + + // Ack + ackFlags uint16 + rxSeqStart uint16 + rxSeqEnd uint16 + rxSeqInit bool + cmdAck func() +} + +func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*DTLSConn, error) { + udp, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + _ = udp.SetReadBuffer(2 * 1024 * 1024) + + ctx, cancel := context.WithCancel(context.Background()) + psk := DerivePSK(enr) + + if port == 0 { + port = 32761 + } + + c := &DTLSConn{ + conn: udp, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, + uid: uid, + authKey: authKey, + enr: enr, + psk: psk, + verbose: verbose, + ctx: ctx, + cancel: cancel, + rxSeqStart: 0xffff, + rxSeqEnd: 0xffff, + } + + if err = c.discovery(); err != nil { + _ = c.Close() + return nil, err + } + + c.clientBuf = make(chan []byte, 64) + c.serverBuf = make(chan []byte, 64) + c.rawCmd = make(chan []byte, 16) + c.frames = tutk.NewFrameHandler(c.verbose) + + c.wg.Add(1) + go c.reader() + + if err = c.connect(); err != nil { + _ = c.Close() + return nil, err + } + + c.wg.Add(1) + go c.worker() + + return c, nil +} + +func (c *DTLSConn) AVClientStart(timeout time.Duration) error { + randomID := tutk.GenSessionID() + pkt1 := c.msgAVLogin(magicAVLogin1, 570, 0x0001, randomID) + pkt2 := c.msgAVLogin(magicAVLogin2, 572, 0x0000, randomID) + pkt2[20]++ // pkt2 has randomID incremented by 1 + + if _, err := c.clientConn.Write(pkt1); err != nil { + return fmt.Errorf("av login 1 failed: %w", err) + } + + time.Sleep(10 * time.Millisecond) + + if _, err := c.clientConn.Write(pkt2); err != nil { + return fmt.Errorf("av login 2 failed: %w", err) + } + + // Wait for response + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case data, ok := <-c.rawCmd: + if !ok { + return io.EOF + } + if len(data) >= 32 && binary.LittleEndian.Uint16(data) == magicAVLoginResp { + c.hasTwoWayStreaming = data[31] == 1 + + ack := c.msgACK() + c.clientConn.Write(ack) + + // Start ACK sender for continuous streaming + c.wg.Add(1) + go func() { + defer c.wg.Done() + ackTicker := time.NewTicker(100 * time.Millisecond) + defer ackTicker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ackTicker.C: + if c.clientConn != nil { + ack := c.msgACK() + c.clientConn.Write(ack) + } + } + } + }() + + return nil + } + case <-timer.C: + return context.DeadlineExceeded + } + } +} + +func (c *DTLSConn) AVServStart() error { + conn, err := NewDTLSServer(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf, c.psk) + if err != nil { + return fmt.Errorf("dtls: server handshake failed: %w", err) + } + + if c.verbose { + fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack) + fmt.Printf("[SERVER] Waiting for AV Login request from camera...\n") + } + + // Wait for AV Login request from camera + buf := make([]byte, 1024) + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, err := conn.Read(buf) + if err != nil { + go conn.Close() + return fmt.Errorf("read av login: %w", err) + } + + if c.verbose { + fmt.Printf("[SERVER] AV Login request len=%d data:\n%s", n, hexDump(buf[:n])) + } + + if n < 24 { + go conn.Close() + return fmt.Errorf("av login too short: %d bytes", n) + } + + checksum := binary.LittleEndian.Uint32(buf[20:]) + resp := c.msgAVLoginResponse(checksum) + + if c.verbose { + fmt.Printf("[SERVER] Sending AV Login response: %d bytes\n", len(resp)) + } + + if _, err = conn.Write(resp); err != nil { + go conn.Close() + return fmt.Errorf("write av login response: %w", err) + } + + if c.verbose { + fmt.Printf("[SERVER] AV Login response sent, waiting for possible resend...\n") + } + + // Camera may resend, respond again + conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if n, _ = conn.Read(buf); n > 0 { + if c.verbose { + fmt.Printf("[SERVER] Received AV Login resend: %d bytes\n", n) + } + conn.Write(resp) + } + + conn.SetReadDeadline(time.Time{}) + + if c.verbose { + fmt.Printf("[SERVER] AV Login complete, ready for two way streaming\n") + } + + c.mu.Lock() + c.serverConn = conn + c.mu.Unlock() + + return nil +} + +func (c *DTLSConn) AVServStop() error { + c.mu.Lock() + serverConn := c.serverConn + c.serverConn = nil + + // Reset audio TX state + c.audioSeq = 0 + c.audioFrameNo = 0 + c.mu.Unlock() + + if serverConn == nil { + return nil + } + + go serverConn.Close() + + return nil +} + +func (c *DTLSConn) AVRecvFrameData() (*tutk.Packet, error) { + select { + case pkt, ok := <-c.frames.Recv(): + if !ok { + return nil, c.Error() + } + return pkt, nil + case <-c.ctx.Done(): + return nil, c.Error() + } +} + +func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { + c.mu.Lock() + conn := c.serverConn + if conn == nil { + c.mu.Unlock() + return fmt.Errorf("av server not ready") + } + + frame := c.msgAudioFrame(payload, timestampUS, codec, sampleRate, channels) + + c.mu.Unlock() + + n, err := conn.Write(frame) + if c.verbose { + if err != nil { + fmt.Printf("[SERVER TX] DTLS Write ERROR: %v\n", err) + } else { + fmt.Printf("[SERVER TX] len=%d, data:\n%s", n, hexDump(frame)) + } + } + return err +} + +func (c *DTLSConn) Write(data []byte) error { + if c.isCC51 { + _, err := c.conn.WriteToUDP(data, c.addr) + return err + } + _, err := c.conn.WriteToUDP(tutk.TransCodeBlob(data), c.addr) + return err +} + +func (c *DTLSConn) WriteDTLS(payload []byte, channel byte) error { + var frame []byte + if c.isCC51 { + frame = c.msgTxDataCC51(payload, channel) + } else { + frame = c.msgTxData(payload, channel) + } + + return c.Write(frame) +} + +func (c *DTLSConn) WriteIOCtrl(payload []byte) error { + _, err := c.conn.Write(c.msgIOCtrl(payload)) + return err +} + +func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + if err := c.Write(req); err == nil && t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + _ = c.conn.SetDeadline(time.Now().Add(5 * time.Second)) + defer c.conn.SetDeadline(time.Time{}) + + buf := make([]byte, 2048) + for { + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + return nil, err + } + if string(addr.IP) != string(c.addr.IP) || n < 16 { + continue + } + + var res []byte + if c.isCC51 { + res = buf[:n] + } else { + res = tutk.ReverseTransCodeBlob(buf[:n]) + } + + if ok(res) { + c.addr.Port = addr.Port + return res, nil + } + } +} + +func (c *DTLSConn) WriteAndWaitIOCtrl(payload []byte, match func([]byte) bool, timeout time.Duration) ([]byte, error) { + frame := c.msgIOCtrl(payload) + var t *time.Timer + t = time.AfterFunc(1, func() { + c.mu.RLock() + conn := c.clientConn + c.mu.RUnlock() + if conn != nil { + if _, err := conn.Write(frame); err == nil && t != nil { + t.Reset(time.Second) + } + } + }) + defer t.Stop() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case data, ok := <-c.rawCmd: + if !ok { + return nil, io.EOF + } + + ack := c.msgACK() + c.clientConn.Write(ack) + + if match(data) { + return data, nil + } + case <-timer.C: + return nil, fmt.Errorf("timeout waiting for response") + } + } +} + +func (c *DTLSConn) HasTwoWayStreaming() bool { + return c.hasTwoWayStreaming +} + +func (c *DTLSConn) IsBackchannelReady() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.serverConn != nil +} + +func (c *DTLSConn) RemoteAddr() *net.UDPAddr { + return c.addr +} + +func (c *DTLSConn) LocalAddr() *net.UDPAddr { + return c.conn.LocalAddr().(*net.UDPAddr) +} + +func (c *DTLSConn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *DTLSConn) Close() error { + c.cancel() + + c.mu.Lock() + if conn := c.serverConn; conn != nil { + c.serverConn = nil + go conn.Close() + } + if conn := c.clientConn; conn != nil { + c.clientConn = nil + go conn.Close() + } + if c.frames != nil { + c.frames.Close() + } + c.mu.Unlock() + + c.wg.Wait() + + return c.conn.Close() +} + +func (c *DTLSConn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *DTLSConn) discovery() error { + c.sid = tutk.GenSessionID() + + pktIOTC := tutk.TransCodeBlob(c.msgDisco(1)) + pktCC51 := c.msgDiscoCC51(0, 0, false) + + buf := make([]byte, 2048) + deadline := time.Now().Add(5 * time.Second) + + for time.Now().Before(deadline) { + c.conn.WriteToUDP(pktIOTC, c.addr) + c.conn.WriteToUDP(pktCC51, c.addr) + + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + continue + } + if !addr.IP.Equal(c.addr.IP) { + continue + } + + // CC51 protocol + if n >= packetSizeCC51 && string(buf[:2]) == magicCC51 { + if binary.LittleEndian.Uint16(buf[4:]) == cmdDiscoCC51 { + c.addr, c.isCC51, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) + if n >= 24 { + copy(c.sid, buf[16:24]) + } + return c.discoDoneCC51() + } + continue + } + + // IOTC Protocol (Basis) + data := tutk.ReverseTransCodeBlob(buf[:n]) + if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == cmdDiscoRes { + c.addr, c.isCC51 = addr, false + return c.discoDone() + } + } + + return fmt.Errorf("discovery timeout") +} + +func (c *DTLSConn) discoDone() error { + c.Write(c.msgDisco(2)) + time.Sleep(100 * time.Millisecond) + _, err := c.WriteAndWait(c.msgSession(), func(res []byte) bool { + return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == cmdSessionRes + }) + return err +} + +func (c *DTLSConn) discoDoneCC51() error { + _, err := c.WriteAndWait(c.msgDiscoCC51(2, c.ticket, false), func(res []byte) bool { + if len(res) < packetSizeCC51 || string(res[:2]) != magicCC51 { + return false + } + cmd := binary.LittleEndian.Uint16(res[4:]) + dir := binary.LittleEndian.Uint16(res[8:]) + seq := binary.LittleEndian.Uint16(res[12:]) + return cmd == cmdDiscoCC51 && dir == 0xFFFF && seq == 3 + }) + return err +} + +func (c *DTLSConn) connect() error { + conn, err := NewDTLSClient(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf, c.psk) + if err != nil { + return fmt.Errorf("dtls: client handshake failed: %w", err) + } + + c.mu.Lock() + c.clientConn = conn + c.mu.Unlock() + + if c.verbose { + fmt.Printf("[DTLS] Client handshake complete on channel %d\n", iotcChannelMain) + } + + return nil +} + +func (c *DTLSConn) worker() { + defer c.wg.Done() + + buf := make([]byte, 2048) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + n, err := c.clientConn.Read(buf) + if err != nil { + c.err = err + return + } + + if n < 2 { + continue + } + + data := buf[:n] + magic := binary.LittleEndian.Uint16(data) + + if c.verbose { + fmt.Printf("[DTLS RX] magic=0x%04x len=%d\n", magic, n) + } + + switch magic { + case magicAVLoginResp: + c.queue(c.rawCmd, data) + + case magicIOCtrl, magicChannelMsg: + c.queue(c.rawCmd, data) + + case protoVersion: + // Seq-Tracking + if len(data) >= 8 { + seq := binary.LittleEndian.Uint16(data[4:]) + if !c.rxSeqInit { + c.rxSeqInit = true + } + if seq > c.rxSeqEnd || c.rxSeqEnd == 0xffff { + c.rxSeqEnd = seq + } + } + c.queue(c.rawCmd, data) + + case magicACK: + c.mu.RLock() + ack := c.cmdAck + c.mu.RUnlock() + if ack != nil { + ack() + } + + default: + channel := data[0] + if channel == tutk.ChannelAudio || channel == tutk.ChannelIVideo || channel == tutk.ChannelPVideo { + c.frames.Handle(data) + } + } + } +} + +func (c *DTLSConn) reader() { + defer c.wg.Done() + + buf := make([]byte, 2048) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return + } + + 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 { + c.addr.Port = addr.Port + } + + // CC51 Protocol + if c.isCC51 && n >= 12 && string(buf[:2]) == magicCC51 { + cmd := binary.LittleEndian.Uint16(buf[4:]) + switch cmd { + case cmdKeepaliveCC51: + if n >= keepaliveSizeCC51 { + _ = c.Write(c.msgKeepaliveCC51()) + } + case cmdDTLSCC51: + if n >= headerSizeCC51+authSizeCC51 { + ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) + dtlsData := buf[headerSizeCC51 : n-authSizeCC51] + switch ch { + case iotcChannelMain: + c.queue(c.clientBuf, dtlsData) + case iotcChannelBack: + c.queue(c.serverBuf, dtlsData) + } + } + } + continue + } + + // IOTC Protocol (Basis) + data := tutk.ReverseTransCodeBlob(buf[:n]) + if len(data) < 16 { + continue + } + + switch binary.LittleEndian.Uint16(data[8:]) { + case cmdKeepaliveRes: + if len(data) > 24 { + _ = c.Write(c.msgKeepalive(data[16:])) + } + case cmdDataRX: + if len(data) > 28 { + ch := data[14] + switch ch { + case iotcChannelMain: + c.queue(c.clientBuf, data[28:]) + case iotcChannelBack: + c.queue(c.serverBuf, data[28:]) + } + } + } + } +} + +func (c *DTLSConn) queue(ch chan []byte, data []byte) { + b := make([]byte, len(data)) + copy(b, data) + select { + case ch <- b: + default: + select { + case <-ch: + default: + } + ch <- b + } +} + +func (c *DTLSConn) msgDisco(stage byte) []byte { + b := make([]byte, discoSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], discoBodySize) // body size + binary.LittleEndian.PutUint16(b[8:], cmdDiscoReq) // 0x0601 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[36:], sdkVersion42) // SDK 4.2.1.1 + copy(body[40:], c.sid) + body[48] = stage + if stage == 1 && len(c.authKey) > 0 { + copy(body[58:], c.authKey) + } + return b +} + +func (c *DTLSConn) msgDiscoCC51(seq, ticket uint16, isResponse bool) []byte { + b := make([]byte, packetSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDiscoCC51) // 0x1002 + binary.LittleEndian.PutUint16(b[6:], payloadSizeCC51) // 40 bytes + if isResponse { + binary.LittleEndian.PutUint16(b[8:], 0xFFFF) // response + } + binary.LittleEndian.PutUint16(b[12:], seq) + binary.LittleEndian.PutUint16(b[14:], ticket) + copy(b[16:24], c.sid) + copy(b[24:28], sdkVersion43) // SDK 4.3.8.0 + b[28] = 0x1d // unknown field (capability/build flag?) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:32]) + copy(b[32:52], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgKeepaliveCC51() []byte { + c.kaSeq += 2 + b := make([]byte, keepaliveSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdKeepaliveCC51) // 0x1202 + binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload + binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter + copy(b[20:28], c.sid) // session ID + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:28]) + copy(b[28:48], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgSession() []byte { + b := make([]byte, sessionSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], sessionBody) // body size + binary.LittleEndian.PutUint16(b[8:], cmdSessionReq) // 0x0402 + binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[20:], c.sid) + binary.LittleEndian.PutUint32(body[32:], uint32(time.Now().Unix())) + return b +} + +func (c *DTLSConn) msgAVLogin(magic uint16, size int, flags uint16, randomID []byte) []byte { + b := make([]byte, size) + binary.LittleEndian.PutUint16(b, magic) + binary.LittleEndian.PutUint16(b[2:], protoVersion) + binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size + binary.LittleEndian.PutUint16(b[18:], flags) + copy(b[20:], randomID[:4]) + copy(b[24:], "admin") // username + copy(b[280:], c.enr) // password/ENR + binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? + binary.LittleEndian.PutUint32(b[552:], defaultCaps) // capabilities + return b +} + +func (c *DTLSConn) msgAVLoginResponse(checksum uint32) []byte { + b := make([]byte, 60) + binary.LittleEndian.PutUint16(b, 0x2100) // magic + binary.LittleEndian.PutUint16(b[2:], 0x000c) // version + b[4] = 0x10 // success + binary.LittleEndian.PutUint32(b[16:], 0x24) // payload size + binary.LittleEndian.PutUint32(b[20:], checksum) // echo checksum + b[29] = 0x01 // enable flag + b[31] = 0x01 // two-way streaming + binary.LittleEndian.PutUint32(b[36:], 0x04) // buffer config + binary.LittleEndian.PutUint32(b[40:], defaultCaps) + binary.LittleEndian.PutUint16(b[54:], 0x0003) // channel info + binary.LittleEndian.PutUint16(b[56:], 0x0002) + return b +} + +func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, sampleRate uint32, channels uint8) []byte { + c.audioSeq++ + c.audioFrameNo++ + prevFrame := uint32(0) + if c.audioFrameNo > 1 { + prevFrame = c.audioFrameNo - 1 + } + + totalPayload := len(payload) + 16 // payload + frameinfo + b := make([]byte, 36+totalPayload) + + // Outer header (36 bytes) + b[0] = tutk.ChannelAudio // 0x03 + b[1] = tutk.FrameTypeStartAlt // 0x09 + binary.LittleEndian.PutUint16(b[2:], protoVersion) + binary.LittleEndian.PutUint32(b[4:], c.audioSeq) + binary.LittleEndian.PutUint32(b[8:], timestampUS) + if c.audioFrameNo == 1 { + binary.LittleEndian.PutUint32(b[12:], 0x00000001) + } else { + binary.LittleEndian.PutUint32(b[12:], 0x00100001) + } + + // Inner header + b[16] = tutk.ChannelAudio + b[17] = tutk.FrameTypeEndSingle + binary.LittleEndian.PutUint16(b[18:], uint16(prevFrame)) + binary.LittleEndian.PutUint16(b[20:], 0x0001) // pkt_total + binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags + binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload)) + binary.LittleEndian.PutUint32(b[28:], prevFrame) + binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo) + copy(b[36:], payload) // Payload + FrameInfo + fi := b[36+len(payload):] + fi[0] = codec // Codec ID (low byte) + fi[1] = 0 // Codec ID (high byte, unused) + // Audio flags: [3:2]=sampleRateIdx [1]=16bit [0]=stereo + srIdx := tutk.GetSampleRateIndex(sampleRate) + fi[2] = (srIdx << 2) | 0x02 // 16-bit always set + if channels == 2 { + fi[2] |= 0x01 + } + fi[4] = 1 // online + binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*tutk.GetSamplesPerFrame(codec)*1000/sampleRate) + return b +} + +func (c *DTLSConn) msgTxData(payload []byte, channel byte) []byte { + bodySize := 12 + len(payload) + b := make([]byte, 16+bodySize) + copy(b, "\x04\x02\x1a\x0b") // marker + mode=data + binary.LittleEndian.PutUint16(b[4:], uint16(bodySize)) // body size + binary.LittleEndian.PutUint16(b[6:], c.seq) // sequence + c.seq++ + binary.LittleEndian.PutUint16(b[8:], cmdDataTX) // 0x0407 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + copy(b[12:], c.sid[:2]) // rid[0:2] + b[14] = channel // channel + b[15] = 0x01 // marker + binary.LittleEndian.PutUint32(b[16:], 0x0000000c) // const + copy(b[20:], c.sid[:8]) // rid + copy(b[28:], payload) + return b +} + +func (c *DTLSConn) msgTxDataCC51(payload []byte, channel byte) []byte { + payloadSize := uint16(16 + len(payload) + authSizeCC51) + b := make([]byte, headerSizeCC51+len(payload)+authSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDTLSCC51) // 0x1502 + binary.LittleEndian.PutUint16(b[6:], payloadSize) + binary.LittleEndian.PutUint16(b[12:], uint16(0x0010)|(uint16(channel)<<8)) // channel in high byte + binary.LittleEndian.PutUint16(b[14:], c.ticket) + copy(b[16:24], c.sid) + binary.LittleEndian.PutUint32(b[24:], 1) // const + copy(b[headerSizeCC51:], payload) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:headerSizeCC51]) + copy(b[headerSizeCC51+len(payload):], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgACK() []byte { + c.ackFlags++ + b := make([]byte, 24) + binary.LittleEndian.PutUint16(b[0:], magicACK) // 0x0009 + binary.LittleEndian.PutUint16(b[2:], protoVersion) // 0x000c + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // TX seq + c.avSeq++ + binary.LittleEndian.PutUint16(b[8:], c.rxSeqStart) // RX start (last acked) + binary.LittleEndian.PutUint16(b[10:], c.rxSeqEnd) // RX end (highest received) + if c.rxSeqInit { + c.rxSeqStart = c.rxSeqEnd + } + binary.LittleEndian.PutUint16(b[12:], c.ackFlags) // AckFlags + binary.LittleEndian.PutUint32(b[16:], uint32(c.ackFlags)<<16) // AckCounter + ts := uint32(time.Now().UnixMilli() & 0xFFFF) + binary.LittleEndian.PutUint16(b[20:], uint16(ts)) // Timestamp + return b +} + +func (c *DTLSConn) msgKeepalive(incoming []byte) []byte { + b := make([]byte, 24) + copy(b, "\x04\x02\x1a\x0a") // marker + mode + binary.LittleEndian.PutUint16(b[4:], 8) // body size + binary.LittleEndian.PutUint16(b[8:], cmdKeepaliveReq) // 0x0427 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + if len(incoming) >= 8 { + copy(b[16:], incoming[:8]) // echo payload + } + return b +} + +func (c *DTLSConn) msgIOCtrl(payload []byte) []byte { + b := make([]byte, 40+len(payload)) + binary.LittleEndian.PutUint16(b, protoVersion) // magic + binary.LittleEndian.PutUint16(b[2:], protoVersion) // version + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // av seq + c.avSeq++ + binary.LittleEndian.PutUint16(b[16:], magicIOCtrl) // 0x7000 + binary.LittleEndian.PutUint16(b[18:], c.seqCmd) // sub channel + binary.LittleEndian.PutUint32(b[20:], 1) // ioctl seq + binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)+4)) // payload size + binary.LittleEndian.PutUint32(b[28:], uint32(c.seqCmd)) // flag + b[37] = 0x01 + copy(b[40:], payload) + c.seqCmd++ + return b +} + +func 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/tutk/dtls/dtls.go b/pkg/tutk/dtls/dtls.go new file mode 100644 index 00000000..3b0573ae --- /dev/null +++ b/pkg/tutk/dtls/dtls.go @@ -0,0 +1,146 @@ +package dtls + +import ( + "context" + "net" + "sync" + "time" + + "github.com/pion/dtls/v3" +) + +func NewDTLSClient(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, false) +} + +func NewDTLSServer(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, true) +} + +func dialDTLS(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte, isServer bool) (*dtls.Conn, error) { + adapter := &channelAdapter{ + ctx: ctx, + channel: channel, + addr: addr, + writeFn: writeFn, + readChan: readChan, + } + + var conn *dtls.Conn + var err error + + if isServer { + conn, err = dtls.Server(adapter, addr, buildDTLSConfig(psk, true)) + } else { + conn, err = dtls.Client(adapter, addr, buildDTLSConfig(psk, false)) + } + if err != nil { + return nil, err + } + + timeout := 5 * time.Second + adapter.SetReadDeadline(time.Now().Add(timeout)) + hsCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := conn.HandshakeContext(hsCtx); err != nil { + go conn.Close() + return nil, err + } + + adapter.SetReadDeadline(time.Time{}) + return conn, nil +} + +func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config { + config := &dtls.Config{ + PSK: func(hint []byte) ([]byte, error) { + return psk, nil + }, + PSKIdentityHint: []byte("AUTHPWD_admin"), + InsecureSkipVerify: true, + InsecureSkipVerifyHello: true, + MTU: 1200, + FlightInterval: 300 * time.Millisecond, + ExtendedMasterSecret: dtls.DisableExtendedMasterSecret, + } + + if isServer { + config.CipherSuites = []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_CBC_SHA256} + } else { + config.CustomCipherSuites = CustomCipherSuites + } + + return config +} + +type channelAdapter struct { + ctx context.Context + channel uint8 + writeFn func([]byte, uint8) error + readChan chan []byte + addr net.Addr + mu sync.Mutex + readDeadline time.Time +} + +func (a *channelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + a.mu.Lock() + deadline := a.readDeadline + a.mu.Unlock() + + if !deadline.IsZero() { + timeout := time.Until(deadline) + if timeout <= 0 { + return 0, nil, &timeoutError{} + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-timer.C: + return 0, nil, &timeoutError{} + case <-a.ctx.Done(): + return 0, nil, net.ErrClosed + } + } + + select { + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-a.ctx.Done(): + return 0, nil, net.ErrClosed + } +} + +func (a *channelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { + if err := a.writeFn(p, a.channel); err != nil { + return 0, err + } + return len(p), nil +} + +func (a *channelAdapter) Close() error { return nil } +func (a *channelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } +func (a *channelAdapter) SetDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} +func (a *channelAdapter) SetReadDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} +func (a *channelAdapter) SetWriteDeadline(time.Time) error { return nil } + +type timeoutError struct{} + +func (e *timeoutError) Error() string { return "i/o timeout" } +func (e *timeoutError) Timeout() bool { return true } +func (e *timeoutError) Temporary() bool { return true } diff --git a/pkg/tutk/frame.go b/pkg/tutk/frame.go new file mode 100644 index 00000000..db5bf074 --- /dev/null +++ b/pkg/tutk/frame.go @@ -0,0 +1,571 @@ +package tutk + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/aac" +) + +const ( + FrameTypeStart uint8 = 0x08 // Extended start (36-byte header) + FrameTypeStartAlt uint8 = 0x09 // StartAlt (36-byte header) + FrameTypeCont uint8 = 0x00 // Continuation (28-byte header) + FrameTypeContAlt uint8 = 0x04 // Continuation alt + FrameTypeEndSingle uint8 = 0x01 // Single-packet frame (28-byte) + FrameTypeEndMulti uint8 = 0x05 // Multi-packet end (28-byte) + FrameTypeEndExt uint8 = 0x0d // Extended end (36-byte) +) + +const ( + ChannelIVideo uint8 = 0x05 + ChannelAudio uint8 = 0x03 + ChannelPVideo uint8 = 0x07 +) + +const frameInfoSize = 40 + +// FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet) +// Video: 40 bytes, Audio: 16 bytes (uses same struct, fields 16+ are zero) +// +// Offset Size Field +// 0-1 2 CodecID - 0x4E=H264, 0x7B=H265, 0x90=AAC_WYZE +// 2 1 Flags - Video: 1=Keyframe, 0=P-frame | Audio: sample rate/bits/channels +// 3 1 CamIndex - Camera index +// 4 1 OnlineNum - Online number +// 5 1 FPS - Framerate (e.g. 20) +// 6 1 ResTier - Video: 1=Low(360P), 4=High(HD/2K) | Audio: 0 +// 7 1 Bitrate - Video: 30=360P, 100=HD, 200=2K | Audio: 1 +// 8-11 4 Timestamp - Timestamp (increases ~50000/frame for 20fps video) +// 12-15 4 SessionID - Session marker (constant per stream) +// 16-19 4 PayloadSize - Frame payload size in bytes +// 20-23 4 FrameNo - Global frame number +// 24-35 12 DeviceID - MAC address (ASCII) - video only +// 36-39 4 Padding - Always 0 - video only +type FrameInfo struct { + CodecID byte // 0 (only low byte used) + Flags uint8 // 2 + CamIndex uint8 // 3 + OnlineNum uint8 // 4 + FPS uint8 // 5: Framerate + ResTier uint8 // 6: Resolution tier (1=Low, 4=High) + Bitrate uint8 // 7: Bitrate index (30=360P, 100=HD, 200=2K) + Timestamp uint32 // 8-11: Timestamp + SessionID uint32 // 12-15: Session marker (constant) + PayloadSize uint32 // 16-19: Payload size + FrameNo uint32 // 20-23: Frame number +} + +func (fi *FrameInfo) IsKeyframe() bool { + return fi.Flags == 0x01 +} + +func (fi *FrameInfo) SampleRate() uint32 { + idx := (fi.Flags >> 2) & 0x0F + if idx < uint8(len(sampleRates)) { + return sampleRates[idx] + } + return 16000 +} + +func (fi *FrameInfo) Channels() uint8 { + if fi.Flags&0x01 == 1 { + return 2 + } + return 1 +} + +func ParseFrameInfo(data []byte) *FrameInfo { + if len(data) < frameInfoSize { + return nil + } + + offset := len(data) - frameInfoSize + fi := data[offset:] + + return &FrameInfo{ + CodecID: fi[0], + Flags: fi[2], + CamIndex: fi[3], + OnlineNum: fi[4], + FPS: fi[5], + ResTier: fi[6], + Bitrate: fi[7], + Timestamp: binary.LittleEndian.Uint32(fi[8:]), + SessionID: binary.LittleEndian.Uint32(fi[12:]), + PayloadSize: binary.LittleEndian.Uint32(fi[16:]), + FrameNo: binary.LittleEndian.Uint32(fi[20:]), + } +} + +type Packet struct { + Channel uint8 + Codec byte + Timestamp uint32 + Payload []byte + IsKeyframe bool + FrameNo uint32 + SampleRate uint32 + Channels uint8 +} + +type PacketHeader struct { + Channel byte + FrameType byte + HeaderSize int + FrameNo uint32 + PktIdx uint16 + PktTotal uint16 + PayloadSize uint16 + HasFrameInfo bool +} + +func ParsePacketHeader(data []byte) *PacketHeader { + if len(data) < 28 { + return nil + } + + frameType := data[1] + hdr := &PacketHeader{ + Channel: data[0], + FrameType: frameType, + } + + switch frameType { + case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt: + hdr.HeaderSize = 36 + default: + hdr.HeaderSize = 28 + } + + if len(data) < hdr.HeaderSize { + return nil + } + + if hdr.HeaderSize == 28 { + hdr.PktTotal = binary.LittleEndian.Uint16(data[12:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[14:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[24:]) + + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } else { + hdr.PktTotal = binary.LittleEndian.Uint16(data[20:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[22:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[32:]) + + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } + + return hdr +} + +func IsStartFrame(frameType uint8) bool { + return frameType == FrameTypeStart || frameType == FrameTypeStartAlt +} + +func IsEndFrame(frameType uint8) bool { + return frameType == FrameTypeEndSingle || + frameType == FrameTypeEndMulti || + frameType == FrameTypeEndExt +} + +func IsContinuationFrame(frameType uint8) bool { + return frameType == FrameTypeCont || frameType == FrameTypeContAlt +} + +type channelState struct { + frameNo uint32 // current frame being assembled + pktTotal uint16 // expected total packets + waitSeq uint16 // next expected packet index (0, 1, 2, ...) + waitData []byte // accumulated payload data + frameInfo *FrameInfo // frame info (from end packet) + hasStarted bool // received first packet of frame + lastPktIdx uint16 // last received packet index (for OOO detection) +} + +func (cs *channelState) reset() { + cs.frameNo = 0 + cs.pktTotal = 0 + cs.waitSeq = 0 + cs.waitData = cs.waitData[:0] + cs.frameInfo = nil + cs.hasStarted = false + cs.lastPktIdx = 0 +} + +const tsWrapPeriod uint32 = 1000000 + +type tsTracker struct { + lastRawTS uint32 + accumUS uint64 + firstTS bool +} + +func (t *tsTracker) update(rawTS uint32) uint64 { + if !t.firstTS { + t.firstTS = true + t.lastRawTS = rawTS + return 0 + } + + var delta uint32 + if rawTS >= t.lastRawTS { + delta = rawTS - t.lastRawTS + } else { + // Wrapped: delta = (wrap - last) + new + delta = (tsWrapPeriod - t.lastRawTS) + rawTS + } + + t.accumUS += uint64(delta) + t.lastRawTS = rawTS + + return t.accumUS +} + +type FrameHandler struct { + channels map[byte]*channelState + videoTS tsTracker + audioTS tsTracker + output chan *Packet + verbose bool + closed bool + closeMu sync.Mutex +} + +func NewFrameHandler(verbose bool) *FrameHandler { + return &FrameHandler{ + channels: make(map[byte]*channelState), + output: make(chan *Packet, 128), + verbose: verbose, + } +} + +func (h *FrameHandler) Recv() <-chan *Packet { + return h.output +} + +func (h *FrameHandler) Close() { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + h.closed = true + close(h.output) +} + +func (h *FrameHandler) Handle(data []byte) { + hdr := ParsePacketHeader(data) + if hdr == nil { + return + } + + payload, fi := h.extractPayload(data, hdr.Channel) + if payload == nil { + return + } + + if h.verbose { + fiStr := "" + if hdr.HasFrameInfo { + fiStr = " +FI" + } + fmt.Printf("[RX] ch=0x%02x type=0x%02x #%d pkt=%d/%d data=%dB%s\n", + hdr.Channel, hdr.FrameType, + hdr.FrameNo, hdr.PktIdx, hdr.PktTotal, len(payload), fiStr) + } + + switch hdr.Channel { + case ChannelAudio: + h.handleAudio(payload, fi) + case ChannelIVideo, ChannelPVideo: + h.handleVideo(hdr.Channel, hdr, payload, fi) + } +} + +func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { + if len(data) < 2 { + return nil, nil + } + + frameType := data[1] + + headerSize := 28 + fiSize := 0 + + switch frameType { + case FrameTypeStart: + headerSize = 36 + case FrameTypeStartAlt: + headerSize = 36 + if len(data) >= 22 { + pktTotal := binary.LittleEndian.Uint16(data[20:]) + if pktTotal == 1 { + fiSize = frameInfoSize + } + } + case FrameTypeCont, FrameTypeContAlt: + headerSize = 28 + case FrameTypeEndSingle, FrameTypeEndMulti: + headerSize = 28 + fiSize = frameInfoSize + case FrameTypeEndExt: + headerSize = 36 + fiSize = frameInfoSize + default: + headerSize = 28 + } + + if len(data) < headerSize { + return nil, nil + } + + if fiSize == 0 { + return data[headerSize:], nil + } + + if len(data) < headerSize+fiSize { + return data[headerSize:], nil + } + + fi := ParseFrameInfo(data) + + validCodec := false + switch channel { + case ChannelIVideo, ChannelPVideo: + validCodec = IsVideoCodec(fi.CodecID) + case ChannelAudio: + validCodec = IsAudioCodec(fi.CodecID) + } + + if validCodec { + payload := data[headerSize : len(data)-fiSize] + return payload, fi + } + + return data[headerSize:], nil +} + +func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) { + cs := h.channels[channel] + if cs == nil { + cs = &channelState{} + h.channels[channel] = cs + } + + // New frame number - reset and start fresh + if hdr.FrameNo != cs.frameNo { + // Check if previous frame was incomplete + if cs.hasStarted && cs.waitSeq < cs.pktTotal { + fmt.Printf("[DROP] ch=0x%02x #%d INCOMPLETE: got %d/%d pkts\n", + channel, cs.frameNo, cs.waitSeq, cs.pktTotal) + } + cs.reset() + cs.frameNo = hdr.FrameNo + cs.pktTotal = hdr.PktTotal + } + + // If packet index doesn't match expected, reset (data loss) + if hdr.PktIdx != cs.waitSeq { + fmt.Printf("[OOO] ch=0x%02x #%d frameType=0x%02x pktTotal=%d expected pkt %d, got %d - reset\n", + channel, hdr.FrameNo, hdr.FrameType, hdr.PktTotal, cs.waitSeq, hdr.PktIdx) + cs.reset() + return + } + + // First packet - mark as started + if cs.waitSeq == 0 { + cs.hasStarted = true + } + + cs.waitData = append(cs.waitData, payload...) + cs.waitSeq++ + + // Store frame info if present + if fi != nil { + cs.frameInfo = fi + } + + // Check if frame is complete + if cs.waitSeq != cs.pktTotal || cs.frameInfo == nil { + return + } + + fi = cs.frameInfo + defer cs.reset() + + if fi.PayloadSize > 0 && uint32(len(cs.waitData)) != fi.PayloadSize { + fmt.Printf("[SIZE] ch=0x%02x #%d mismatch: expected %d, got %d\n", + channel, cs.frameNo, fi.PayloadSize, len(cs.waitData)) + return + } + + if len(cs.waitData) == 0 { + return + } + + accumUS := h.videoTS.update(fi.Timestamp) + rtpTS := uint32(accumUS * 90000 / 1000000) + + pkt := &Packet{ + Channel: channel, + Payload: append([]byte{}, cs.waitData...), + Codec: fi.CodecID, + Timestamp: rtpTS, + IsKeyframe: fi.IsKeyframe(), + FrameNo: fi.FrameNo, + } + + if h.verbose { + frameType := "P" + if fi.IsKeyframe() { + frameType = "KEY" + } + fmt.Printf("[OK] ch=0x%02x #%d codec=0x%02x %s size=%d\n", + channel, fi.FrameNo, fi.CodecID, frameType, len(pkt.Payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x [3]=%d [4]=%d\n", + fi.CodecID, fi.Flags, fi.CamIndex, fi.OnlineNum) + fmt.Printf(" [5]=%d [6]=%d [7]=%d [8-11]ts=%d\n", + fi.FPS, fi.ResTier, fi.Bitrate, fi.Timestamp) + fmt.Printf(" [12-15]=0x%x [16-19]payload=%d [20-23]frameNo=%d\n", + fi.SessionID, fi.PayloadSize, fi.FrameNo) + fmt.Printf(" rtp_ts=%d accum_us=%d\n", rtpTS, accumUS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) + } + + h.queue(pkt) +} + +func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { + if len(payload) == 0 || fi == nil { + return + } + + var sampleRate uint32 + var channels uint8 + + switch fi.CodecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + sampleRate, channels = parseAudioParams(payload, fi) + default: + sampleRate = fi.SampleRate() + channels = fi.Channels() + } + + accumUS := h.audioTS.update(fi.Timestamp) + rtpTS := uint32(accumUS * uint64(sampleRate) / 1000000) + + payloadCopy := make([]byte, len(payload)) + copy(payloadCopy, payload) + + pkt := &Packet{ + Channel: ChannelAudio, + Payload: payloadCopy, + Codec: fi.CodecID, + Timestamp: rtpTS, + SampleRate: sampleRate, + Channels: channels, + FrameNo: fi.FrameNo, + } + + if h.verbose { + bits := 8 + if fi.Flags&0x02 != 0 { + bits = 16 + } + fmt.Printf("[OK] Audio #%d codec=0x%02x size=%d\n", + fi.FrameNo, fi.CodecID, len(payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x(%dHz/%dbit/%dch)\n", + fi.CodecID, fi.Flags, sampleRate, bits, channels) + fmt.Printf(" [8-11]ts=%d [12-15]=0x%x rtp_ts=%d\n", + fi.Timestamp, fi.SessionID, rtpTS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) + } + + h.queue(pkt) +} + +func (h *FrameHandler) queue(pkt *Packet) { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + + select { + case h.output <- pkt: + default: + // Queue full - drop oldest + select { + case <-h.output: + default: + } + select { + case h.output <- pkt: + default: + // Queue still full, drop this packet + } + } +} + +func parseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { + if aac.IsADTS(payload) { + codec := aac.ADTSToCodec(payload) + if codec != nil { + return codec.ClockRate, codec.Channels + } + } + + if fi != nil { + return fi.SampleRate(), fi.Channels() + } + + return 16000, 1 +} + +func dumpHex(fi *FrameInfo) string { + b := make([]byte, frameInfoSize) + b[0] = fi.CodecID + b[1] = 0 // High byte (unused) + b[2] = fi.Flags + b[3] = fi.CamIndex + b[4] = fi.OnlineNum + b[5] = fi.FPS + b[6] = fi.ResTier + b[7] = fi.Bitrate + binary.LittleEndian.PutUint32(b[8:], fi.Timestamp) + binary.LittleEndian.PutUint32(b[12:], fi.SessionID) + binary.LittleEndian.PutUint32(b[16:], fi.PayloadSize) + binary.LittleEndian.PutUint32(b[20:], fi.FrameNo) + // Bytes 24-39 are DeviceID and Padding (not stored in struct) + + hexStr := hex.EncodeToString(b) + formatted := "" + for i := 0; i < len(hexStr); i += 2 { + if i > 0 { + formatted += " " + } + formatted += hexStr[i : i+2] + } + return formatted +} diff --git a/pkg/tutk/helpers.go b/pkg/tutk/helpers.go index 118119be..93bf4b5a 100644 --- a/pkg/tutk/helpers.go +++ b/pkg/tutk/helpers.go @@ -1,16 +1,16 @@ package tutk -import "encoding/binary" - -// https://github.com/seydx/tutk_wyze#11-codec-reference -const ( - CodecH264 = 0x4e - CodecH265 = 0x50 - CodecPCMA = 0x8a - CodecPCML = 0x8c - CodecAAC = 0x88 +import ( + "encoding/binary" + "time" ) +func GenSessionID() []byte { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) + return b +} + func ICAM(cmd uint32, args ...byte) []byte { // 0 4943414d ICAM // 4 d807ff00 command @@ -26,3 +26,46 @@ func ICAM(cmd uint32, args ...byte) []byte { copy(b[23:], args) return b } + +func HL(cmdID uint16, payload []byte) []byte { + // 0-1 "HL" magic + // 2 version (typically 5) + // 3 reserved + // 4-5 cmdID command ID (uint16 LE) + // 6-7 payloadLen payload length (uint16 LE) + // 8-15 reserved + // 16+ payload + const headerSize = 16 + const version = 5 + + b := make([]byte, headerSize+len(payload)) + copy(b, "HL") + b[2] = version + binary.LittleEndian.PutUint16(b[4:], cmdID) + binary.LittleEndian.PutUint16(b[6:], uint16(len(payload))) + copy(b[headerSize:], payload) + return b +} + +func ParseHL(data []byte) (cmdID uint16, payload []byte, ok bool) { + if len(data) < 16 || data[0] != 'H' || data[1] != 'L' { + return 0, nil, false + } + cmdID = binary.LittleEndian.Uint16(data[4:]) + payloadLen := binary.LittleEndian.Uint16(data[6:]) + if len(data) >= 16+int(payloadLen) { + payload = data[16 : 16+payloadLen] + } else if len(data) > 16 { + payload = data[16:] + } + return cmdID, payload, true +} + +func FindHL(data []byte, offset int) []byte { + for i := offset; i+16 <= len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + return data[i:] + } + } + return nil +} diff --git a/pkg/tutk/session0.go b/pkg/tutk/session0.go index 1f1bbc7e..6a1b2253 100644 --- a/pkg/tutk/session0.go +++ b/pkg/tutk/session0.go @@ -155,9 +155,3 @@ func ConnectByUID(stage byte, uid string, sid8 []byte) []byte { return b } - -func GenSessionID() []byte { - b := make([]byte, 8) - binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) - return b -} diff --git a/pkg/wyze/backchannel.go b/pkg/wyze/backchannel.go new file mode 100644 index 00000000..37472c10 --- /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/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..0fe878ee --- /dev/null +++ b/pkg/wyze/client.go @@ -0,0 +1,618 @@ +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) +} diff --git a/pkg/wyze/cloud.go b/pkg/wyze/cloud.go new file mode 100644 index 00000000..17f914a0 --- /dev/null +++ b/pkg/wyze/cloud.go @@ -0,0 +1,337 @@ +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 + phoneID 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"` +} + +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"` +} + +type p2pInfoResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` +} + +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"` +} + +func NewCloud(apiKey, keyID string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 30 * time.Second}, + phoneID: generatePhoneID(), + apiKey: apiKey, + keyID: keyID, + } +} + +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") + req.Header.Set("Apikey", c.apiKey) + req.Header.Set("Keyid", c.keyID) + req.Header.Set("User-Agent", "go2rtc") + + resp, err := c.client.Do(req) + if err != nil { + 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 + + return nil +} + +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 + } + if dev.DeviceParams.IP == "" { + continue // skip cameras without IP (gwell protocol) + } + + 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) +} + +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 apiError struct { + Code string `json:"code"` + ErrorCode int `json:"errorCode"` + Msg string `json:"msg"` + Description string `json:"description"` +} + +func (e *apiError) hasError() bool { + if e.Code == "1" || e.Code == "0" { + return false + } + if e.Code == "" && e.ErrorCode == 0 { + return false + } + return e.Code != "" || e.ErrorCode != 0 +} + +func (e *apiError) message() string { + if e.Msg != "" { + return e.Msg + } + return e.Description +} + +func (e *apiError) code() string { + if e.Code != "" { + return e.Code + } + return fmt.Sprintf("%d", e.ErrorCode) +} + +type AuthError struct { + Message string `json:"message"` + NeedsMFA bool `json:"needs_mfa,omitempty"` + MFAType string `json:"mfa_type,omitempty"` +} + +func (e *AuthError) Error() string { + return e.Message +} + +func generatePhoneID() string { + return core.RandString(16, 16) // 16 hex chars +} + +func hashPassword(password string) string { + encoded := strings.TrimSpace(password) + if strings.HasPrefix(strings.ToLower(encoded), "md5:") { + return encoded[4:] + } + for range 3 { + hash := md5.Sum([]byte(encoded)) + encoded = hex.EncodeToString(hash[:]) + } + return encoded +} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go new file mode 100644 index 00000000..16219c44 --- /dev/null +++ b/pkg/wyze/producer.go @@ -0,0 +1,277 @@ +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/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() + + // 0 = HD (default), 1 = SD/360P, 2 = 720P, 3 = 2K, 4 = Floodlight + var quality byte + switch s := query.Get("subtype"); s { + case "", "hd": + quality = 0 + case "sd": + quality = FrameSize360P + default: + quality = core.ParseByte(s) + } + + medias, err := probe(client, quality) + 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 { + for { + if p.client.verbose { + fmt.Println("[Wyze] Reading packet...") + } + + _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) + pkt, err := p.client.ReadPacket() + if err != nil { + 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.CodecPCMU: + name = core.CodecPCMU + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecPCMA: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecAACADTS, tutk.CodecAACAlt, tutk.CodecAACRaw, tutk.CodecAACLATM: + 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.CodecOpus: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecPCML: + name = core.CodecPCML + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecMP3: + name = core.CodecMP3 + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecMJPEG: + name = core.CodecJPEG + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + default: + continue + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +func probe(client *Client, quality byte) ([]*core.Media, error) { + client.SetResolution(quality) + client.SetDeadline(time.Now().Add(core.ProbeTimeout)) + + var vcodec, acodec *core.Codec + var tutkAudioCodec byte + + for { + if client.verbose { + fmt.Println("[Wyze] Probing for codecs...") + } + + pkt, err := client.ReadPacket() + if err != nil { + return nil, fmt.Errorf("wyze: probe: %w", err) + } + 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.CodecPCMU: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecAACAlt, tutk.CodecAACADTS, tutk.CodecAACRaw, tutk.CodecAACLATM: + if acodec == nil { + config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false) + acodec = aac.ConfigToCodec(config) + tutkAudioCodec = pkt.Codec + } + case tutk.CodecOpus: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecPCML: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCML, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecMP3: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecMP3, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecMJPEG: + if vcodec == nil { + vcodec = &core.Codec{Name: core.CodecJPEG, ClockRate: 90000, PayloadType: core.PayloadTypeRAW} + } + } + + if vcodec != nil && (acodec != nil || !client.SupportsAudio()) { + 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/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index a35592d4..242fda3d 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -107,7 +107,7 @@ func (c *Client) ReadPacket() (hdr, payload []byte, err error) { switch hdr[0] { case tutk.CodecH264, tutk.CodecH265: payload, err = DecodeVideo(payload, c.key) - case tutk.CodecAAC: + case tutk.CodecAACLATM: payload, err = crypto.Decode(payload, c.key) } } diff --git a/pkg/xiaomi/legacy/producer.go b/pkg/xiaomi/legacy/producer.go index 5c1f795d..92375faf 100644 --- a/pkg/xiaomi/legacy/producer.go +++ b/pkg/xiaomi/legacy/producer.go @@ -98,7 +98,7 @@ func probe(client *Client) ([]*core.Media, error) { if acodec == nil { acodec = &core.Codec{Name: core.CodecPCML, ClockRate: 8000} } - case tutk.CodecAAC: + case tutk.CodecAACLATM: if acodec == nil { acodec = aac.ADTSToCodec(payload) if acodec != nil { @@ -187,7 +187,7 @@ func (c *Producer) Start() error { audioTS += uint32(n / 2) // because 16bit } - case tutk.CodecAAC: + case tutk.CodecAACLATM: pkt = &core.Packet{ Header: rtp.Header{ SequenceNumber: audioSeq, 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..953fdae6 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -249,7 +249,22 @@ export class VideoRTC extends HTMLElement { this.appendChild(this.video); this.video.addEventListener('error', ev => { - console.warn(ev); + const err = this.video.error; + // https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code + const MEDIA_ERRORS = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED' + }; + console.error('[VideoRTC] Video error:', { + error: err ? MEDIA_ERRORS[err.code] : 'unknown', + message: err ? err.message : 'unknown', + codecs: this.mseCodecs || 'not set', + readyState: this.video.readyState, + networkState: this.video.networkState, + currentTime: this.video.currentTime + }); if (this.ws) this.ws.close(); // run reconnect for broken MSE stream });