Merge pull request #2011 from seydx/wyze

Native Wyze camera support
This commit is contained in:
Alex X
2026-01-18 08:39:37 +03:00
committed by GitHub
23 changed files with 3865 additions and 22 deletions
+12 -3
View File
@@ -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)*)
+2
View File
@@ -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},
},
})
}
+106
View File
@@ -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._
+202
View File
@@ -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
}
+2
View File
@@ -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
+7
View File
@@ -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.
+59
View File
@@ -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
}
+102
View File
@@ -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))
}
+35
View File
@@ -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
}
+218
View File
@@ -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(),
}
}
+987
View File
@@ -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
}
+146
View File
@@ -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 }
+571
View File
@@ -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
}
+52 -9
View File
@@ -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
}
-6
View File
@@ -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
}
+55
View File
@@ -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
}
+618
View File
@@ -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)
}
+337
View File
@@ -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
}
+277
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
}
+2 -2
View File
@@ -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,
+58
View File
@@ -413,6 +413,64 @@
</script>
<button id="wyze">Wyze</button>
<div>
<p style="margin: 5px 0; font-size: 12px; color: #888;">
API Key required: <a href="https://support.wyze.com/hc/en-us/articles/16129834216731" target="_blank">Get your API Key</a>
</p>
<form id="wyze-login-form">
<input type="text" name="api_id" placeholder="API ID" required size="20">
<input type="text" name="api_key" placeholder="API Key" required size="36">
<input type="email" name="email" placeholder="email" required>
<input type="password" name="password" placeholder="password" required>
<button type="submit">login</button>
</form>
<form id="wyze-devices-form">
<select id="wyze-id" name="id" required></select>
<button type="submit">load devices</button>
</form>
<table id="wyze-table"></table>
</div>
<script>
async function wyzeReload(ev) {
if (ev) ev.target.nextElementSibling.style.display = 'grid';
const r = await fetch('api/wyze', {'cache': 'no-cache'});
const data = await r.json();
const users = document.getElementById('wyze-id');
users.innerHTML = data.map(item => `<option value="${item}">${item}</option>`).join('');
}
document.getElementById('wyze').addEventListener('click', wyzeReload);
document.getElementById('wyze-login-form').addEventListener('submit', async ev => {
ev.preventDefault();
const table = document.getElementById('wyze-table');
table.innerText = 'loading...';
const params = new URLSearchParams(new FormData(ev.target));
const r = await fetch('api/wyze', {method: 'POST', body: params});
if (!r.ok) {
table.innerText = (await r.text()) || 'Unknown error';
return;
}
const data = await r.json();
table.innerText = '';
drawTable(table, data);
wyzeReload();
});
document.getElementById('wyze-devices-form').addEventListener('submit', async ev => {
ev.preventDefault();
const params = new URLSearchParams(new FormData(ev.target));
await getSources('wyze-table', 'api/wyze?' + params.toString());
});
</script>
<button id="xiaomi">Xiaomi</button>
<div>
<form id="xiaomi-login-form">
+16 -1
View File
@@ -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
});