Add support xiaomi source

This commit is contained in:
Alex X
2025-12-14 13:07:45 +03:00
parent 17c1f69f66
commit a4d4598a13
9 changed files with 1678 additions and 0 deletions
+50
View File
@@ -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
```
+267
View File
@@ -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"
}
+2
View File
@@ -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},
+15
View File
@@ -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)
+42
View File
@@ -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
}
+563
View File
@@ -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
}
+451
View File
@@ -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
}
+206
View File
@@ -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)
}
+82
View File
@@ -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>