mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 23:57:20 +08:00
add wyze support
This commit is contained in:
@@ -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)*
|
||||||
|
|||||||
@@ -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},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 | |
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user