Add support xiaomi source
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Xiaomi
|
||||
|
||||
This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem.
|
||||
|
||||
**Important:**
|
||||
|
||||
1. **Not all cameras are supported**. There are several P2P protocol vendors in the Xiaomi ecosystem.
|
||||
Currently, the **CS2** vendor is supported. However, the **TUTK** vendor is not supported.
|
||||
2. Each time you connect to the camera, you need internet access to obtain encryption keys.
|
||||
3. Connection to the camera is local only.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Multiple Xiaomi accounts supported
|
||||
- Cameras from multiple regions are supported for a single account
|
||||
- Two-way audio is supported
|
||||
- Cameras with multiple lenses are supported
|
||||
|
||||
## Setup
|
||||
|
||||
1. Goto go2rtc WebUI > Add > Xiaomi > Login with username and password
|
||||
2. Receive verification code by email or phone if required.
|
||||
3. Complete the captcha if required.
|
||||
4. If everything is OK, your account will be added and you can load cameras from it.
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
xiaomi:
|
||||
1234567890: V1:***
|
||||
|
||||
streams:
|
||||
xiaomi1: xiaomi://1234567890:cn@192.168.1.123?did=9876543210&model=isa.camera.hlc7
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can change camera's quality: `subtype=hd/sd/auto`
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
xiaomi1: xiaomi://***&subtype=sd
|
||||
```
|
||||
|
||||
You can use second channel for Dual cameras: `channel=1`
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
xiaomi1: xiaomi://***&channel=1
|
||||
```
|
||||
@@ -0,0 +1,267 @@
|
||||
package xiaomi
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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/xiaomi"
|
||||
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var v struct {
|
||||
Cfg map[string]string `yaml:"xiaomi"`
|
||||
}
|
||||
app.LoadConfig(&v)
|
||||
|
||||
tokens = v.Cfg
|
||||
|
||||
log := app.GetLogger("xiaomi")
|
||||
|
||||
streams.HandleFunc("xiaomi", func(rawURL string) (core.Producer, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u.User != nil {
|
||||
rawURL, err = getCameraURL(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().Msgf("xiaomi: dial %s", rawURL)
|
||||
|
||||
return xiaomi.Dial(rawURL)
|
||||
})
|
||||
|
||||
api.HandleFunc("api/xiaomi", apiXiaomi)
|
||||
}
|
||||
|
||||
var tokens map[string]string
|
||||
var tokensMu sync.Mutex
|
||||
|
||||
func getCloud(userID string) (*xiaomi.Cloud, error) {
|
||||
tokensMu.Lock()
|
||||
defer tokensMu.Unlock()
|
||||
|
||||
token := tokens[userID]
|
||||
cloud := xiaomi.NewCloud(AppXiaomiHome)
|
||||
if err := cloud.LoginWithToken(userID, token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloud, nil
|
||||
}
|
||||
|
||||
func getCameraURL(url *url.URL) (string, error) {
|
||||
clientPublic, clientPrivate, err := miss.GenerateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := url.Query()
|
||||
|
||||
params := fmt.Sprintf(
|
||||
`{"app_pubkey":"%x","did":"%s","support_vendors":"CS2"}`,
|
||||
clientPublic, query.Get("did"),
|
||||
)
|
||||
|
||||
cloud, err := getCloud(url.User.Username())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
region, _ := url.User.Password()
|
||||
|
||||
res, err := cloud.Request(GetBaseURL(region), "/v2/device/miss_get_vendor", params, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var v struct {
|
||||
Vendor struct {
|
||||
VendorID byte `json:"vendor"`
|
||||
} `json:"vendor"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
if err = json.Unmarshal(res, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query.Set("client_public", hex.EncodeToString(clientPublic))
|
||||
query.Set("client_private", hex.EncodeToString(clientPrivate))
|
||||
query.Set("device_public", v.PublicKey)
|
||||
query.Set("sign", v.Sign)
|
||||
query.Set("vendor", getVendorName(v.Vendor.VendorID))
|
||||
|
||||
url.RawQuery = query.Encode()
|
||||
return url.String(), nil
|
||||
}
|
||||
|
||||
func getVendorName(i byte) string {
|
||||
switch i {
|
||||
case 1:
|
||||
return "tutk"
|
||||
case 3:
|
||||
return "agora"
|
||||
case 4:
|
||||
return "cs2"
|
||||
case 6:
|
||||
return "mtp"
|
||||
}
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
func apiXiaomi(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()
|
||||
|
||||
user := query.Get("id")
|
||||
if user == "" {
|
||||
tokensMu.Lock()
|
||||
users := make([]string, 0, len(tokens))
|
||||
for s := range tokens {
|
||||
users = append(users, s)
|
||||
}
|
||||
tokensMu.Unlock()
|
||||
|
||||
api.ResponseJSON(w, users)
|
||||
return
|
||||
}
|
||||
|
||||
err := func() error {
|
||||
cloud, err := getCloud(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
region := query.Get("region")
|
||||
|
||||
res, err := cloud.Request(GetBaseURL(region), "/v2/home/device_list_page", "{}", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var v struct {
|
||||
List []*Device `json:"list"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(res, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var items []*api.Source
|
||||
|
||||
for _, device := range v.List {
|
||||
if !strings.Contains(device.Model, ".camera.") {
|
||||
continue
|
||||
}
|
||||
items = append(items, &api.Source{
|
||||
Name: device.Name,
|
||||
Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC),
|
||||
URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s", user, region, device.IP, device.Did, device.Model),
|
||||
})
|
||||
}
|
||||
|
||||
api.ResponseSources(w, items)
|
||||
return nil
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
Did string `json:"did"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"localip"`
|
||||
}
|
||||
|
||||
var auth *xiaomi.Cloud
|
||||
|
||||
func apiAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.Form.Get("username")
|
||||
password := r.Form.Get("password")
|
||||
captcha := r.Form.Get("captcha")
|
||||
verify := r.Form.Get("verify")
|
||||
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case username != "" || password != "":
|
||||
auth = xiaomi.NewCloud(AppXiaomiHome)
|
||||
err = auth.Login(username, password)
|
||||
case captcha != "":
|
||||
err = auth.LoginWithCaptcha(captcha)
|
||||
case verify != "":
|
||||
err = auth.LoginWithVerify(verify)
|
||||
default:
|
||||
http.Error(w, "wrong request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
userID, token := auth.UserToken()
|
||||
auth = nil
|
||||
|
||||
tokensMu.Lock()
|
||||
if tokens == nil {
|
||||
tokens = map[string]string{userID: token}
|
||||
} else {
|
||||
tokens[userID] = token
|
||||
}
|
||||
tokensMu.Unlock()
|
||||
|
||||
err = app.PatchConfig([]string{"xiaomi", userID}, token)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var login *xiaomi.LoginError
|
||||
if errors.As(err, &login) {
|
||||
w.Header().Set("Content-Type", api.MimeJSON)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(err)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
const AppXiaomiHome = "xiaomiio"
|
||||
|
||||
func GetBaseURL(region string) string {
|
||||
switch region {
|
||||
case "de", "i2", "ru", "sg", "us":
|
||||
return "https://" + region + ".api.io.mi.com/app"
|
||||
}
|
||||
return "https://api.io.mi.com/app"
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/internal/webtorrent"
|
||||
"github.com/AlexxIT/go2rtc/internal/wyoming"
|
||||
"github.com/AlexxIT/go2rtc/internal/xiaomi"
|
||||
"github.com/AlexxIT/go2rtc/internal/yandex"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
@@ -98,6 +99,7 @@ func main() {
|
||||
{"roborock", roborock.Init},
|
||||
{"tapo", tapo.Init},
|
||||
{"tuya", tuya.Init},
|
||||
{"xiaomi", xiaomi.Init},
|
||||
{"yandex", yandex.Init},
|
||||
// Helper modules
|
||||
{"debug", debug.Init},
|
||||
|
||||
@@ -67,6 +67,21 @@ func Atoi(s string) (i int) {
|
||||
return
|
||||
}
|
||||
|
||||
// ParseByte - fast parsing string to byte function
|
||||
func ParseByte(s string) (b byte) {
|
||||
for i, ch := range []byte(s) {
|
||||
ch -= '0'
|
||||
if ch > 9 {
|
||||
return 0
|
||||
}
|
||||
if i > 0 {
|
||||
b *= 10
|
||||
}
|
||||
b += ch
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Assert(ok bool) {
|
||||
if !ok {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package xiaomi
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
const size8bit40ms = 8000 * 0.040
|
||||
|
||||
func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
if err := p.client.SpeakerStart(); err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO: check this!!!
|
||||
time.Sleep(time.Second)
|
||||
|
||||
sender := core.NewSender(media, track.Codec)
|
||||
|
||||
switch track.Codec.Name {
|
||||
case core.CodecPCMA:
|
||||
var buf []byte
|
||||
|
||||
sender.Handler = func(pkt *rtp.Packet) {
|
||||
buf = append(buf, pkt.Payload...)
|
||||
for len(buf) >= size8bit40ms {
|
||||
_ = p.client.WriteAudio(miss.CodecPCMA, buf[:size8bit40ms])
|
||||
buf = buf[size8bit40ms:]
|
||||
}
|
||||
}
|
||||
case core.CodecOpus:
|
||||
sender.Handler = func(pkt *rtp.Packet) {
|
||||
_ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
p.Senders = append(p.Senders, sender)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package xiaomi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/rc4"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Cloud struct {
|
||||
client *http.Client
|
||||
|
||||
sid string
|
||||
cookies string // for auth
|
||||
ssecurity []byte // for encryption
|
||||
|
||||
userID string
|
||||
passToken string
|
||||
|
||||
auth map[string]string
|
||||
}
|
||||
|
||||
func NewCloud(sid string) *Cloud {
|
||||
return &Cloud{
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
sid: sid,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cloud) Login(username, password string) error {
|
||||
res, err := c.client.Get("https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=" + c.sid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v1 struct {
|
||||
Qs string `json:"qs"`
|
||||
Sign string `json:"_sign"`
|
||||
Sid string `json:"sid"`
|
||||
Callback string `json:"callback"`
|
||||
}
|
||||
if _, err = readLoginResponse(res.Body, &v1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash := fmt.Sprintf("%X", md5.Sum([]byte(password)))
|
||||
|
||||
form := url.Values{
|
||||
"_json": {"true"},
|
||||
"hash": {hash},
|
||||
"sid": {v1.Sid},
|
||||
"callback": {v1.Callback},
|
||||
"_sign": {v1.Sign},
|
||||
"qs": {v1.Qs},
|
||||
"user": {username},
|
||||
}
|
||||
cookies := "deviceId=" + core.RandString(16, 62)
|
||||
|
||||
// login after captcha
|
||||
if c.auth != nil && c.auth["captcha_code"] != "" {
|
||||
form.Set("captCode", c.auth["captcha_code"])
|
||||
cookies += "; ick=" + c.auth["ick"]
|
||||
}
|
||||
|
||||
req := Request{
|
||||
Method: "POST",
|
||||
URL: "https://account.xiaomi.com/pass/serviceLoginAuth2",
|
||||
RawBody: form.Encode(),
|
||||
Headers: url.Values{
|
||||
"Content-Type": {"application/x-www-form-urlencoded"},
|
||||
},
|
||||
RawCookies: cookies,
|
||||
}.Encode()
|
||||
|
||||
res, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v2 struct {
|
||||
Ssecurity []byte `json:"ssecurity"`
|
||||
PassToken string `json:"passToken"`
|
||||
Location string `json:"location"`
|
||||
|
||||
CaptchaURL string `json:"captchaURL"`
|
||||
NotificationURL string `json:"notificationUrl"`
|
||||
}
|
||||
body, err := readLoginResponse(res.Body, &v2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// save auth for two step verification
|
||||
c.auth = map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
if v2.CaptchaURL != "" {
|
||||
return c.getCaptcha(v2.CaptchaURL)
|
||||
}
|
||||
|
||||
if v2.NotificationURL != "" {
|
||||
return c.authStart(v2.NotificationURL)
|
||||
}
|
||||
|
||||
if v2.Location == "" {
|
||||
return fmt.Errorf("xiaomi: %s", body)
|
||||
}
|
||||
|
||||
c.auth = nil
|
||||
c.ssecurity = v2.Ssecurity
|
||||
c.passToken = v2.PassToken
|
||||
|
||||
return c.finishAuth(v2.Location)
|
||||
}
|
||||
|
||||
func (c *Cloud) LoginWithCaptcha(captcha string) error {
|
||||
if c.auth == nil || c.auth["ick"] == "" {
|
||||
panic("wrong login step")
|
||||
}
|
||||
|
||||
c.auth["captcha_code"] = captcha
|
||||
|
||||
// check if captcha after verify
|
||||
if c.auth["flag"] != "" {
|
||||
return c.sendTicket()
|
||||
}
|
||||
|
||||
return c.Login(c.auth["username"], c.auth["password"])
|
||||
}
|
||||
|
||||
func (c *Cloud) LoginWithVerify(ticket string) error {
|
||||
if c.auth == nil || c.auth["flag"] == "" {
|
||||
panic("wrong login step")
|
||||
}
|
||||
|
||||
req := Request{
|
||||
Method: "POST",
|
||||
URL: "https://account.xiaomi.com/identity/auth/verify" + c.verifyName(),
|
||||
RawParams: "_flag" + c.auth["flag"] + "&ticket=" + ticket + "&trust=false&_json=true",
|
||||
RawCookies: "identity_session=" + c.auth["identity_session"],
|
||||
}.Encode()
|
||||
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v1 struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
body, err := readLoginResponse(res.Body, &v1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v1.Location == "" {
|
||||
return fmt.Errorf("xiaomi: %s", body)
|
||||
}
|
||||
|
||||
return c.finishAuth(v1.Location)
|
||||
}
|
||||
|
||||
func (c *Cloud) getCaptcha(captchaURL string) error {
|
||||
res, err := c.client.Get("https://account.xiaomi.com" + captchaURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.auth["ick"] = findCookie(res, "ick")
|
||||
|
||||
return &LoginError{
|
||||
Captcha: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cloud) authStart(notificationURL string) error {
|
||||
rawURL := strings.Replace(notificationURL, "/fe/service/identity/authStart", "/identity/list", 1)
|
||||
res, err := c.client.Get(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v1 struct {
|
||||
Code int `json:"code"`
|
||||
Flag int `json:"flag"`
|
||||
}
|
||||
if _, err = readLoginResponse(res.Body, &v1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.auth["flag"] = strconv.Itoa(v1.Flag)
|
||||
c.auth["identity_session"] = findCookie(res, "identity_session")
|
||||
|
||||
return c.sendTicket()
|
||||
}
|
||||
|
||||
func findCookie(res *http.Response, name string) string {
|
||||
for _, cookie := range res.Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Cloud) verifyName() string {
|
||||
switch c.auth["flag"] {
|
||||
case "4":
|
||||
return "Phone"
|
||||
case "8":
|
||||
return "Email"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Cloud) sendTicket() error {
|
||||
name := c.verifyName()
|
||||
cookies := "identity_session=" + c.auth["identity_session"]
|
||||
|
||||
req := Request{
|
||||
URL: "https://account.xiaomi.com/identity/auth/verify" + name,
|
||||
RawParams: "_flag=" + c.auth["flag"] + "&_json=true",
|
||||
RawCookies: cookies,
|
||||
}.Encode()
|
||||
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v1 struct {
|
||||
Code int `json:"code"`
|
||||
MaskedPhone string `json:"maskedPhone"`
|
||||
MaskedEmail string `json:"maskedEmail"`
|
||||
}
|
||||
if _, err = readLoginResponse(res.Body, &v1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// verify after captcha
|
||||
captCode := c.auth["captcha_code"]
|
||||
if captCode != "" {
|
||||
cookies += "; ick=" + c.auth["ick"]
|
||||
}
|
||||
|
||||
req = Request{
|
||||
Method: "POST",
|
||||
URL: "https://account.xiaomi.com/identity/auth/send" + name + "Ticket",
|
||||
RawCookies: cookies,
|
||||
RawBody: `{"retry":0,"icode":"` + captCode + `","_json":"true"}`,
|
||||
}.Encode()
|
||||
|
||||
res, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v2 struct {
|
||||
Code int `json:"code"`
|
||||
CaptchaURL string `json:"captchaURL"`
|
||||
}
|
||||
body, err := readLoginResponse(res.Body, &v2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v2.CaptchaURL != "" {
|
||||
return c.getCaptcha(v2.CaptchaURL)
|
||||
}
|
||||
|
||||
if v2.Code != 0 {
|
||||
return fmt.Errorf("xiaomi: %s", body)
|
||||
}
|
||||
|
||||
return &LoginError{
|
||||
VerifyPhone: v1.MaskedPhone,
|
||||
VerifyEmail: v1.MaskedEmail,
|
||||
}
|
||||
}
|
||||
|
||||
type LoginError struct {
|
||||
Captcha []byte `json:"captcha,omitempty"`
|
||||
VerifyPhone string `json:"verify_phone,omitempty"`
|
||||
VerifyEmail string `json:"verify_email,omitempty"`
|
||||
}
|
||||
|
||||
func (l *LoginError) Error() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Cloud) finishAuth(location string) error {
|
||||
res, err := c.client.Get(location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
// LoginWithVerify
|
||||
// - userId, cUserId, serviceToken from cookies
|
||||
// - passToken from redirect cookies
|
||||
// - ssecurity from extra header
|
||||
// LoginWithToken
|
||||
// - userId, cUserId, serviceToken from cookies
|
||||
var cUserId, serviceToken string
|
||||
|
||||
for res != nil {
|
||||
for _, cookie := range res.Cookies() {
|
||||
switch cookie.Name {
|
||||
case "userId":
|
||||
c.userID = cookie.Value
|
||||
case "cUserId":
|
||||
cUserId = cookie.Value
|
||||
case "serviceToken":
|
||||
serviceToken = cookie.Value
|
||||
case "passToken":
|
||||
c.passToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
if s := res.Header.Get("Extension-Pragma"); s != "" {
|
||||
var v1 struct {
|
||||
Ssecurity []byte `json:"ssecurity"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(s), &v1); err != nil {
|
||||
return err
|
||||
}
|
||||
c.ssecurity = v1.Ssecurity
|
||||
}
|
||||
|
||||
res = res.Request.Response
|
||||
}
|
||||
|
||||
c.cookies = fmt.Sprintf("userId=%s; cUserId=%s; serviceToken=%s", c.userID, cUserId, serviceToken)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cloud) LoginWithToken(userID, passToken string) error {
|
||||
req, err := http.NewRequest("GET", "https://account.xiaomi.com/pass/serviceLogin?_json=true&sid="+c.sid, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Cookie", fmt.Sprintf("userId=%s; passToken=%s", userID, passToken))
|
||||
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v1 struct {
|
||||
Ssecurity []byte `json:"ssecurity"`
|
||||
PassToken string `json:"passToken"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
if _, err = readLoginResponse(res.Body, &v1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.ssecurity = v1.Ssecurity
|
||||
c.passToken = v1.PassToken
|
||||
|
||||
return c.finishAuth(v1.Location)
|
||||
}
|
||||
|
||||
func (c *Cloud) UserToken() (string, string) {
|
||||
return c.userID, c.passToken
|
||||
}
|
||||
|
||||
func (c *Cloud) Request(baseURL, apiURL, params string, headers map[string]string) ([]byte, error) {
|
||||
form := url.Values{"data": {params}}
|
||||
|
||||
nonce := genNonce()
|
||||
signedNonce := genSignedNonce(c.ssecurity, nonce)
|
||||
|
||||
// 1. gen hash for data param
|
||||
form.Set("rc4_hash__", genSignature64("POST", apiURL, form, signedNonce))
|
||||
|
||||
// 2. encrypt data and hash params
|
||||
for _, v := range form {
|
||||
ciphertext, err := crypt(signedNonce, []byte(v[0]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v[0] = base64.StdEncoding.EncodeToString(ciphertext)
|
||||
}
|
||||
|
||||
// 3. add signature for encrypted data and hash params
|
||||
form.Set("signature", genSignature64("POST", apiURL, form, signedNonce))
|
||||
|
||||
// 4. add nonce
|
||||
form.Set("_nonce", base64.StdEncoding.EncodeToString(nonce))
|
||||
|
||||
req, err := http.NewRequest("POST", baseURL+apiURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Cookie", c.cookies)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(res.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plaintext, err := crypt(signedNonce, ciphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res1 struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err = json.Unmarshal(plaintext, &res1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res1.Code != 0 {
|
||||
return nil, errors.New("xiaomi: " + res1.Message)
|
||||
}
|
||||
|
||||
return res1.Result, nil
|
||||
}
|
||||
|
||||
func readLoginResponse(rc io.ReadCloser, v any) ([]byte, error) {
|
||||
defer rc.Close()
|
||||
|
||||
body, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, ok := bytes.CutPrefix(body, []byte("&&&START&&&"))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("xiaomi: %s", body)
|
||||
}
|
||||
|
||||
return body, json.Unmarshal(body, &v)
|
||||
}
|
||||
|
||||
func genNonce() []byte {
|
||||
ts := time.Now().Unix() / 60
|
||||
|
||||
nonce := make([]byte, 12)
|
||||
_, _ = rand.Read(nonce[:8])
|
||||
binary.BigEndian.PutUint32(nonce[8:], uint32(ts))
|
||||
return nonce
|
||||
}
|
||||
|
||||
func genSignedNonce(ssecurity, nonce []byte) []byte {
|
||||
hasher := sha256.New()
|
||||
hasher.Write(ssecurity)
|
||||
hasher.Write(nonce)
|
||||
return hasher.Sum(nil)
|
||||
}
|
||||
|
||||
func crypt(key, plaintext []byte) ([]byte, error) {
|
||||
cipher, err := rc4.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmp := make([]byte, 1024)
|
||||
cipher.XORKeyStream(tmp, tmp)
|
||||
|
||||
ciphertext := make([]byte, len(plaintext))
|
||||
cipher.XORKeyStream(ciphertext, plaintext)
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func genSignature64(method, path string, values url.Values, signedNonce []byte) string {
|
||||
s := method + "&" + path + "&data=" + values.Get("data")
|
||||
if values.Has("rc4_hash__") {
|
||||
s += "&rc4_hash__=" + values.Get("rc4_hash__")
|
||||
}
|
||||
s += "&" + base64.StdEncoding.EncodeToString(signedNonce)
|
||||
|
||||
hasher := sha1.New()
|
||||
hasher.Write([]byte(s))
|
||||
signature := hasher.Sum(nil)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(signature)
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Method string
|
||||
URL string
|
||||
RawParams string
|
||||
RawBody string
|
||||
Headers url.Values
|
||||
RawCookies string
|
||||
}
|
||||
|
||||
func (r Request) Encode() *http.Request {
|
||||
if r.RawParams != "" {
|
||||
r.URL += "?" + r.RawParams
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if r.RawBody != "" {
|
||||
body = strings.NewReader(r.RawBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(r.Method, r.URL, body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.Headers != nil {
|
||||
req.Header = http.Header(r.Headers)
|
||||
}
|
||||
|
||||
if r.RawCookies != "" {
|
||||
req.Header.Set("Cookie", r.RawCookies)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package miss
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"golang.org/x/crypto/chacha20"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
if query.Get("vendor") != "cs2" {
|
||||
return nil, fmt.Errorf("miss: unsupported vendor")
|
||||
}
|
||||
|
||||
clientPrivate := query.Get("client_private")
|
||||
devicePublic := query.Get("device_public")
|
||||
|
||||
key, err := calcSharedKey(devicePublic, clientPrivate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
conn: conn,
|
||||
addr: &net.UDPAddr{IP: net.ParseIP(u.Host), Port: 32108},
|
||||
buf: make([]byte, 1500),
|
||||
key: key,
|
||||
}
|
||||
|
||||
clientPublic := query.Get("client_public")
|
||||
sign := query.Get("sign")
|
||||
|
||||
if err = client.login(clientPublic, sign); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.chSeq0 = 1
|
||||
client.chRaw2 = make(chan []byte, 100)
|
||||
go client.worker()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
const (
|
||||
CodecH264 = 4
|
||||
CodecH265 = 5
|
||||
CodecPCM = 1024
|
||||
CodecPCMU = 1026
|
||||
CodecPCMA = 1027
|
||||
CodecOPUS = 1032
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *net.UDPConn
|
||||
addr *net.UDPAddr
|
||||
buf []byte
|
||||
key []byte // shared key
|
||||
|
||||
chSeq0 uint16
|
||||
chSeq3 uint16
|
||||
chRaw2 chan []byte
|
||||
}
|
||||
|
||||
func (c *Client) RemoteAddr() *net.UDPAddr {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
func (c *Client) SetDeadline(t time.Time) error {
|
||||
return c.conn.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
const (
|
||||
magic = 0xF1
|
||||
magicDrw = 0xD1
|
||||
msgLanSearch = 0x30
|
||||
msgPunchPkt = 0x41
|
||||
msgP2PRdy = 0x42
|
||||
msgDrw = 0xD0
|
||||
msgDrwAck = 0xD1
|
||||
msgAlive = 0xE0
|
||||
|
||||
cmdAuthReq = 0x100
|
||||
cmdAuthRes = 0x101
|
||||
cmdVideoStart = 0x102
|
||||
cmdVideoStop = 0x103
|
||||
cmdAudioStart = 0x104
|
||||
cmdAudioStop = 0x105
|
||||
cmdSpeakerStartReq = 0x106
|
||||
cmdSpeakerStartRes = 0x107
|
||||
cmdSpeakerStop = 0x108
|
||||
cmdStreamCtrlReq = 0x109
|
||||
cmdStreamCtrlRes = 0x10A
|
||||
cmdGetAudioFormatReq = 0x10B
|
||||
cmdGetAudioFormatRes = 0x10C
|
||||
cmdPlaybackReq = 0x10D
|
||||
cmdPlaybackRes = 0x10E
|
||||
cmdDevInfoReq = 0x110
|
||||
cmdDevInfoRes = 0x111
|
||||
cmdMotorReq = 0x112
|
||||
cmdMotorRes = 0x113
|
||||
cmdEncoded = 0x1001
|
||||
)
|
||||
|
||||
func (c *Client) login(clientPublic, sign string) error {
|
||||
_ = c.conn.SetDeadline(time.Now().Add(core.ConnDialTimeout))
|
||||
|
||||
buf, err := c.writeAndWait([]byte{magic, msgLanSearch, 0, 0}, msgPunchPkt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("miss: read punch: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.writeAndWait(buf, msgP2PRdy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("miss: read ready: %w", err)
|
||||
}
|
||||
|
||||
_, _ = c.conn.WriteToUDP([]byte{magic, msgAlive, 0, 0}, c.addr)
|
||||
|
||||
s := fmt.Sprintf(`{"public_key":"%s","sign":"%s","uuid":"","support_encrypt":0}`, clientPublic, sign)
|
||||
buf, err = c.writeAndWait(marshalCmd(0, 0, cmdAuthReq, []byte(s)), msgDrw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("miss: read auth: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(buf[16:]), `"result":"success"`) {
|
||||
return fmt.Errorf("miss: read auth: %s", buf[16:])
|
||||
}
|
||||
|
||||
_, _ = c.conn.WriteToUDP([]byte{magic, msgDrwAck, 0, 6, magicDrw, 0, 0, 1, 0, 0}, c.addr)
|
||||
|
||||
_ = c.conn.SetDeadline(time.Time{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) writeAndWait(b []byte, waitMsg uint8) ([]byte, error) {
|
||||
if _, err := c.conn.WriteToUDP(b, c.addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
n, addr, err := c.conn.ReadFromUDP(c.buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(addr.IP) != string(c.addr.IP) {
|
||||
continue // skip messages from another IP
|
||||
}
|
||||
|
||||
if n >= 16 && c.buf[0] == magic && c.buf[1] == waitMsg {
|
||||
if waitMsg == msgPunchPkt {
|
||||
c.addr.Port = addr.Port
|
||||
}
|
||||
return c.buf[:n], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) VideoStart(channel, quality, audio uint8) error {
|
||||
buf := binary.BigEndian.AppendUint32(nil, cmdVideoStart)
|
||||
if channel == 0 {
|
||||
buf = fmt.Appendf(buf, `{"videoquality":%d,"enableaudio":%d}`, quality, audio)
|
||||
} else {
|
||||
buf = fmt.Appendf(buf, `{"videoquality":-1,"videoquality2":%d,"enableaudio":%d}`, quality, audio)
|
||||
}
|
||||
buf, err := encode(c.key, buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf = marshalCmd(0, c.chSeq0, cmdEncoded, buf)
|
||||
c.chSeq0++
|
||||
|
||||
_, err = c.conn.WriteToUDP(buf, c.addr)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SpeakerStart() error {
|
||||
buf := binary.BigEndian.AppendUint32(nil, cmdSpeakerStartReq)
|
||||
buf, err := encode(c.key, buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf = marshalCmd(0, c.chSeq0, cmdEncoded, buf)
|
||||
c.chSeq0++
|
||||
|
||||
_, err = c.conn.WriteToUDP(buf, c.addr)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) ReadPacket() (*Packet, error) {
|
||||
b, ok := <-c.chRaw2
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("miss: read raw: i/o timeout")
|
||||
}
|
||||
return unmarshalPacket(c.key, b)
|
||||
}
|
||||
|
||||
func unmarshalPacket(key, b []byte) (*Packet, error) {
|
||||
n := uint32(len(b))
|
||||
|
||||
if n < 32 {
|
||||
return nil, fmt.Errorf("miss: packet header too small")
|
||||
}
|
||||
|
||||
if l := binary.LittleEndian.Uint32(b); l+32 != n {
|
||||
return nil, fmt.Errorf("miss: packet payload has wrong length")
|
||||
}
|
||||
|
||||
payload, err := decode(key, b[32:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Packet{
|
||||
CodecID: binary.LittleEndian.Uint32(b[4:]),
|
||||
Sequence: binary.LittleEndian.Uint32(b[8:]),
|
||||
Flags: binary.LittleEndian.Uint32(b[12:]),
|
||||
Timestamp: binary.LittleEndian.Uint64(b[16:]),
|
||||
Payload: payload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) WriteAudio(codecID uint32, payload []byte) error {
|
||||
payload, err := encode(c.key, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n := uint32(len(payload))
|
||||
|
||||
const hdrOffset = 12
|
||||
const hdrSize = 32
|
||||
|
||||
buf := make([]byte, n+hdrOffset+hdrSize)
|
||||
buf[0] = magic
|
||||
buf[1] = msgDrw
|
||||
binary.BigEndian.PutUint16(buf[2:], uint16(n+8+hdrSize))
|
||||
|
||||
buf[4] = magicDrw
|
||||
buf[5] = 3 // channel
|
||||
binary.BigEndian.PutUint16(buf[6:], c.chSeq3)
|
||||
|
||||
binary.BigEndian.PutUint32(buf[8:], n+hdrSize)
|
||||
|
||||
binary.LittleEndian.PutUint32(buf[hdrOffset:], n)
|
||||
binary.LittleEndian.PutUint32(buf[hdrOffset+4:], codecID)
|
||||
binary.LittleEndian.PutUint64(buf[hdrOffset+16:], uint64(time.Now().UnixMilli()))
|
||||
copy(buf[hdrOffset+hdrSize:], payload)
|
||||
|
||||
c.chSeq3++
|
||||
|
||||
_, err = c.conn.WriteToUDP(buf, c.addr)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) worker() {
|
||||
defer close(c.chRaw2)
|
||||
|
||||
chAck := []uint16{1, 0, 0, 0}
|
||||
|
||||
var ch2WaitSize int
|
||||
var ch2WaitData []byte
|
||||
|
||||
for {
|
||||
n, addr, err := c.conn.ReadFromUDP(c.buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
//log.Printf("<- %.20x...", c.buf[:n])
|
||||
|
||||
if string(addr.IP) != string(c.addr.IP) || n < 8 || c.buf[0] != magic {
|
||||
//log.Printf("unknown msg: %x", c.buf[:n])
|
||||
continue // skip messages from another IP
|
||||
}
|
||||
|
||||
switch c.buf[1] {
|
||||
case msgDrw:
|
||||
ch := c.buf[5]
|
||||
seqHI := c.buf[6]
|
||||
seqLO := c.buf[7]
|
||||
|
||||
if chAck[ch] != uint16(seqHI)<<8|uint16(seqLO) {
|
||||
continue
|
||||
}
|
||||
chAck[ch]++
|
||||
|
||||
//log.Printf("%.40x", c.buf)
|
||||
|
||||
ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO}
|
||||
if _, err = c.conn.WriteToUDP(ack, c.addr); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case 0:
|
||||
//log.Printf("data ch0 %x", c.buf[:n])
|
||||
//size := binary.BigEndian.Uint32(c.buf[8:])
|
||||
//if binary.BigEndian.Uint32(c.buf[12:]) == cmdEncoded {
|
||||
// raw, _ := decode(c.key, c.buf[16:12+size])
|
||||
// log.Printf("cmd enc %x", raw)
|
||||
//} else {
|
||||
// log.Printf("cmd raw %x", c.buf[12:12+size])
|
||||
//}
|
||||
|
||||
case 2:
|
||||
ch2WaitData = append(ch2WaitData, c.buf[8:n]...)
|
||||
|
||||
for len(ch2WaitData) > 4 {
|
||||
if ch2WaitSize == 0 {
|
||||
ch2WaitSize = int(binary.BigEndian.Uint32(ch2WaitData))
|
||||
ch2WaitData = ch2WaitData[4:]
|
||||
}
|
||||
if ch2WaitSize <= len(ch2WaitData) {
|
||||
c.chRaw2 <- ch2WaitData[:ch2WaitSize]
|
||||
ch2WaitData = ch2WaitData[ch2WaitSize:]
|
||||
ch2WaitSize = 0
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("!!! unknown chanel: %x", c.buf[:n])
|
||||
}
|
||||
|
||||
case msgDrwAck: // skip it
|
||||
|
||||
default:
|
||||
log.Printf("!!! unknown msg type: %x", c.buf[:n])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func marshalCmd(channel byte, seq uint16, cmd uint32, payload []byte) []byte {
|
||||
size := len(payload)
|
||||
buf := make([]byte, 4+4+4+4+size)
|
||||
|
||||
// 1. message header (4 bytes)
|
||||
buf[0] = magic
|
||||
buf[1] = msgDrw
|
||||
binary.BigEndian.PutUint16(buf[2:], uint16(4+4+4+size))
|
||||
|
||||
// 2. drw? header (4 bytes)
|
||||
buf[4] = magicDrw
|
||||
buf[5] = channel
|
||||
binary.BigEndian.PutUint16(buf[6:], seq)
|
||||
|
||||
// 3. payload size (4 bytes)
|
||||
binary.BigEndian.PutUint32(buf[8:], uint32(4+size))
|
||||
|
||||
// 4. payload command (4 bytes)
|
||||
binary.BigEndian.PutUint32(buf[12:], cmd)
|
||||
|
||||
// 5. payload
|
||||
copy(buf[16:], payload)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func calcSharedKey(devicePublic, clientPrivate string) ([]byte, error) {
|
||||
var sharedKey, publicKey, privateKey [32]byte
|
||||
if _, err := hex.Decode(publicKey[:], []byte(devicePublic)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := hex.Decode(privateKey[:], []byte(clientPrivate)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
box.Precompute(&sharedKey, &publicKey, &privateKey)
|
||||
return sharedKey[:], nil
|
||||
}
|
||||
|
||||
func encode(key, src []byte) ([]byte, error) {
|
||||
dst := make([]byte, len(src)+8)
|
||||
|
||||
if _, err := rand.Read(dst[:8]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, 12)
|
||||
copy(nonce[4:], dst[:8])
|
||||
|
||||
c, err := chacha20.NewUnauthenticatedCipher(key, nonce)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.XORKeyStream(dst[8:], src)
|
||||
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func decode(key, src []byte) ([]byte, error) {
|
||||
nonce := make([]byte, 12)
|
||||
copy(nonce[4:], src[:8])
|
||||
|
||||
c, err := chacha20.NewUnauthenticatedCipher(key, nonce)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dst := make([]byte, len(src)-8)
|
||||
c.XORKeyStream(dst, src[8:])
|
||||
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
type Packet struct {
|
||||
//Length uint32
|
||||
CodecID uint32
|
||||
Sequence uint32
|
||||
Flags uint32
|
||||
Timestamp uint64 // msec
|
||||
//TimestampS uint32
|
||||
//Reserved uint32
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func GenerateKey() ([]byte, []byte, error) {
|
||||
public, private, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return public[:], private[:], err
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package xiaomi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"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/xiaomi/miss"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
core.Connection
|
||||
client *miss.Client
|
||||
}
|
||||
|
||||
func Dial(rawURL string) (core.Producer, error) {
|
||||
client, err := miss.Dial(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, _ := url.Parse(rawURL)
|
||||
query := u.Query()
|
||||
|
||||
// 0 - main, 1 - second
|
||||
channel := core.ParseByte(query.Get("channel"))
|
||||
|
||||
// 0 - auto, 1 - worst, 3 or 5 - best
|
||||
var quality byte
|
||||
switch s := query.Get("subtype"); s {
|
||||
case "", "hd":
|
||||
quality = 3
|
||||
case "sd":
|
||||
quality = 1
|
||||
case "auto":
|
||||
quality = 0
|
||||
default:
|
||||
quality = core.ParseByte(s)
|
||||
}
|
||||
|
||||
medias, err := probe(client, channel, quality)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Producer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "xiaomi",
|
||||
Protocol: "cs2+udp",
|
||||
RemoteAddr: client.RemoteAddr().String(),
|
||||
Source: rawURL,
|
||||
Medias: medias,
|
||||
Transport: client,
|
||||
},
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func probe(client *miss.Client, channel, quality uint8) ([]*core.Media, error) {
|
||||
_ = client.SetDeadline(time.Now().Add(core.ProbeTimeout))
|
||||
|
||||
if err := client.VideoStart(channel, quality, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var video, audio *core.Codec
|
||||
|
||||
for {
|
||||
pkt, err := client.ReadPacket()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xiaomi: probe: %w", err)
|
||||
}
|
||||
|
||||
switch pkt.CodecID {
|
||||
case miss.CodecH264:
|
||||
if video == nil {
|
||||
buf := annexb.EncodeToAVCC(pkt.Payload)
|
||||
if h264.NALUType(buf) == h264.NALUTypeSPS {
|
||||
video = h264.AVCCToCodec(buf)
|
||||
}
|
||||
}
|
||||
case miss.CodecH265:
|
||||
if video == nil {
|
||||
buf := annexb.EncodeToAVCC(pkt.Payload)
|
||||
if h265.NALUType(buf) == h265.NALUTypeVPS {
|
||||
video = h265.AVCCToCodec(buf)
|
||||
}
|
||||
}
|
||||
case miss.CodecPCMA:
|
||||
if audio == nil {
|
||||
audio = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000}
|
||||
}
|
||||
case miss.CodecOPUS:
|
||||
if audio == nil {
|
||||
audio = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2}
|
||||
}
|
||||
}
|
||||
|
||||
if video != nil && audio != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_ = client.SetDeadline(time.Time{})
|
||||
|
||||
return []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{video},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{audio},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{audio.Clone()},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
const timestamp40ms = 48000 * 0.040
|
||||
|
||||
func (p *Producer) Start() error {
|
||||
var audioTS uint32
|
||||
|
||||
for {
|
||||
_ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline))
|
||||
pkt, err := p.client.ReadPacket()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: rewrite this
|
||||
var name string
|
||||
var pkt2 *core.Packet
|
||||
|
||||
switch pkt.CodecID {
|
||||
case miss.CodecH264:
|
||||
name = core.CodecH264
|
||||
pkt2 = &core.Packet{
|
||||
Header: rtp.Header{
|
||||
SequenceNumber: uint16(pkt.Sequence),
|
||||
Timestamp: TimeToRTP(pkt.Timestamp, 90000),
|
||||
},
|
||||
Payload: annexb.EncodeToAVCC(pkt.Payload),
|
||||
}
|
||||
case miss.CodecH265:
|
||||
name = core.CodecH265
|
||||
pkt2 = &core.Packet{
|
||||
Header: rtp.Header{
|
||||
SequenceNumber: uint16(pkt.Sequence),
|
||||
Timestamp: TimeToRTP(pkt.Timestamp, 90000),
|
||||
},
|
||||
Payload: annexb.EncodeToAVCC(pkt.Payload),
|
||||
}
|
||||
case miss.CodecPCMA:
|
||||
name = core.CodecPCMA
|
||||
pkt2 = &core.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
SequenceNumber: uint16(pkt.Sequence),
|
||||
Timestamp: audioTS,
|
||||
},
|
||||
Payload: pkt.Payload,
|
||||
}
|
||||
audioTS += uint32(len(pkt.Payload))
|
||||
case miss.CodecOPUS:
|
||||
name = core.CodecOpus
|
||||
pkt2 = &core.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
SequenceNumber: uint16(pkt.Sequence),
|
||||
Timestamp: audioTS,
|
||||
},
|
||||
Payload: pkt.Payload,
|
||||
}
|
||||
// known cameras sends packets with 40ms long
|
||||
audioTS += timestamp40ms
|
||||
}
|
||||
|
||||
for _, recv := range p.Receivers {
|
||||
if recv.Codec.Name == name {
|
||||
recv.WriteRTP(pkt2)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TimeToRTP convert time in milliseconds to RTP time
|
||||
func TimeToRTP(timeMS, clockRate uint64) uint32 {
|
||||
return uint32(timeMS * clockRate / 1000)
|
||||
}
|
||||
@@ -413,6 +413,88 @@
|
||||
</script>
|
||||
|
||||
|
||||
<button id="xiaomi">Xiaomi</button>
|
||||
<div>
|
||||
<form id="xiaomi-login-form">
|
||||
<input type="text" name="username" placeholder="username" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<form id="xiaomi-captcha-form">
|
||||
<img id="xiaomi-captcha">
|
||||
<input type="text" name="captcha" placeholder="captcha" required size="10">
|
||||
<button type="submit">send</button>
|
||||
</form>
|
||||
<form id="xiaomi-verify-form">
|
||||
<label id="xiaomi-verify"></label>
|
||||
<input type="text" name="verify" placeholder="verify" required size="10">
|
||||
<button type="submit">send</button>
|
||||
</form>
|
||||
<form id="xiaomi-devices-form">
|
||||
<select id="xiaomi-id" name="id" required></select>
|
||||
<select name="region" required>
|
||||
<option value="cn">China</option>
|
||||
<option value="de">Europe</option>
|
||||
<option value="i2">India</option>
|
||||
<option value="ru">Russia</option>
|
||||
<option value="sg">Singapore</option>
|
||||
<option value="us">United States</option>
|
||||
</select>
|
||||
<button type="submit">load devices</button>
|
||||
</form>
|
||||
<table id="xiaomi-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
async function xiaomiReload(ev) {
|
||||
if (ev) ev.target.nextElementSibling.style.display = 'grid';
|
||||
|
||||
document.getElementById('xiaomi-login-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-captcha-form').style.display = 'none';
|
||||
document.getElementById('xiaomi-verify-form').style.display = 'none';
|
||||
|
||||
const r = await fetch('api/xiaomi', {'cache': 'no-cache'});
|
||||
const data = await r.json();
|
||||
const users = document.getElementById('xiaomi-id');
|
||||
users.innerHTML = data.map(item => `<option value="${item}">${item}</option>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('xiaomi').addEventListener('click', xiaomiReload);
|
||||
|
||||
async function xiaomiLogin(ev) {
|
||||
ev.preventDefault();
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
const r = await fetch('api/xiaomi', {method: 'POST', body: params});
|
||||
if (r.status === 401) {
|
||||
/** @type {{captcha: string, verify_email: string, verify_phone: string}} */
|
||||
const data = await r.json();
|
||||
document.getElementById('xiaomi-login-form').style.display = 'none';
|
||||
if (data.captcha) {
|
||||
document.getElementById('xiaomi-captcha-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-captcha').src = 'data:image/jpeg;base64,' + data.captcha;
|
||||
} else {
|
||||
document.getElementById('xiaomi-verify-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-verify').innerText = data.verify_email || data.verify_phone;
|
||||
}
|
||||
} else if (r.ok) {
|
||||
alert('OK');
|
||||
xiaomiReload();
|
||||
} else {
|
||||
alert('ERROR: ' + await r.text());
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('xiaomi-login-form').addEventListener('submit', xiaomiLogin);
|
||||
document.getElementById('xiaomi-captcha-form').addEventListener('submit', xiaomiLogin);
|
||||
document.getElementById('xiaomi-verify-form').addEventListener('submit', xiaomiLogin);
|
||||
|
||||
document.getElementById('xiaomi-devices-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
await getSources('xiaomi-table', 'api/xiaomi?' + params.toString());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="webtorrent">WebTorrent Shares</button>
|
||||
<div>
|
||||
<table id="webtorrent-table"></table>
|
||||
|
||||
Reference in New Issue
Block a user