add wyze support

This commit is contained in:
seydx
2026-01-01 05:24:45 +01:00
parent 212def9ceb
commit a42ab88dbd
21 changed files with 5371 additions and 1 deletions
+5
View File
@@ -67,6 +67,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
* [Source: Kasa](#source-kasa) * [Source: Kasa](#source-kasa)
* [Source: Tuya](#source-tuya) * [Source: Tuya](#source-tuya)
* [Source: Xiaomi](#source-xiaomi) * [Source: Xiaomi](#source-xiaomi)
* [Source: Wyze](#source-wyze)
* [Source: GoPro](#source-gopro) * [Source: GoPro](#source-gopro)
* [Source: Ivideon](#source-ivideon) * [Source: Ivideon](#source-ivideon)
* [Source: Hass](#source-hass) * [Source: Hass](#source-hass)
@@ -605,6 +606,10 @@ Tested: KD110, KC200, KC401, KC420WS, EC71.
This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/xiaomi/README.md). This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/xiaomi/README.md).
#### Source: Wyze
This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. [Read more](https://github.com/AlexxIT/go2rtc/blob/master/pkg/wyze/README.md).
#### Source: GoPro #### Source: GoPro
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)*
+2
View File
@@ -198,6 +198,8 @@ func tcpHandler(conn *rtsp.Conn) {
{Name: core.CodecPCM, ClockRate: 8000}, {Name: core.CodecPCM, ClockRate: 8000},
{Name: core.CodecPCMA, ClockRate: 8000}, {Name: core.CodecPCMA, ClockRate: 8000},
{Name: core.CodecPCMU, ClockRate: 8000}, {Name: core.CodecPCMU, ClockRate: 8000},
{Name: core.CodecAAC, ClockRate: 8000},
{Name: core.CodecAAC, ClockRate: 16000},
}, },
}) })
} }
+225
View File
@@ -0,0 +1,225 @@
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"
)
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)
}
type AccountConfig struct {
APIKey string `yaml:"api_key"`
APIID string `yaml:"api_id"`
Password string `yaml:"password"`
}
var accounts map[string]AccountConfig
func getCloud(email string) (*wyze.Cloud, error) {
cfg, ok := accounts[email]
if !ok {
return nil, fmt.Errorf("wyze: account not found: %s", email)
}
var cloud *wyze.Cloud
if cfg.APIKey != "" && cfg.APIID != "" {
cloud = wyze.NewCloudWithAPIKey(cfg.APIKey, cfg.APIID)
} else {
cloud = wyze.NewCloud()
}
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 == "" {
// Return list of configured accounts
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 {
streamURL := buildStreamURL(cam)
items = append(items, &api.Source{
Name: cam.Nickname,
Info: fmt.Sprintf("%s | %s | %s", cam.ModelName(), cam.MAC, cam.IP),
URL: streamURL,
})
}
api.ResponseSources(w, items)
return nil
}()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func buildStreamURL(cam *wyze.Camera) string {
// Use IP if available, otherwise use P2P_ID as host
host := cam.IP
if host == "" {
host = cam.P2PID
}
query := url.Values{}
query.Set("uid", cam.P2PID)
query.Set("enr", cam.ENR)
query.Set("mac", cam.MAC)
if cam.DTLS == 1 {
query.Set("dtls", "true")
}
return fmt.Sprintf("wyze://%s?%s", host, query.Encode())
}
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 == "" {
http.Error(w, "email and password required", http.StatusBadRequest)
return
}
// Try to login
var cloud *wyze.Cloud
if apiKey != "" && apiID != "" {
cloud = wyze.NewCloudWithAPIKey(apiKey, apiID)
} else {
cloud = wyze.NewCloud()
}
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
}
// Save credentials to config (not tokens!)
cfg := map[string]string{
"password": password,
}
if apiKey != "" {
cfg["api_key"] = apiKey
}
if apiID != "" {
cfg["api_id"] = apiID
}
if err := app.PatchConfig([]string{"wyze", email}, cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Update in-memory config
if accounts == nil {
accounts = make(map[string]AccountConfig)
}
accounts[email] = AccountConfig{
APIKey: apiKey,
APIID: apiID,
Password: password,
}
// Return camera list with direct URLs
cameras, err := cloud.GetCameraList()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var items []*api.Source
for _, cam := range cameras {
streamURL := buildStreamURL(cam)
items = append(items, &api.Source{
Name: cam.Nickname,
Info: fmt.Sprintf("%s | %s | %s", cam.ModelName(), cam.MAC, cam.IP),
URL: streamURL,
})
}
api.ResponseSources(w, items)
}
func isAuthError(err error, target **wyze.AuthError) bool {
if e, ok := err.(*wyze.AuthError); ok {
*target = e
return true
}
return false
}
+2
View File
@@ -43,6 +43,7 @@ import (
"github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webrtc"
"github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/internal/webtorrent"
"github.com/AlexxIT/go2rtc/internal/wyoming" "github.com/AlexxIT/go2rtc/internal/wyoming"
"github.com/AlexxIT/go2rtc/internal/wyze"
"github.com/AlexxIT/go2rtc/internal/xiaomi" "github.com/AlexxIT/go2rtc/internal/xiaomi"
"github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/internal/yandex"
"github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/shell"
@@ -100,6 +101,7 @@ func main() {
{"roborock", roborock.Init}, {"roborock", roborock.Init},
{"tapo", tapo.Init}, {"tapo", tapo.Init},
{"tuya", tuya.Init}, {"tuya", tuya.Init},
{"wyze", wyze.Init},
{"xiaomi", xiaomi.Init}, {"xiaomi", xiaomi.Init},
{"yandex", yandex.Init}, {"yandex", yandex.Init},
// Helper modules // Helper modules
+7
View File
@@ -10,6 +10,13 @@ import (
const ADTSHeaderSize = 7 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 { func IsADTS(b []byte) bool {
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
// A 12 Syncword, all bits must be set to 1. // A 12 Syncword, all bits must be set to 1.
+89
View File
@@ -0,0 +1,89 @@
# 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 newer firmware with DTLS**. Only cameras with DTLS-enabled firmware are currently 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
```
## 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]&dtls=true
```
| Parameter | Description |
|-----------|-------------|
| `IP` | Camera's local IP address |
| `uid` | P2P identifier (20 chars) |
| `enr` | Encryption key for DTLS |
| `mac` | Device MAC address |
| `dtls` | Enable DTLS encryption (default: true) |
## Configuration
### Resolution
You can change the camera's resolution using the `quality` parameter:
```yaml
streams:
wyze_hd: wyze://...&quality=hd # 1080P/2K (default)
wyze_sd: wyze://...&quality=sd # 360P
```
### 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.
## Supported Cameras
Cameras using the TUTK P2P protocol:
| Model | Name | Tested |
|-------|------|--------|
| WYZE_CAKP2JFUS | Wyze Cam v3 | |
| HL_CAM3P | Wyze Cam v3 Pro | |
| HL_CAM4 | Wyze Cam v4 | Yes |
| WYZECP1_JEF | Wyze Cam Pan | |
| HL_PANP | Wyze Cam Pan v2 | |
| HL_PAN3 | Wyze Cam Pan v3 | |
| WVOD1 | Wyze Video Doorbell | |
| WVOD2 | Wyze Video Doorbell v2 | |
| AN_RSCW | Wyze Video Doorbell Pro | |
| GW_BE1 | Wyze Cam Floodlight | |
| HL_WCO2 | Wyze Cam Outdoor | |
| HL_CFL2 | Wyze Cam Floodlight v2 | |
| LD_CFP | Wyze Battery Cam Pro | |
+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/wyze/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
}
+537
View File
@@ -0,0 +1,537 @@
package wyze
import (
"encoding/binary"
"encoding/json"
"fmt"
"net"
"net/url"
"strings"
"sync"
"time"
"github.com/AlexxIT/go2rtc/pkg/wyze/crypto"
"github.com/AlexxIT/go2rtc/pkg/wyze/tutk"
)
type Client struct {
conn *tutk.Conn
host string
uid string
enr string
mac string
authKey string
verbose bool
closed bool
closeMu sync.Mutex
hasAudio bool
hasIntercom bool
audioCodecID uint16
audioSampleRate uint32
audioChannels uint8
}
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"),
verbose: query.Get("verbose") == "true",
}
c.authKey = string(crypto.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 uint16, sampleRate uint32, channels uint8) {
c.audioCodecID = codecID
c.audioSampleRate = sampleRate
c.audioChannels = channels
}
func (c *Client) GetBackchannelCodec() (codecID uint16, sampleRate uint32, channels uint8) {
return c.audioCodecID, c.audioSampleRate, c.audioChannels
}
func (c *Client) SetResolution(sd bool) error {
var frameSize uint8
var bitrate uint16
if sd {
frameSize = tutk.FrameSize360P
bitrate = tutk.BitrateSD
} else {
frameSize = tutk.FrameSize2K
bitrate = tutk.BitrateMax
}
if c.verbose {
fmt.Printf("[Wyze] SetResolution: sd=%v frameSize=%d bitrate=%d\n", sd, frameSize, bitrate)
}
k10056 := c.buildK10056(frameSize, bitrate)
if err := c.conn.SendIOCtrl(tutk.KCmdSetResolution, k10056); err != nil {
return fmt.Errorf("wyze: K10056 send failed: %w", err)
}
// Wait for response (SDK-style: accept any IOCtrl)
cmdID, data, err := c.conn.RecvIOCtrl(1 * time.Second)
if err != nil {
return err
}
if c.verbose {
fmt.Printf("[Wyze] SetResolution response: K%d (%d bytes)\n", cmdID, len(data))
}
if cmdID == tutk.KCmdSetResolutionResp && len(data) >= 17 {
result := data[16]
if c.verbose {
fmt.Printf("[Wyze] K10057 result: %d\n", result)
}
}
return nil
}
func (c *Client) StartVideo() error {
k10010 := c.buildK10010(tutk.MediaTypeVideo, true)
if c.verbose {
fmt.Printf("[Wyze] TX K10010 video (%d bytes): % x\n", len(k10010), k10010)
}
if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil {
return fmt.Errorf("K10010 video send failed: %w", err)
}
// Wait for K10011 response
cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second)
if err != nil {
return fmt.Errorf("K10011 video recv failed: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] K10011 video response: cmdID=%d, len=%d\n", cmdID, len(data))
if len(data) >= 18 {
fmt.Printf("[Wyze] K10011 video: media=%d status=%d\n", data[16], data[17])
}
}
return nil
}
func (c *Client) StartAudio() error {
k10010 := c.buildK10010(tutk.MediaTypeAudio, true)
if c.verbose {
fmt.Printf("[Wyze] TX K10010 audio (%d bytes): % x\n", len(k10010), k10010)
}
if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil {
return fmt.Errorf("K10010 audio send failed: %w", err)
}
// Wait for K10011 response
cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second)
if err != nil {
return fmt.Errorf("K10011 audio recv failed: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] K10011 audio response: cmdID=%d, len=%d\n", cmdID, len(data))
if len(data) >= 18 {
fmt.Printf("[Wyze] K10011 audio: media=%d status=%d\n", data[16], data[17])
}
}
return nil
}
func (c *Client) StartIntercom() error {
if c.conn.IsBackchannelReady() {
return nil // Already enabled
}
if c.verbose {
fmt.Printf("[Wyze] Sending K10010 (enable return audio)\n")
}
k10010 := c.buildK10010(tutk.MediaTypeReturnAudio, true)
if err := c.conn.SendIOCtrl(tutk.KCmdControlChannel, k10010); err != nil {
return fmt.Errorf("K10010 send failed: %w", err)
}
// Wait for K10011 response
cmdID, data, err := c.conn.RecvIOCtrl(5 * time.Second)
if err != nil {
return fmt.Errorf("K10011 recv failed: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] K10011 response: cmdID=%d, len=%d\n", cmdID, len(data))
}
// Perform DTLS server handshake on backchannel (camera connects to us)
if c.verbose {
fmt.Printf("[Wyze] Starting speaker channel DTLS handshake\n")
}
if err := c.conn.AVServStart(); err != nil {
return fmt.Errorf("speaker channel handshake failed: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] Backchannel ready\n")
}
return nil
}
func (c *Client) ReadPacket() (*tutk.Packet, error) {
return c.conn.AVRecvFrameData()
}
func (c *Client) WriteAudio(codec uint16, 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%04x, 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")
}
if c.conn != nil {
c.conn.Close()
}
return nil
}
func (c *Client) connect() error {
host := c.host
if idx := strings.Index(host, ":"); idx > 0 {
host = host[:idx]
}
conn, err := tutk.Dial(host, 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 {
if c.verbose {
fmt.Printf("[Wyze] Starting K-command authentication\n")
}
// Step 1: Send K10000
k10000 := c.buildK10000()
if err := c.conn.SendIOCtrl(tutk.KCmdAuth, k10000); err != nil {
return fmt.Errorf("wyze: K10000 send failed: %w", err)
}
// Step 2: Wait for K10001
cmdID, data, err := c.conn.RecvIOCtrl(10 * time.Second)
if err != nil {
return fmt.Errorf("wyze: K10001 recv failed: %w", err)
}
if cmdID != tutk.KCmdChallenge {
return fmt.Errorf("wyze: expected K10001, got K%d", cmdID)
}
challenge, status, err := c.parseK10001(data)
if err != nil {
return fmt.Errorf("wyze: K10001 parse failed: %w", err)
}
if c.verbose {
fmt.Printf("[Wyze] K10001 received, status=%d\n", status)
}
// Step 3: Send K10002
k10002 := c.buildK10002(challenge, status)
if err := c.conn.SendIOCtrl(tutk.KCmdChallengeResp, k10002); err != nil {
return fmt.Errorf("wyze: K10002 send failed: %w", err)
}
// Step 4: Wait for K10003
cmdID, data, err = c.conn.RecvIOCtrl(10 * time.Second)
if err != nil {
return fmt.Errorf("wyze: K10003 recv failed: %w", err)
}
if cmdID != tutk.KCmdAuthResult {
return fmt.Errorf("wyze: expected K10003, got K%d", cmdID)
}
authResp, err := c.parseK10003(data)
if err != nil {
return fmt.Errorf("wyze: K10003 parse failed: %w", err)
}
// Parse capabilities
if authResp != nil && authResp.CameraInfo != nil {
if c.verbose {
fmt.Printf("[Wyze] CameraInfo authResp: ")
b, _ := json.Marshal(authResp)
fmt.Printf("%s\n", b)
}
// Audio receiving support
if audio, ok := authResp.CameraInfo["audio"].(bool); ok {
c.hasAudio = audio
} else {
c.hasAudio = true // Default to true
}
} else {
c.hasAudio = true
}
if avResp := c.conn.GetAVLoginResponse(); avResp != nil {
c.hasIntercom = avResp.TwoWayStreaming == 1
if c.verbose {
fmt.Printf("[Wyze] two_way_streaming=%d (from AV Login Response)\n", avResp.TwoWayStreaming)
}
}
if c.verbose {
fmt.Printf("[Wyze] K-auth complete\n")
}
return nil
}
func (c *Client) buildK10000() []byte {
buf := make([]byte, 16)
buf[0] = 'H'
buf[1] = 'L'
buf[2] = 5
binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdAuth)
return buf
}
func (c *Client) buildK10002(challenge []byte, status byte) []byte {
response := crypto.GenerateChallengeResponse(challenge, c.enr, status)
buf := make([]byte, 38)
buf[0] = 'H'
buf[1] = 'L'
buf[2] = 5
binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdChallengeResp)
buf[6] = 22 // Payload length
if len(response) >= 16 {
copy(buf[16:32], response[:16])
}
if len(c.uid) >= 4 {
copy(buf[32:36], c.uid[:4])
}
buf[36] = 1 // Video flag (0 = disabled, 1 = enabled > will start video stream immediately)
buf[37] = 1 // Audio flag (0 = disabled, 1 = enabled > will start audio stream immediately)
return buf
}
func (c *Client) buildK10010(mediaType byte, enabled bool) []byte {
// SDK format: 18 bytes total
// Header: 16 bytes, Payload: 2 bytes (media_type + enabled)
// TX K10010: 48 4c 05 00 1a 27 02 00 00 00 00 00 00 00 00 00 01 01
buf := make([]byte, 18)
buf[0] = 'H'
buf[1] = 'L'
buf[2] = 5 // Version
binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdControlChannel) // 0x271a = 10010
binary.LittleEndian.PutUint16(buf[6:8], 2) // Payload length = 2
buf[16] = mediaType // 1=Video, 2=Audio, 3=ReturnAudio
if enabled {
buf[17] = 1
} else {
buf[17] = 2
}
return buf
}
func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte {
// SDK format: 21 bytes total
// Header: 16 bytes, Payload: 5 bytes
// TX K10056: 48 4c 05 00 48 27 05 00 00 00 00 00 00 00 00 00 04 f0 00 00 00
buf := make([]byte, 21)
buf[0] = 'H'
buf[1] = 'L'
buf[2] = 5 // Version
binary.LittleEndian.PutUint16(buf[4:6], tutk.KCmdSetResolution) // 0x2748 = 10056
binary.LittleEndian.PutUint16(buf[6:8], 5) // Payload length = 5
buf[16] = frameSize + 1 // 4 = HD
binary.LittleEndian.PutUint16(buf[17:19], bitrate) // 0x00f0 = 240
// buf[19], buf[20] = FPS (0 = auto)
return buf
}
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:6])
if cmdID != tutk.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) (*tutk.AuthResponse, error) {
if c.verbose {
fmt.Printf("[Wyze] parseK10003: received %d bytes\n", len(data))
}
if len(data) < 16 {
return &tutk.AuthResponse{}, nil
}
if data[0] != 'H' || data[1] != 'L' {
return &tutk.AuthResponse{}, nil
}
cmdID := binary.LittleEndian.Uint16(data[4:6])
textLen := binary.LittleEndian.Uint16(data[6:8])
if cmdID != tutk.KCmdAuthResult {
return &tutk.AuthResponse{}, nil
}
if len(data) > 16 && textLen > 0 {
jsonData := data[16:]
for i := range jsonData {
if jsonData[i] == '{' {
var resp tutk.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 &tutk.AuthResponse{}, nil
}
+390
View File
@@ -0,0 +1,390 @@
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
refreshToken string
phoneID string
openUserID 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"`
}
func (c *Camera) ModelName() string {
models := map[string]string{
"WYZEC1": "Wyze Cam v1",
"WYZEC1-JZ": "Wyze Cam v2",
"WYZE_CAKP2JFUS": "Wyze Cam v3",
"HL_CAM3P": "Wyze Cam v3 Pro",
"HL_CAM4": "Wyze Cam v4",
"WYZECP1_JEF": "Wyze Cam Pan",
"HL_PANP": "Wyze Cam Pan v2",
"HL_PAN3": "Wyze Cam Pan v3",
"WVOD1": "Wyze Video Doorbell",
"WVOD2": "Wyze Video Doorbell v2",
"AN_RSCW": "Wyze Video Doorbell Pro",
"GW_BE1": "Wyze Cam Floodlight",
"HL_WCO2": "Wyze Cam Outdoor",
"HL_CFL2": "Wyze Cam Floodlight v2",
"LD_CFP": "Wyze Battery Cam Pro",
}
if name, ok := models[c.ProductModel]; ok {
return name
}
return c.ProductModel
}
func NewCloud() *Cloud {
return &Cloud{
client: &http.Client{Timeout: 30 * time.Second},
phoneID: generatePhoneID(),
}
}
func NewCloudWithAPIKey(apiKey, keyID string) *Cloud {
c := NewCloud()
c.apiKey = apiKey
c.keyID = keyID
return c
}
func generatePhoneID() string {
return core.RandString(16, 16) // 16 hex chars
}
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"`
}
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)
}
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")
if c.apiKey != "" && c.keyID != "" {
req.Header.Set("Apikey", c.apiKey)
req.Header.Set("Keyid", c.keyID)
req.Header.Set("User-Agent", "go2rtc")
} else {
req.Header.Set("X-API-Key", "WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ")
req.Header.Set("Phone-Id", c.phoneID)
req.Header.Set("User-Agent", "wyze_ios_"+appVersion)
}
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
c.refreshToken = result.RefreshToken
c.openUserID = result.UserID
return nil
}
func (c *Cloud) LoginWithToken(accessToken, phoneID string) error {
c.accessToken = accessToken
if phoneID != "" {
c.phoneID = phoneID
}
_, err := c.GetCameraList()
return err
}
func (c *Cloud) Credentials() (phoneID, openUserID string) {
return c.phoneID, c.openUserID
}
func (c *Cloud) AccessToken() string {
return c.accessToken
}
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"`
}
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
}
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)
}
type p2pInfoResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data map[string]any `json:"data"`
}
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 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 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
}
+143
View File
@@ -0,0 +1,143 @@
package crypto
import (
"bytes"
"crypto/rand"
"encoding/binary"
"math/bits"
)
const charlie = "Charlie is the designer of P2P!!"
func TransCodePartial(src []byte) []byte {
n := len(src)
tmp := make([]byte, n)
dst := bytes.Clone(src)
src16, tmp16, dst16 := src, tmp, dst
for ; n >= 16; n -= 16 {
for i := 0; i < 16; i += 4 {
x := binary.LittleEndian.Uint32(src16[i:])
binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, -i-1))
}
for i := range 16 {
dst16[i] = tmp16[i] ^ charlie[i]
}
swap(dst16, tmp16, 16)
for i := 0; i < 16; i += 4 {
x := binary.LittleEndian.Uint32(tmp16[i:])
binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, -i-3))
}
tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:]
}
for i := 0; i < n; i++ {
tmp16[i] = src16[i] ^ charlie[i]
}
swap(tmp16, dst16, n)
return dst
}
func ReverseTransCodePartial(src []byte) []byte {
n := len(src)
tmp := make([]byte, n)
dst := bytes.Clone(src)
src16, tmp16, dst16 := src, tmp, dst
for ; n >= 16; n -= 16 {
for i := 0; i < 16; i += 4 {
x := binary.LittleEndian.Uint32(src16[i:])
binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3))
}
swap(tmp16, dst16, 16)
for i := range 16 {
tmp16[i] = dst16[i] ^ charlie[i]
}
for i := 0; i < 16; i += 4 {
x := binary.LittleEndian.Uint32(tmp16[i:])
binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, i+1))
}
tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:]
}
swap(src16, tmp16, n)
for i := 0; i < n; i++ {
dst16[i] = tmp16[i] ^ charlie[i]
}
return dst
}
func TransCodeBlob(src []byte) []byte {
if len(src) < 16 {
return TransCodePartial(src)
}
dst := make([]byte, len(src))
header := TransCodePartial(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(src[16 : 16+encryptLen])
copy(dst[16:], encrypted)
}
if remaining > 48 {
copy(dst[64:], src[64:])
}
} else { // Full encryption
encrypted := TransCodePartial(src[16:])
copy(dst[16:], encrypted)
}
}
return dst
}
func ReverseTransCodeBlob(src []byte) []byte {
if len(src) < 16 {
return ReverseTransCodePartial(src)
}
dst := make([]byte, len(src))
header := ReverseTransCodePartial(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(src[16 : 16+decryptLen])
copy(dst[16:], decrypted)
}
if remaining > 48 {
copy(dst[64:], src[64:])
}
} else { // Full decryption
decrypted := ReverseTransCodePartial(src[16:])
copy(dst[16:], decrypted)
}
}
return dst
}
func RandRead(b []byte) {
_, _ = rand.Read(b)
}
func swap(src, dst []byte, n int) {
switch n {
case 8:
dst[0], dst[1], dst[2], dst[3] = src[7], src[4], src[3], src[2]
dst[4], dst[5], dst[6], dst[7] = src[1], src[6], src[5], src[0]
case 16:
dst[0], dst[1], dst[2], dst[3] = src[11], src[9], src[8], src[15]
dst[4], dst[5], dst[6], dst[7] = src[13], src[10], src[12], src[14]
dst[8], dst[9], dst[10], dst[11] = src[2], src[1], src[5], src[0]
dst[12], dst[13], dst[14], dst[15] = src[6], src[4], src[7], src[3]
default:
copy(dst, src[:n])
}
}
+147
View File
@@ -0,0 +1,147 @@
package crypto
import (
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"strings"
)
const delta = 0x9e3779b9
const (
StatusDefault byte = 1
StatusENR16 byte = 3
StatusENR32 byte = 6
)
func XXTEADecrypt(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] -= mx(sum, y, z, p, e, k)
y = v[p]
}
z := v[n-1]
v[0] -= mx(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 XXTEAEncrypt(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
var sum uint32
z := v[n-1]
for rounds > 0 {
sum += delta
e := (sum >> 2) & 3
for p := 0; p < n-1; p++ {
y := v[p+1]
v[p] += mx(sum, y, z, p, e, k)
z = v[p]
}
y := v[0]
v[n-1] += mx(sum, y, z, n-1, e, k)
z = v[n-1]
rounds--
}
result := make([]byte, n*4)
for i := range n {
binary.LittleEndian.PutUint32(result[i*4:], v[i])
}
return result[:len(data)]
}
func mx(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))
}
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 = XXTEADecrypt(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 XXTEADecrypt(challengeBytes, secretKey)
}
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)
}
+232
View File
@@ -0,0 +1,232 @@
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/wyze/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()
sd := query.Get("subtype") == "sd"
medias, err := probe(client, sd)
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 {
defer p.client.Close()
for {
_ = 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.AudioCodecG711U:
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.AudioCodecG711A:
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.AudioCodecAACADTS, tutk.AudioCodecAACWyze, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM:
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.AudioCodecOpus:
name = core.CodecOpus
pkt2 = &core.Packet{
Header: rtp.Header{Version: 2, Marker: true, 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, sd bool) ([]*core.Media, error) {
_ = client.SetResolution(sd)
_ = client.SetDeadline(time.Now().Add(core.ProbeTimeout))
var vcodec, acodec *core.Codec
var tutkAudioCodec uint16
for {
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.AudioCodecG711U:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels}
tutkAudioCodec = pkt.Codec
}
case tutk.AudioCodecG711A:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels}
tutkAudioCodec = pkt.Codec
}
case tutk.AudioCodecAACWyze, tutk.AudioCodecAACADTS, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM:
if acodec == nil {
config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false)
acodec = aac.ConfigToCodec(config)
tutkAudioCodec = pkt.Codec
}
case tutk.AudioCodecOpus:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2}
tutkAudioCodec = pkt.Codec
}
case tutk.AudioCodecPCM:
if acodec == nil {
acodec = &core.Codec{Name: core.CodecPCM, ClockRate: pkt.SampleRate, Channels: pkt.Channels}
tutkAudioCodec = pkt.Codec
}
}
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
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
package tutk
import (
"encoding/binary"
"github.com/AlexxIT/go2rtc/pkg/aac"
)
const FrameInfoSize = 40
// Wire format (little-endian) - Wyze extended FRAMEINFO:
//
// [0-1] codec_id uint16 (0x004e=H.264, 0x0050=H.265, 0x0088=AAC)
// [2] flags uint8 (Video: 0=P/1=I, Audio: sr_idx<<2|bits16<<1|ch)
// [3] cam_index uint8
// [4] online_num uint8
// [5] framerate uint8 (FPS, e.g. 20)
// [6] frame_size uint8 (Resolution: 1=1080P, 2=360P, 4=2K)
// [7] bitrate uint8 (e.g. 0xF0=240)
// [8-11] timestamp_us uint32 (microseconds component)
// [12-15] timestamp uint32 (Unix timestamp in seconds)
// [16-19] payload_sz uint32 (frame payload size)
// [20-23] frame_no uint32 (frame number)
// [24-39] device_id 16 bytes (MAC address + padding)
type FrameInfo struct {
CodecID uint16
Flags uint8
CamIndex uint8
OnlineNum uint8
Framerate uint8 // FPS (e.g. 20)
FrameSize uint8 // Resolution: 1=1080P, 2=360P, 4=2K
Bitrate uint8 // Bitrate value (e.g. 240)
TimestampUS uint32
Timestamp uint32
PayloadSize uint32
FrameNo uint32
}
// Resolution constants (as received in FrameSize field)
// Note: Some cameras only support 2K + 360P, others support 1080P + 360P
// The actual resolution depends on camera model!
const (
ResolutionUnknown = 0
ResolutionSD = 1 // 360P (640x360) on 2K cameras, or 1080P on older cams
Resolution360P = 2 // 360P (640x360)
Resolution2K = 4 // 2K (2560x1440)
)
func (fi *FrameInfo) IsKeyframe() bool {
return fi.Flags == 0x01
}
// Resolution returns a human-readable resolution string
func (fi *FrameInfo) Resolution() string {
switch fi.FrameSize {
case ResolutionSD:
return "SD" // Could be 360P or 1080P depending on camera
case Resolution360P:
return "360P"
case Resolution2K:
return "2K"
default:
return "unknown"
}
}
func (fi *FrameInfo) SampleRate() uint32 {
srIdx := (fi.Flags >> 2) & 0x0F
return uint32(SampleRateValue(srIdx))
}
func (fi *FrameInfo) Channels() uint8 {
if fi.Flags&0x01 == 1 {
return 2
}
return 1
}
func (fi *FrameInfo) IsVideo() bool {
return IsVideoCodec(fi.CodecID)
}
func (fi *FrameInfo) IsAudio() bool {
return IsAudioCodec(fi.CodecID)
}
func ParseFrameInfo(data []byte) *FrameInfo {
if len(data) < FrameInfoSize {
return nil
}
offset := len(data) - FrameInfoSize
fi := data[offset:]
return &FrameInfo{
CodecID: binary.LittleEndian.Uint16(fi[0:2]),
Flags: fi[2],
CamIndex: fi[3],
OnlineNum: fi[4],
Framerate: fi[5],
FrameSize: fi[6],
Bitrate: fi[7],
TimestampUS: binary.LittleEndian.Uint32(fi[8:12]),
Timestamp: binary.LittleEndian.Uint32(fi[12:16]),
PayloadSize: binary.LittleEndian.Uint32(fi[16:20]),
FrameNo: binary.LittleEndian.Uint32(fi[20:24]),
}
}
func ParseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) {
// Try ADTS header first (more reliable than FRAMEINFO flags)
if aac.IsADTS(payload) {
codec := aac.ADTSToCodec(payload)
if codec != nil {
return codec.ClockRate, codec.Channels
}
}
// Fallback to FRAMEINFO flags
if fi != nil {
return fi.SampleRate(), fi.Channels()
}
// Default values
return 16000, 1
}
+64
View File
@@ -0,0 +1,64 @@
package tutk
import (
"fmt"
"net"
"time"
)
type ChannelAdapter struct {
conn *Conn
channel uint8
}
func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
var buf chan []byte
if a.channel == IOTCChannelMain {
buf = a.conn.mainBuf
} else {
buf = a.conn.speakerBuf
}
select {
case data := <-buf:
n = copy(p, data)
if a.conn.verbose && len(data) >= 1 {
fmt.Printf("[ChannelAdapter] ch=%d ReadFrom: len=%d contentType=%d\n",
a.channel, len(data), data[0])
}
return n, a.conn.addr, nil
case <-a.conn.done:
return 0, nil, net.ErrClosed
}
}
func (a *ChannelAdapter) WriteTo(p []byte, addr net.Addr) (n int, err error) {
if a.conn.verbose {
fmt.Printf("[IOTC TX] channel=%d size=%d\n", a.channel, len(p))
}
_, err = a.conn.sendIOTC(p, a.channel)
if 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{IP: net.IPv4(0, 0, 0, 0), Port: 0}
}
func (a *ChannelAdapter) SetDeadline(time.Time) error {
return nil
}
func (a *ChannelAdapter) SetReadDeadline(time.Time) error {
return nil
}
func (a *ChannelAdapter) SetWriteDeadline(time.Time) error {
return nil
}
+218
View File
@@ -0,0 +1,218 @@
package tutk
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 := 0; i < chachaNonceLength; i++ {
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(),
}
}
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
package tutk
const (
CodecUnknown uint16 = 0x00 // Unknown codec
CodecMPEG4 uint16 = 0x4C // 76 - MPEG4
CodecH263 uint16 = 0x4D // 77 - H.263
CodecH264 uint16 = 0x4E // 78 - H.264/AVC (common for Wyze)
CodecMJPEG uint16 = 0x4F // 79 - MJPEG
CodecH265 uint16 = 0x50 // 80 - H.265/HEVC (common for Wyze)
)
const (
AudioCodecAACRaw uint16 = 0x86 // 134 - AAC raw format
AudioCodecAACADTS uint16 = 0x87 // 135 - AAC with ADTS header
AudioCodecAACLATM uint16 = 0x88 // 136 - AAC with LATM format
AudioCodecG711U uint16 = 0x89 // 137 - G.711 μ-law (PCMU)
AudioCodecG711A uint16 = 0x8A // 138 - G.711 A-law (PCMA)
AudioCodecADPCM uint16 = 0x8B // 139 - ADPCM
AudioCodecPCM uint16 = 0x8C // 140 - PCM 16-bit signed LE
AudioCodecSPEEX uint16 = 0x8D // 141 - Speex
AudioCodecMP3 uint16 = 0x8E // 142 - MP3
AudioCodecG726 uint16 = 0x8F // 143 - G.726
// Wyze extensions (not in official SDK)
AudioCodecAACWyze uint16 = 0x90 // 144 - Wyze AAC
AudioCodecOpus uint16 = 0x92 // 146 - Opus codec
)
const (
SampleRate8K uint8 = 0x00 // 8000 Hz
SampleRate11K uint8 = 0x01 // 11025 Hz
SampleRate12K uint8 = 0x02 // 12000 Hz
SampleRate16K uint8 = 0x03 // 16000 Hz
SampleRate22K uint8 = 0x04 // 22050 Hz
SampleRate24K uint8 = 0x05 // 24000 Hz
SampleRate32K uint8 = 0x06 // 32000 Hz
SampleRate44K uint8 = 0x07 // 44100 Hz
SampleRate48K uint8 = 0x08 // 48000 Hz
)
var SampleRates = map[uint8]int{
SampleRate8K: 8000,
SampleRate11K: 11025,
SampleRate12K: 12000,
SampleRate16K: 16000,
SampleRate22K: 22050,
SampleRate24K: 24000,
SampleRate32K: 32000,
SampleRate44K: 44100,
SampleRate48K: 48000,
}
var SamplesPerFrame = map[uint16]uint32{
AudioCodecAACRaw: 1024, // AAC frame = 1024 samples
AudioCodecAACADTS: 1024,
AudioCodecAACLATM: 1024,
AudioCodecAACWyze: 1024,
AudioCodecG711U: 160, // G.711 typically 20ms = 160 samples at 8kHz
AudioCodecG711A: 160,
AudioCodecPCM: 160,
AudioCodecADPCM: 160,
AudioCodecSPEEX: 160,
AudioCodecMP3: 1152, // MP3 frame = 1152 samples
AudioCodecG726: 160,
AudioCodecOpus: 960, // Opus typically 20ms = 960 samples at 48kHz
}
const (
IOTypeVideoStart = 0x01FF
IOTypeVideoStop = 0x02FF
IOTypeAudioStart = 0x0300
IOTypeAudioStop = 0x0301
IOTypeSpeakerStart = 0x0350
IOTypeSpeakerStop = 0x0351
IOTypeGetAudioOutFormatReq = 0x032A
IOTypeGetAudioOutFormatRes = 0x032B
IOTypeSetStreamCtrlReq = 0x0320
IOTypeSetStreamCtrlRes = 0x0321
IOTypeGetStreamCtrlReq = 0x0322
IOTypeGetStreamCtrlRes = 0x0323
IOTypeDevInfoReq = 0x0340
IOTypeDevInfoRes = 0x0341
IOTypeGetSupportStreamReq = 0x0344
IOTypeGetSupportStreamRes = 0x0345
IOTypeSetRecordReq = 0x0310
IOTypeSetRecordRes = 0x0311
IOTypeGetRecordReq = 0x0312
IOTypeGetRecordRes = 0x0313
IOTypePTZCommand = 0x1001
IOTypeReceiveFirstFrame = 0x1002
IOTypeGetEnvironmentReq = 0x030A
IOTypeGetEnvironmentRes = 0x030B
IOTypeSetVideoModeReq = 0x030C
IOTypeSetVideoModeRes = 0x030D
IOTypeGetVideoModeReq = 0x030E
IOTypeGetVideoModeRes = 0x030F
IOTypeSetTimeReq = 0x0316
IOTypeSetTimeRes = 0x0317
IOTypeGetTimeReq = 0x0318
IOTypeGetTimeRes = 0x0319
IOTypeSetWifiReq = 0x0102
IOTypeSetWifiRes = 0x0103
IOTypeGetWifiReq = 0x0104
IOTypeGetWifiRes = 0x0105
IOTypeListWifiAPReq = 0x0106
IOTypeListWifiAPRes = 0x0107
IOTypeSetMotionDetectReq = 0x0306
IOTypeSetMotionDetectRes = 0x0307
IOTypeGetMotionDetectReq = 0x0308
IOTypeGetMotionDetectRes = 0x0309
)
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
)
const (
MagicAVLoginResp uint16 = 0x2100
MagicIOCtrl uint16 = 0x7000
MagicChannelMsg uint16 = 0x1000
MagicACK uint16 = 0x0009
MagicAVLogin1 uint16 = 0x0000
MagicAVLogin2 uint16 = 0x2000
)
const (
ProtocolVersion uint16 = 0x000c // Version 12
)
const (
DefaultCapabilities uint32 = 0x001f07fb
)
const (
KCmdAuth = 10000
KCmdChallenge = 10001
KCmdChallengeResp = 10002
KCmdAuthResult = 10003
KCmdControlChannel = 10010
KCmdControlChannelResp = 10011
KCmdSetResolution = 10056
KCmdSetResolutionResp = 10057
)
const (
MediaTypeVideo = 1
MediaTypeAudio = 2
MediaTypeReturnAudio = 3
MediaTypeRDT = 4
)
const (
IOTCChannelMain = 0 // Main AV channel (we = DTLS Client, camera = Server)
IOTCChannelBack = 1 // Backchannel for Return Audio (we = DTLS Server, camera = Client)
)
const (
BitrateMax uint16 = 0xF0 // 240 KB/s
BitrateSD uint16 = 0x3C // 60 KB/s
)
const (
FrameSize1080P = 0
FrameSize360P = 1
FrameSize720P = 2
FrameSize2K = 3
)
const (
QualityUnknown = 0
QualityMax = 1
QualityHigh = 2
QualityMiddle = 3
QualityLow = 4
QualityMin = 5
)
func CodecName(id uint16) string {
switch id {
case CodecH264:
return "H264"
case CodecH265:
return "H265"
case CodecMPEG4:
return "MPEG4"
case CodecH263:
return "H263"
case CodecMJPEG:
return "MJPEG"
default:
return "Unknown"
}
}
func AudioCodecName(id uint16) string {
switch id {
case AudioCodecG711U:
return "PCMU"
case AudioCodecG711A:
return "PCMA"
case AudioCodecPCM:
return "PCM"
case AudioCodecAACLATM, AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACWyze:
return "AAC"
case AudioCodecOpus:
return "Opus"
case AudioCodecSPEEX:
return "Speex"
case AudioCodecMP3:
return "MP3"
case AudioCodecG726:
return "G726"
case AudioCodecADPCM:
return "ADPCM"
default:
return "Unknown"
}
}
func SampleRateValue(enum uint8) int {
if rate, ok := SampleRates[enum]; ok {
return rate
}
return 16000 // Default
}
func SampleRateIndex(hz uint32) uint8 {
switch hz {
case 8000:
return SampleRate8K
case 11025:
return SampleRate11K
case 12000:
return SampleRate12K
case 16000:
return SampleRate16K
case 22050:
return SampleRate22K
case 24000:
return SampleRate24K
case 32000:
return SampleRate32K
case 44100:
return SampleRate44K
case 48000:
return SampleRate48K
default:
return SampleRate16K // Default
}
}
func BuildAudioFlags(sampleRate uint32, bits16 bool, stereo bool) uint8 {
flags := SampleRateIndex(sampleRate) << 2
if bits16 {
flags |= 0x02
}
if stereo {
flags |= 0x01
}
return flags
}
func IsVideoCodec(id uint16) bool {
return id >= CodecMPEG4 && id <= CodecH265
}
func IsAudioCodec(id uint16) bool {
return id >= AudioCodecAACRaw && id <= AudioCodecOpus
}
func GetSamplesPerFrame(codecID uint16) uint32 {
if samples, ok := SamplesPerFrame[codecID]; ok {
return samples
}
return 1024 // Default to AAC
}
+155
View File
@@ -0,0 +1,155 @@
package tutk
const (
// Start packets - first fragment of a frame
// 0x08: Extended start (36-byte header, no FrameInfo)
// 0x09: StartAlt (36-byte header, FrameInfo only if pkt_total==1)
FrameTypeStart uint8 = 0x08
FrameTypeStartAlt uint8 = 0x09
// Continuation packets - middle fragment (28-byte header, no FrameInfo)
FrameTypeCont uint8 = 0x00
FrameTypeContAlt uint8 = 0x04
// End packets - last fragment (with 40-byte FrameInfo)
// 0x01: Single-packet frame (28-byte header)
// 0x05: Multi-packet end (28-byte header)
// 0x0d: Extended end (36-byte header)
FrameTypeEndSingle uint8 = 0x01
FrameTypeEndMulti uint8 = 0x05
FrameTypeEndExt uint8 = 0x0d
)
const (
ChannelIVideo uint8 = 0x05
ChannelAudio uint8 = 0x03
ChannelPVideo uint8 = 0x07
)
type Packet struct {
Channel uint8
Codec uint16
Timestamp uint32
Payload []byte
IsKeyframe bool
FrameNo uint32
SampleRate uint32
Channels uint8
}
func (p *Packet) IsVideo() bool {
return p.Channel == ChannelIVideo || p.Channel == ChannelPVideo
}
func (p *Packet) IsAudio() bool {
return p.Channel == ChannelAudio
}
type AuthResponse struct {
ConnectionRes string `json:"connectionRes"`
CameraInfo map[string]any `json:"cameraInfo"`
}
type AVLoginResponse struct {
ServerType uint32
Resend int32
TwoWayStreaming int32
SyncRecvData int32
SecurityMode uint32
VideoOnConnect int32
AudioOnConnect int32
}
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 PacketHeader struct {
Channel byte
FrameType byte
HeaderSize int // 28 or 36
FrameNo uint32 // Frame number (from [24-27] for 28-byte, [32-35] for 36-byte)
PktIdx uint16 // Packet index within frame (0-based)
PktTotal uint16 // Total packets in this frame
PayloadSize uint16
HasFrameInfo bool // true if [14-15] or [22-23] == 0x0028
}
func ParsePacketHeader(data []byte) *PacketHeader {
if len(data) < 28 {
return nil
}
frameType := data[1]
hdr := &PacketHeader{
Channel: data[0],
FrameType: frameType,
}
// Header size based on FrameType (NOT magic bytes!)
switch frameType {
case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt: // 0x08, 0x09, 0x0d
hdr.HeaderSize = 36
default: // 0x00, 0x01, 0x04, 0x05
hdr.HeaderSize = 28
}
if len(data) < hdr.HeaderSize {
return nil
}
if hdr.HeaderSize == 28 {
// 28-Byte Header Layout:
// [12-13] pkt_total
// [14-15] pkt_idx OR 0x0028 (FrameInfo marker) - ONLY 0x0028 in End packets!
// [16-17] payload_size
// [24-27] frame_no (uint32)
hdr.PktTotal = uint16(data[12]) | uint16(data[13])<<8
pktIdxOrMarker := uint16(data[14]) | uint16(data[15])<<8
hdr.PayloadSize = uint16(data[16]) | uint16(data[17])<<8
hdr.FrameNo = uint32(data[24]) | uint32(data[25])<<8 | uint32(data[26])<<16 | uint32(data[27])<<24
// 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40
if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 {
hdr.HasFrameInfo = true
if hdr.PktTotal > 0 {
hdr.PktIdx = hdr.PktTotal - 1 // Last packet
}
} else {
hdr.PktIdx = pktIdxOrMarker
}
} else {
// 36-Byte Header Layout:
// [20-21] pkt_total
// [22-23] pkt_idx OR 0x0028 (FrameInfo marker) - ONLY 0x0028 in End packets!
// [24-25] payload_size
// [32-35] frame_no (uint32) - GLOBAL frame counter, matches 28-byte [24-27]
// NOTE: [18-19] is channel-specific frame index, NOT used for reassembly!
hdr.PktTotal = uint16(data[20]) | uint16(data[21])<<8
pktIdxOrMarker := uint16(data[22]) | uint16(data[23])<<8
hdr.PayloadSize = uint16(data[24]) | uint16(data[25])<<8
hdr.FrameNo = uint32(data[32]) | uint32(data[33])<<8 | uint32(data[34])<<16 | uint32(data[35])<<24
// 0x0028 is FrameInfo marker ONLY in End packets, otherwise it's pkt_idx=40
if IsEndFrame(hdr.FrameType) && pktIdxOrMarker == 0x0028 {
hdr.HasFrameInfo = true
if hdr.PktTotal > 0 {
hdr.PktIdx = hdr.PktTotal - 1
}
} else {
hdr.PktIdx = pktIdxOrMarker
}
}
return hdr
}
+58
View File
@@ -413,6 +413,64 @@
</script> </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> <button id="xiaomi">Xiaomi</button>
<div> <div>
<form id="xiaomi-login-form"> <form id="xiaomi-login-form">
+14 -1
View File
@@ -249,7 +249,20 @@ export class VideoRTC extends HTMLElement {
this.appendChild(this.video); this.appendChild(this.video);
this.video.addEventListener('error', ev => { this.video.addEventListener('error', ev => {
console.warn(ev); const err = this.video.error;
console.error('[VideoRTC] Video error:', {
code: err ? err.code : 'unknown',
message: err ? err.message : 'unknown',
MEDIA_ERR_ABORTED: 1,
MEDIA_ERR_NETWORK: 2,
MEDIA_ERR_DECODE: 3,
MEDIA_ERR_SRC_NOT_SUPPORTED: 4,
codecs: this.mseCodecs || 'not set',
readyState: this.video.readyState,
networkState: this.video.networkState,
currentTime: this.video.currentTime,
event: ev
});
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
}); });