Skip to content

Commit a4d4598

Browse files
committed
Add support xiaomi source
1 parent 17c1f69 commit a4d4598

9 files changed

Lines changed: 1678 additions & 0 deletions

File tree

internal/xiaomi/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Xiaomi
2+
3+
This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem.
4+
5+
**Important:**
6+
7+
1. **Not all cameras are supported**. There are several P2P protocol vendors in the Xiaomi ecosystem.
8+
Currently, the **CS2** vendor is supported. However, the **TUTK** vendor is not supported.
9+
2. Each time you connect to the camera, you need internet access to obtain encryption keys.
10+
3. Connection to the camera is local only.
11+
12+
**Features:**
13+
14+
- Multiple Xiaomi accounts supported
15+
- Cameras from multiple regions are supported for a single account
16+
- Two-way audio is supported
17+
- Cameras with multiple lenses are supported
18+
19+
## Setup
20+
21+
1. Goto go2rtc WebUI > Add > Xiaomi > Login with username and password
22+
2. Receive verification code by email or phone if required.
23+
3. Complete the captcha if required.
24+
4. If everything is OK, your account will be added and you can load cameras from it.
25+
26+
**Example**
27+
28+
```yaml
29+
xiaomi:
30+
1234567890: V1:***
31+
32+
streams:
33+
xiaomi1: xiaomi://1234567890:[email protected]?did=9876543210&model=isa.camera.hlc7
34+
```
35+
36+
## Configuration
37+
38+
You can change camera's quality: `subtype=hd/sd/auto`
39+
40+
```yaml
41+
streams:
42+
xiaomi1: xiaomi://***&subtype=sd
43+
```
44+
45+
You can use second channel for Dual cameras: `channel=1`
46+
47+
```yaml
48+
streams:
49+
xiaomi1: xiaomi://***&channel=1
50+
```

internal/xiaomi/xiaomi.go

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
package xiaomi
2+
3+
import (
4+
"encoding/hex"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"sync"
12+
13+
"github.com/AlexxIT/go2rtc/internal/api"
14+
"github.com/AlexxIT/go2rtc/internal/app"
15+
"github.com/AlexxIT/go2rtc/internal/streams"
16+
"github.com/AlexxIT/go2rtc/pkg/core"
17+
"github.com/AlexxIT/go2rtc/pkg/xiaomi"
18+
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss"
19+
)
20+
21+
func Init() {
22+
var v struct {
23+
Cfg map[string]string `yaml:"xiaomi"`
24+
}
25+
app.LoadConfig(&v)
26+
27+
tokens = v.Cfg
28+
29+
log := app.GetLogger("xiaomi")
30+
31+
streams.HandleFunc("xiaomi", func(rawURL string) (core.Producer, error) {
32+
u, err := url.Parse(rawURL)
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
if u.User != nil {
38+
rawURL, err = getCameraURL(u)
39+
if err != nil {
40+
return nil, err
41+
}
42+
}
43+
44+
log.Debug().Msgf("xiaomi: dial %s", rawURL)
45+
46+
return xiaomi.Dial(rawURL)
47+
})
48+
49+
api.HandleFunc("api/xiaomi", apiXiaomi)
50+
}
51+
52+
var tokens map[string]string
53+
var tokensMu sync.Mutex
54+
55+
func getCloud(userID string) (*xiaomi.Cloud, error) {
56+
tokensMu.Lock()
57+
defer tokensMu.Unlock()
58+
59+
token := tokens[userID]
60+
cloud := xiaomi.NewCloud(AppXiaomiHome)
61+
if err := cloud.LoginWithToken(userID, token); err != nil {
62+
return nil, err
63+
}
64+
65+
return cloud, nil
66+
}
67+
68+
func getCameraURL(url *url.URL) (string, error) {
69+
clientPublic, clientPrivate, err := miss.GenerateKey()
70+
if err != nil {
71+
return "", err
72+
}
73+
74+
query := url.Query()
75+
76+
params := fmt.Sprintf(
77+
`{"app_pubkey":"%x","did":"%s","support_vendors":"CS2"}`,
78+
clientPublic, query.Get("did"),
79+
)
80+
81+
cloud, err := getCloud(url.User.Username())
82+
if err != nil {
83+
return "", err
84+
}
85+
86+
region, _ := url.User.Password()
87+
88+
res, err := cloud.Request(GetBaseURL(region), "/v2/device/miss_get_vendor", params, nil)
89+
if err != nil {
90+
return "", err
91+
}
92+
93+
var v struct {
94+
Vendor struct {
95+
VendorID byte `json:"vendor"`
96+
} `json:"vendor"`
97+
PublicKey string `json:"public_key"`
98+
Sign string `json:"sign"`
99+
}
100+
if err = json.Unmarshal(res, &v); err != nil {
101+
return "", err
102+
}
103+
104+
query.Set("client_public", hex.EncodeToString(clientPublic))
105+
query.Set("client_private", hex.EncodeToString(clientPrivate))
106+
query.Set("device_public", v.PublicKey)
107+
query.Set("sign", v.Sign)
108+
query.Set("vendor", getVendorName(v.Vendor.VendorID))
109+
110+
url.RawQuery = query.Encode()
111+
return url.String(), nil
112+
}
113+
114+
func getVendorName(i byte) string {
115+
switch i {
116+
case 1:
117+
return "tutk"
118+
case 3:
119+
return "agora"
120+
case 4:
121+
return "cs2"
122+
case 6:
123+
return "mtp"
124+
}
125+
return fmt.Sprintf("%d", i)
126+
}
127+
128+
func apiXiaomi(w http.ResponseWriter, r *http.Request) {
129+
switch r.Method {
130+
case "GET":
131+
apiDeviceList(w, r)
132+
case "POST":
133+
apiAuth(w, r)
134+
}
135+
}
136+
137+
func apiDeviceList(w http.ResponseWriter, r *http.Request) {
138+
query := r.URL.Query()
139+
140+
user := query.Get("id")
141+
if user == "" {
142+
tokensMu.Lock()
143+
users := make([]string, 0, len(tokens))
144+
for s := range tokens {
145+
users = append(users, s)
146+
}
147+
tokensMu.Unlock()
148+
149+
api.ResponseJSON(w, users)
150+
return
151+
}
152+
153+
err := func() error {
154+
cloud, err := getCloud(user)
155+
if err != nil {
156+
return err
157+
}
158+
159+
region := query.Get("region")
160+
161+
res, err := cloud.Request(GetBaseURL(region), "/v2/home/device_list_page", "{}", nil)
162+
if err != nil {
163+
return err
164+
}
165+
var v struct {
166+
List []*Device `json:"list"`
167+
}
168+
169+
if err = json.Unmarshal(res, &v); err != nil {
170+
return err
171+
}
172+
173+
var items []*api.Source
174+
175+
for _, device := range v.List {
176+
if !strings.Contains(device.Model, ".camera.") {
177+
continue
178+
}
179+
items = append(items, &api.Source{
180+
Name: device.Name,
181+
Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC),
182+
URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s", user, region, device.IP, device.Did, device.Model),
183+
})
184+
}
185+
186+
api.ResponseSources(w, items)
187+
return nil
188+
}()
189+
190+
if err != nil {
191+
http.Error(w, err.Error(), http.StatusInternalServerError)
192+
}
193+
}
194+
195+
type Device struct {
196+
Did string `json:"did"`
197+
Name string `json:"name"`
198+
Model string `json:"model"`
199+
MAC string `json:"mac"`
200+
IP string `json:"localip"`
201+
}
202+
203+
var auth *xiaomi.Cloud
204+
205+
func apiAuth(w http.ResponseWriter, r *http.Request) {
206+
if err := r.ParseForm(); err != nil {
207+
http.Error(w, err.Error(), http.StatusBadRequest)
208+
return
209+
}
210+
211+
username := r.Form.Get("username")
212+
password := r.Form.Get("password")
213+
captcha := r.Form.Get("captcha")
214+
verify := r.Form.Get("verify")
215+
216+
var err error
217+
218+
switch {
219+
case username != "" || password != "":
220+
auth = xiaomi.NewCloud(AppXiaomiHome)
221+
err = auth.Login(username, password)
222+
case captcha != "":
223+
err = auth.LoginWithCaptcha(captcha)
224+
case verify != "":
225+
err = auth.LoginWithVerify(verify)
226+
default:
227+
http.Error(w, "wrong request", http.StatusBadRequest)
228+
return
229+
}
230+
231+
if err == nil {
232+
userID, token := auth.UserToken()
233+
auth = nil
234+
235+
tokensMu.Lock()
236+
if tokens == nil {
237+
tokens = map[string]string{userID: token}
238+
} else {
239+
tokens[userID] = token
240+
}
241+
tokensMu.Unlock()
242+
243+
err = app.PatchConfig([]string{"xiaomi", userID}, token)
244+
}
245+
246+
if err != nil {
247+
var login *xiaomi.LoginError
248+
if errors.As(err, &login) {
249+
w.Header().Set("Content-Type", api.MimeJSON)
250+
w.WriteHeader(http.StatusUnauthorized)
251+
_ = json.NewEncoder(w).Encode(err)
252+
return
253+
}
254+
255+
http.Error(w, err.Error(), http.StatusInternalServerError)
256+
}
257+
}
258+
259+
const AppXiaomiHome = "xiaomiio"
260+
261+
func GetBaseURL(region string) string {
262+
switch region {
263+
case "de", "i2", "ru", "sg", "us":
264+
return "https://" + region + ".api.io.mi.com/app"
265+
}
266+
return "https://api.io.mi.com/app"
267+
}

main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
"github.com/AlexxIT/go2rtc/internal/webrtc"
4444
"github.com/AlexxIT/go2rtc/internal/webtorrent"
4545
"github.com/AlexxIT/go2rtc/internal/wyoming"
46+
"github.com/AlexxIT/go2rtc/internal/xiaomi"
4647
"github.com/AlexxIT/go2rtc/internal/yandex"
4748
"github.com/AlexxIT/go2rtc/pkg/shell"
4849
)
@@ -98,6 +99,7 @@ func main() {
9899
{"roborock", roborock.Init},
99100
{"tapo", tapo.Init},
100101
{"tuya", tuya.Init},
102+
{"xiaomi", xiaomi.Init},
101103
{"yandex", yandex.Init},
102104
// Helper modules
103105
{"debug", debug.Init},

pkg/core/helpers.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ func Atoi(s string) (i int) {
6767
return
6868
}
6969

70+
// ParseByte - fast parsing string to byte function
71+
func ParseByte(s string) (b byte) {
72+
for i, ch := range []byte(s) {
73+
ch -= '0'
74+
if ch > 9 {
75+
return 0
76+
}
77+
if i > 0 {
78+
b *= 10
79+
}
80+
b += ch
81+
}
82+
return
83+
}
84+
7085
func Assert(ok bool) {
7186
if !ok {
7287
_, file, line, _ := runtime.Caller(1)

pkg/xiaomi/backchannel.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package xiaomi
2+
3+
import (
4+
"time"
5+
6+
"github.com/AlexxIT/go2rtc/pkg/core"
7+
"github.com/AlexxIT/go2rtc/pkg/xiaomi/miss"
8+
"github.com/pion/rtp"
9+
)
10+
11+
const size8bit40ms = 8000 * 0.040
12+
13+
func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
14+
if err := p.client.SpeakerStart(); err != nil {
15+
return err
16+
}
17+
// TODO: check this!!!
18+
time.Sleep(time.Second)
19+
20+
sender := core.NewSender(media, track.Codec)
21+
22+
switch track.Codec.Name {
23+
case core.CodecPCMA:
24+
var buf []byte
25+
26+
sender.Handler = func(pkt *rtp.Packet) {
27+
buf = append(buf, pkt.Payload...)
28+
for len(buf) >= size8bit40ms {
29+
_ = p.client.WriteAudio(miss.CodecPCMA, buf[:size8bit40ms])
30+
buf = buf[size8bit40ms:]
31+
}
32+
}
33+
case core.CodecOpus:
34+
sender.Handler = func(pkt *rtp.Packet) {
35+
_ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload)
36+
}
37+
}
38+
39+
sender.HandleRTP(track)
40+
p.Senders = append(p.Senders, sender)
41+
return nil
42+
}

0 commit comments

Comments
 (0)