feat: add Go API library

This commit is contained in:
2026-07-18 22:40:40 +08:00
commit 4755655859
23 changed files with 5346 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.mimocode/
mijia-api/
auth.json
miot-cache/
+137
View File
@@ -0,0 +1,137 @@
# mijia-go-api
`mijia-go-api` 是一个纯 Go 米家 API 库,支持二维码登录与 token 刷新、家庭/设备/场景/耗材查询、属性读写、action 执行、统计查询,以及基于 MIoT 设备描述的高级 `Device` 操作。
本项目只提供 Go 库,不提供 CLI、MCP、skills 或 HAR decrypt 功能。
## 安装
```bash
go get git.misaka.ren/m1saka/mijia-go-api
```
## 登录
`NewClient` 的认证路径传空字符串时,默认使用 `~/.config/mijia-api/auth.json``Login` 会优先尝试刷新已有 token;需要重新登录时,会在终端输出二维码供米家 App 扫描。
```go
package main
import (
"context"
"log"
mijia "git.misaka.ren/m1saka/mijia-go-api"
)
func main() {
client, err := mijia.NewClient("")
if err != nil {
log.Fatal(err)
}
if _, err := client.Login(context.Background()); err != nil {
log.Fatal(err)
}
auth := client.AuthData()
log.Printf("已登录 userId=%s", auth.UserID)
}
```
`auth.json` 包含 `serviceToken``passToken``ssecurity` 等敏感认证数据。库写入该文件时使用 `0600` 权限;请保持此权限,并且不要将该文件提交到版本库。
## 底层 API
以下示例展示设备、属性和 action 的直接调用。`GetDevices``homeID` 传空字符串时查询所有家庭。
```go
devices, err := client.GetDevices(ctx, "")
if err != nil {
return err
}
properties, err := client.GetProperties(ctx, []mijia.PropertyRequest{
{DID: devices[0].DID, SIID: 2, PIID: 1},
})
if err != nil {
return err
}
_, err = client.SetProperties(ctx, []mijia.PropertySetRequest{
{DID: devices[0].DID, SIID: 2, PIID: 1, Value: true},
})
if err != nil {
return err
}
_, err = client.RunActions(ctx, []mijia.ActionRequest{
{DID: devices[0].DID, SIID: 2, AIID: 1},
})
```
家庭、场景、耗材和统计分别使用 `GetHomes``GetScenes``RunScene``GetConsumables``GetStatistics`
## 高级 Device
`NewDevice` 可通过 `DeviceSelector.DID``DeviceSelector.Name` 选择设备;名称匹配到多个设备时会返回 `MultipleDevicesFoundError`。设备描述默认缓存到认证文件所在目录,每次成功的 `Get``Set``RunAction` 后默认等待 `500ms`
```go
device, err := mijia.NewDevice(ctx, client, mijia.DeviceSelector{DID: "设备 DID"})
// 也可以按名称选择:mijia.DeviceSelector{Name: "客厅灯"}
if err != nil {
return err
}
value, err := device.Get(ctx, "on")
if err != nil {
return err
}
if err := device.Set(ctx, "on", true); err != nil {
return err
}
_, err = device.RunAction(ctx, "toggle", nil)
_ = value
```
可通过实际导出的 `DeviceOption` 调整行为:
```go
device, err := mijia.NewDevice(
ctx,
client,
mijia.DeviceSelector{Name: "客厅灯"},
mijia.WithDeviceDelay(0),
mijia.WithDeviceCacheDir("./miot-cache"),
mijia.WithDeviceHTTPClient(http.DefaultClient),
)
```
## 超时与错误
所有网络和设备操作都接收 `context.Context`,建议设置超时:
```go
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
devices, err := client.GetDevices(ctx, "")
```
API 错误和高级设备错误可使用 `errors.As` 判断:
```go
var apiErr *mijia.APIError
var deviceErr *mijia.DeviceGetError
switch {
case errors.As(err, &apiErr):
log.Printf("API 错误: code=%d message=%s", apiErr.Code, apiErr.Message)
case errors.As(err, &deviceErr):
log.Printf("设备读取错误: device=%s property=%s code=%d", deviceErr.DeviceName, deviceErr.Name, deviceErr.Code)
}
```
写属性和执行 action 时,对应错误类型为 `DeviceSetError``DeviceActionError`;设备选择还可能返回 `DeviceNotFoundError``MultipleDevicesFoundError`
+338
View File
@@ -0,0 +1,338 @@
package mijia
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
)
const (
checkNewMessagesURI = "/v2/message/v2/check_new_msg"
getHomesURI = "/v2/homeroom/gethome_merged"
getDevicesURI = "/home/home_device_list"
getSharedDevicesURI = "/v2/home/device_list_page"
getScenesURI = "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList"
getConsumablesURI = "/v2/home/standard_consumable_items"
getPropertiesURI = "/miotspec/prop/get"
setPropertiesURI = "/miotspec/prop/set"
runActionURI = "/miotspec/action"
)
var getHomesData = map[string]any{
"fg": true, "fetch_share": true, "fetch_share_dev": true, "fetch_cariot": true,
"limit": 300, "app_ver": 7, "plat_form": 0,
}
func (client *Client) CheckNewMessages(ctx context.Context, beginAt time.Time) (json.RawMessage, error) {
result, err := client.request(ctx, checkNewMessagesURI, map[string]int64{"begin_at": beginAt.Unix()}, true)
if err != nil {
return nil, fmt.Errorf("check new messages: %w", err)
}
return result, nil
}
func (client *Client) GetHomes(ctx context.Context) ([]Home, error) {
result, err := client.request(ctx, getHomesURI, getHomesData, true)
if err != nil {
return nil, fmt.Errorf("get homes: %w", err)
}
var response struct {
Homes []Home `json:"homelist"`
}
if err := decodeJSON(result, &response); err != nil {
return nil, fmt.Errorf("decode %s response: %w", getHomesURI, err)
}
return response.Homes, nil
}
func (client *Client) GetDevices(ctx context.Context, homeID string) ([]Device, error) {
homes, err := client.GetHomes(ctx)
if err != nil {
return nil, err
}
if homeID != "" {
owner, err := homeOwner(homes, homeID)
if err != nil {
return nil, err
}
return client.getDevices(ctx, homeID, owner)
}
var devices []Device
for _, home := range homes {
homeDevices, err := client.getDevices(ctx, home.ID, home.UID)
devices = append(devices, homeDevices...)
if err != nil {
return devices, fmt.Errorf("get devices for home %s: %w", home.ID, err)
}
}
return devices, nil
}
func (client *Client) getDevices(ctx context.Context, homeID string, owner int64) ([]Device, error) {
numericHomeID, err := strconv.ParseInt(homeID, 10, 64)
if err != nil {
return nil, fmt.Errorf("home id %q must be numeric: %w", homeID, err)
}
var devices []Device
startDID := ""
seenCursors := map[string]struct{}{startDID: {}}
for {
data := map[string]any{
"home_owner": owner, "home_id": numericHomeID, "limit": 200, "start_did": startDID,
"get_split_device": true, "support_smart_home": true, "get_cariot_device": true, "get_third_device": true,
}
result, err := client.request(ctx, getDevicesURI, data, true)
if err != nil {
return nil, fmt.Errorf("request devices for home %s: %w", homeID, err)
}
var page struct {
Devices []Device `json:"device_info"`
MaxDID string `json:"max_did"`
HasMore bool `json:"has_more"`
}
if err := decodeJSON(result, &page); err != nil {
return devices, fmt.Errorf("decode %s response for home %s: %w", getDevicesURI, homeID, err)
}
for index := range page.Devices {
page.Devices[index].HomeID = homeID
}
devices = append(devices, page.Devices...)
if !page.HasMore {
return devices, nil
}
if page.MaxDID == "" {
return devices, fmt.Errorf("paginate %s response for home %s: has_more is true but max_did is empty", getDevicesURI, homeID)
}
if _, seen := seenCursors[page.MaxDID]; seen {
return devices, fmt.Errorf("paginate %s response for home %s: max_did %q repeats or forms a cursor cycle", getDevicesURI, homeID, page.MaxDID)
}
seenCursors[page.MaxDID] = struct{}{}
startDID = page.MaxDID
}
}
func (client *Client) GetSharedDevices(ctx context.Context) ([]Device, error) {
data := map[string]any{
"ssid": "<unknown ssid>", "bssid": "02:00:00:00:00:00", "getVirtualModel": true, "getHuamiDevices": 1,
"get_split_device": true, "support_smart_home": true, "get_cariot_device": true, "get_third_device": true,
"get_phone_device": true, "get_miwear_device": true,
}
result, err := client.request(ctx, getSharedDevicesURI, data, true)
if err != nil {
return nil, fmt.Errorf("get shared devices: %w", err)
}
var response struct {
Devices []Device `json:"list"`
}
if err := decodeJSON(result, &response); err != nil {
return nil, fmt.Errorf("decode %s response: %w", getSharedDevicesURI, err)
}
devices := make([]Device, 0, len(response.Devices))
for _, device := range response.Devices {
if device.Owner {
device.HomeID = "shared"
devices = append(devices, device)
}
}
return devices, nil
}
func (client *Client) GetScenes(ctx context.Context, homeID string) ([]Scene, error) {
homes, err := client.GetHomes(ctx)
if err != nil {
return nil, err
}
if homeID != "" {
owner, err := homeOwner(homes, homeID)
if err != nil {
return nil, err
}
return client.getScenes(ctx, homeID, owner)
}
var scenes []Scene
for _, home := range homes {
homeScenes, err := client.getScenes(ctx, home.ID, home.UID)
if err != nil {
return nil, fmt.Errorf("get scenes for home %s: %w", home.ID, err)
}
scenes = append(scenes, homeScenes...)
}
return scenes, nil
}
func (client *Client) getScenes(ctx context.Context, homeID string, owner int64) ([]Scene, error) {
data := map[string]any{"app_version": 12, "get_type": 2, "home_id": homeID, "owner_uid": owner}
result, err := client.request(ctx, getScenesURI, data, true)
if err != nil {
return nil, fmt.Errorf("request scenes for home %s: %w", homeID, err)
}
var response struct {
Scenes []Scene `json:"manual_scene_info_list"`
}
if err := decodeJSON(result, &response); err != nil {
return nil, fmt.Errorf("decode %s response for home %s: %w", getScenesURI, homeID, err)
}
for index := range response.Scenes {
response.Scenes[index].HomeID = homeID
}
return response.Scenes, nil
}
func (client *Client) RunScene(ctx context.Context, sceneID, homeID string) (json.RawMessage, error) {
homes, err := client.GetHomes(ctx)
if err != nil {
return nil, err
}
owner, err := homeOwner(homes, homeID)
if err != nil {
return nil, err
}
data := map[string]any{"scene_id": sceneID, "scene_type": 2, "phone_id": "null", "home_id": homeID, "owner_uid": owner}
result, err := client.request(ctx, "/appgateway/miot/appsceneservice/AppSceneService/NewRunScene", data, true)
if err != nil {
return nil, fmt.Errorf("run scene %s in home %s: %w", sceneID, homeID, err)
}
return result, nil
}
func (client *Client) GetConsumables(ctx context.Context, homeID string) ([]Consumable, error) {
homes, err := client.GetHomes(ctx)
if err != nil {
return nil, err
}
if homeID != "" {
owner, err := homeOwner(homes, homeID)
if err != nil {
return nil, err
}
return client.getConsumables(ctx, homeID, owner)
}
var consumables []Consumable
for _, home := range homes {
homeConsumables, err := client.getConsumables(ctx, home.ID, home.UID)
if err != nil {
return nil, fmt.Errorf("get consumables for home %s: %w", home.ID, err)
}
consumables = append(consumables, homeConsumables...)
}
return consumables, nil
}
func (client *Client) getConsumables(ctx context.Context, homeID string, owner int64) ([]Consumable, error) {
numericHomeID, err := strconv.ParseInt(homeID, 10, 64)
if err != nil {
return nil, fmt.Errorf("home id %q must be numeric: %w", homeID, err)
}
data := map[string]any{"home_id": numericHomeID, "owner_id": owner, "filter_ignore": true}
result, err := client.request(ctx, getConsumablesURI, data, true)
if err != nil {
return nil, fmt.Errorf("request consumables for home %s: %w", homeID, err)
}
var response struct {
Items []struct {
Consumables []Consumable `json:"consumes_data"`
} `json:"items"`
}
if err := decodeJSON(result, &response); err != nil {
return nil, fmt.Errorf("decode %s response for home %s: %w", getConsumablesURI, homeID, err)
}
if len(response.Items) == 0 {
return nil, nil
}
consumables := response.Items[0].Consumables
for index := range consumables {
consumables[index].HomeID = homeID
var details []json.RawMessage
if json.Unmarshal(consumables[index].Details, &details) == nil && len(details) == 1 {
consumables[index].Details = details[0]
}
}
return consumables, nil
}
func (client *Client) GetProperties(ctx context.Context, requests []PropertyRequest) ([]PropertyResult, error) {
if len(requests) == 0 {
return []PropertyResult{}, nil
}
result, err := client.request(ctx, getPropertiesURI, map[string]any{"params": requests, "datasource": 1}, true)
if err != nil {
return nil, fmt.Errorf("get properties: %w", err)
}
var properties []PropertyResult
if err := decodeJSON(result, &properties); err != nil {
return nil, fmt.Errorf("decode %s response: %w", getPropertiesURI, err)
}
return properties, nil
}
func (client *Client) SetProperties(ctx context.Context, requests []PropertySetRequest) ([]PropertyResult, error) {
if len(requests) == 0 {
return []PropertyResult{}, nil
}
result, err := client.request(ctx, setPropertiesURI, map[string]any{"params": requests}, true)
if err != nil {
return nil, fmt.Errorf("set properties: %w", err)
}
var properties []PropertyResult
if err := decodeJSON(result, &properties); err != nil {
return nil, fmt.Errorf("decode %s response: %w", setPropertiesURI, err)
}
for index := range properties {
properties[index].Message = resultMessage(properties[index].Code, properties[index].Message)
}
return properties, nil
}
func (client *Client) RunActions(ctx context.Context, requests []ActionRequest) ([]ActionResult, error) {
results := make([]ActionResult, 0, len(requests))
for index, action := range requests {
result, err := client.request(ctx, runActionURI, map[string]any{"params": action}, true)
if err != nil {
return results, fmt.Errorf("run action %d: %w", index, err)
}
var actionResult ActionResult
if err := decodeJSON(result, &actionResult); err != nil {
return results, fmt.Errorf("decode %s response for action %d: %w", runActionURI, index, err)
}
actionResult.Message = resultMessage(actionResult.Code, actionResult.Message)
results = append(results, actionResult)
}
return results, nil
}
func (client *Client) GetStatistics(ctx context.Context, requests []StatisticsRequest) ([]json.RawMessage, error) {
results := make([]json.RawMessage, 0, len(requests))
for index, statisticsRequest := range requests {
result, err := client.request(ctx, "/v2/user/statistics", statisticsRequest, true)
if err != nil {
return results, fmt.Errorf("get statistics %d: %w", index, err)
}
results = append(results, result)
}
return results, nil
}
func homeOwner(homes []Home, homeID string) (int64, error) {
for _, home := range homes {
if home.ID == homeID {
return home.UID, nil
}
}
return 0, &APIError{Code: -1, Message: fmt.Sprintf("未找到 home_id=%s 的家庭信息", homeID)}
}
func resultMessage(code int, message string) string {
if code == 0 || code == 1 {
return "成功"
}
if knownMessage, ok := errorCodeMessages[code]; ok {
return knownMessage
}
if message != "" {
return message
}
return ErrorMessage(code)
}
+407
View File
@@ -0,0 +1,407 @@
package mijia
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
)
type apiExpectation struct {
path string
body string
result string
status int
}
func TestCheckNewMessagesAndGetHomes(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/message/v2/check_new_msg", body: `{"begin_at":1700000000}`, result: `{"has_new":true}`},
{path: "/v2/homeroom/gethome_merged", body: `{"app_ver":7,"fetch_cariot":true,"fetch_share":true,"fetch_share_dev":true,"fg":true,"limit":300,"plat_form":0}`, result: `{"homelist":[{"id":"10","name":"Main","uid":100,"room_id":"living"},{"id":20,"name":"Other","uid":200}]}`},
})
message, err := client.CheckNewMessages(context.Background(), time.Unix(1700000000, 999))
if err != nil || string(message) != `{"has_new":true}` {
t.Fatalf("CheckNewMessages() = %s, %v", message, err)
}
homes, err := client.GetHomes(context.Background())
if err != nil {
t.Fatal(err)
}
if len(homes) != 2 || homes[0].ID != "10" || homes[1].ID != "20" || homes[1].UID != 200 {
t.Fatalf("GetHomes() = %#v", homes)
}
if string(homes[0].Extra["room_id"]) != `"living"` {
t.Fatalf("GetHomes()[0].Extra = %#v", homes[0].Extra)
}
verify()
}
func TestGetDevicesReturnsPartialResultsForRepeatedCursor(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":20,"uid":200}]}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a","name":"Lamp","model":"lamp.a"}],"max_did":"a","has_more":true}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"a","support_smart_home":true}`, result: `{"device_info":[{"did":"b","name":"Fan","model":"fan.b"}],"max_did":"a","has_more":true}`},
})
devices, err := client.GetDevices(context.Background(), "")
if err == nil || len(devices) != 2 || devices[0].HomeID != "10" || devices[1].DID != "b" || !strings.Contains(err.Error(), "max_did \"a\" repeats") {
t.Fatalf("GetDevices() = %#v, %v", devices, err)
}
verify()
}
func TestGetDevicesStopsCursorCycle(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100}]}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a"}],"max_did":"a","has_more":true}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"a","support_smart_home":true}`, result: `{"device_info":[{"did":"b"}],"max_did":"b","has_more":true}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"b","support_smart_home":true}`, result: `{"device_info":[{"did":"c"}],"max_did":"a","has_more":true}`},
})
devices, err := client.GetDevices(context.Background(), "10")
if err == nil || len(devices) != 3 || !strings.Contains(err.Error(), "cursor cycle") {
t.Fatalf("GetDevices() = %#v, %v", devices, err)
}
verify()
}
func TestGetDevicesReturnsPartialResultsForEmptyCursor(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100}]}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a"}],"has_more":true}`},
})
devices, err := client.GetDevices(context.Background(), "10")
if err == nil || len(devices) != 1 || !strings.Contains(err.Error(), "max_did is empty") {
t.Fatalf("GetDevices() = %#v, %v", devices, err)
}
verify()
}
func TestGetDevicesSingleHomeErrors(t *testing.T) {
t.Run("owner missing", func(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[]}`}})
_, err := client.GetDevices(context.Background(), "10")
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != -1 {
t.Fatalf("error = %v, want APIError(-1)", err)
}
verify()
})
t.Run("invalid numeric id", func(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"home-a","uid":100}]}`}})
_, err := client.GetDevices(context.Background(), "home-a")
if err == nil {
t.Fatal("GetDevices() error = nil")
}
verify()
})
}
func TestSharedDevicesScenesAndConsumables(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/home/device_list_page", body: `{"bssid":"02:00:00:00:00:00","getHuamiDevices":1,"getVirtualModel":true,"get_cariot_device":true,"get_miwear_device":true,"get_phone_device":true,"get_split_device":true,"get_third_device":true,"ssid":"<unknown ssid>","support_smart_home":true}`, result: `{"list":[{"did":"owned","owner":true,"localip":"192.0.2.1"},{"did":"shared","owner":false},{"did":"missing"}]}`},
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
{path: "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList", body: `{"app_version":12,"get_type":2,"home_id":"10","owner_uid":100}`, result: `{"manual_scene_info_list":[{"scene_id":"s1","name":"Sleep","isOnline":true}]}`},
{path: "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList", body: `{"app_version":12,"get_type":2,"home_id":"20","owner_uid":200}`, result: `{}`},
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
{path: "/appgateway/miot/appsceneservice/AppSceneService/NewRunScene", body: `{"home_id":"10","owner_uid":100,"phone_id":"null","scene_id":"s1","scene_type":2}`, result: `true`},
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
{path: "/v2/home/standard_consumable_items", body: `{"filter_ignore":true,"home_id":10,"owner_id":100}`, result: `{"items":[{"consumes_data":[{"did":"a","name":"Filter","details":[{"id":"filter-life"}],"room_id":"kitchen"}]}]}`},
{path: "/v2/home/standard_consumable_items", body: `{"filter_ignore":true,"home_id":20,"owner_id":200}`, result: `{"items":[]}`},
})
shared, err := client.GetSharedDevices(context.Background())
if err != nil || len(shared) != 1 || shared[0].DID != "owned" || shared[0].HomeID != "shared" || string(shared[0].Extra["localip"]) != `"192.0.2.1"` {
t.Fatalf("GetSharedDevices() = %#v, %v", shared, err)
}
scenes, err := client.GetScenes(context.Background(), "")
if err != nil || len(scenes) != 1 || scenes[0].HomeID != "10" || string(scenes[0].Extra["isOnline"]) != "true" {
t.Fatalf("GetScenes() = %#v, %v", scenes, err)
}
runResult, err := client.RunScene(context.Background(), "s1", "10")
if err != nil || string(runResult) != "true" {
t.Fatalf("RunScene() = %s, %v", runResult, err)
}
consumables, err := client.GetConsumables(context.Background(), "")
if err != nil || len(consumables) != 1 || consumables[0].HomeID != "10" || string(consumables[0].Details) != `{"id":"filter-life"}` || string(consumables[0].Extra["room_id"]) != `"kitchen"` {
t.Fatalf("GetConsumables() = %#v, %v", consumables, err)
}
verify()
}
func TestPropertiesActionsAndStatistics(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"a","siid":2,"piid":1},{"did":"b","siid":3,"piid":2}]}`, result: `[{"did":"a","siid":2,"piid":1,"value":true,"code":0},{"did":"b","siid":3,"piid":2,"code":-704030013}]`},
{path: "/miotspec/prop/set", body: `{"params":[{"did":"a","siid":2,"piid":1,"value":true},{"did":"b","siid":3,"piid":2,"value":5}]}`, result: `[{"did":"a","siid":2,"piid":1,"code":1},{"did":"b","siid":3,"piid":2,"code":-704030023}]`},
{path: "/miotspec/action", body: `{"params":{"did":"a","siid":2,"aiid":1}}`, result: `{"did":"a","siid":2,"aiid":1,"code":0,"out":{"enabled":true}}`},
{path: "/miotspec/action", body: `{"params":{"did":"b","siid":3,"aiid":2,"value":[5]}}`, result: `{"did":"b","siid":3,"aiid":2,"code":-704040005}`},
{path: "/v2/user/statistics", body: `{"data_type":"stat_day_v3","did":"a","key":"2.1","limit":2,"time_end":20,"time_start":10}`, result: `[{"time":10,"value":"[1]"}]`},
{path: "/v2/user/statistics", body: `{"data_type":"stat_hour_v3","did":"b","key":"3.2","limit":1,"time_end":40,"time_start":30}`, result: `[{"time":30,"value":"[2]"}]`},
})
properties, err := client.GetProperties(context.Background(), []PropertyRequest{{DID: "a", SIID: 2, PIID: 1}, {DID: "b", SIID: 3, PIID: 2}})
if err != nil || len(properties) != 2 || properties[0].Value != true {
t.Fatalf("GetProperties() = %#v, %v", properties, err)
}
setResults, err := client.SetProperties(context.Background(), []PropertySetRequest{{DID: "a", SIID: 2, PIID: 1, Value: true}, {DID: "b", SIID: 3, PIID: 2, Value: 5}})
if err != nil || setResults[0].Message != "成功" || setResults[1].Message != ErrorMessage(-704030023) {
t.Fatalf("SetProperties() = %#v, %v", setResults, err)
}
actions, err := client.RunActions(context.Background(), []ActionRequest{{DID: "a", SIID: 2, AIID: 1}, {DID: "b", SIID: 3, AIID: 2, Value: []any{5}}})
if err != nil || actions[0].Message != "成功" || string(actions[0].Out) != `{"enabled":true}` || actions[1].Message != ErrorMessage(-704040005) || actions[1].Out != nil {
t.Fatalf("RunActions() = %#v, %v", actions, err)
}
statistics, err := client.GetStatistics(context.Background(), []StatisticsRequest{
{DID: "a", Key: "2.1", DataType: "stat_day_v3", Limit: 2, TimeStart: 10, TimeEnd: 20},
{DID: "b", Key: "3.2", DataType: "stat_hour_v3", Limit: 1, TimeStart: 30, TimeEnd: 40},
})
if err != nil || len(statistics) != 2 || string(statistics[1]) != `[{"time":30,"value":"[2]"}]` {
t.Fatalf("GetStatistics() = %#v, %v", statistics, err)
}
verify()
}
func TestPropertyResultsPreserveDynamicJSONNumbers(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"integer","siid":2,"piid":1},{"did":"float","siid":2,"piid":2},{"did":"bool","siid":2,"piid":3},{"did":"string","siid":2,"piid":4}]}`, result: `[{"did":"integer","value":9007199254740993},{"did":"float","value":1.25},{"did":"bool","value":true},{"did":"string","value":"on"}]`},
})
properties, err := client.GetProperties(context.Background(), []PropertyRequest{
{DID: "integer", SIID: 2, PIID: 1},
{DID: "float", SIID: 2, PIID: 2},
{DID: "bool", SIID: 2, PIID: 3},
{DID: "string", SIID: 2, PIID: 4},
})
integer, integerOK := properties[0].Value.(json.Number)
decimal, decimalOK := properties[1].Value.(json.Number)
if err != nil || !integerOK || integer.String() != "9007199254740993" || !decimalOK || decimal.String() != "1.25" || properties[2].Value != true || properties[3].Value != "on" {
t.Fatalf("GetProperties() = %#v, %v", properties, err)
}
verify()
}
func TestDecodeErrorsIncludeEndpoint(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"a","siid":2,"piid":1}]}`, result: `{`},
})
_, err := client.GetProperties(context.Background(), []PropertyRequest{{DID: "a", SIID: 2, PIID: 1}})
if err == nil || !strings.Contains(err.Error(), "/miotspec/prop/get") {
t.Fatalf("GetProperties() error = %v", err)
}
verify()
}
func TestResponseTypesMarshalExtraWithoutKnownFieldConflicts(t *testing.T) {
responses := []any{
Home{ID: "10", Extra: map[string]json.RawMessage{"id": json.RawMessage(`"wrong"`), "room_id": json.RawMessage(`"living"`)}},
Device{DID: "device", Extra: map[string]json.RawMessage{"did": json.RawMessage(`"wrong"`), "localip": json.RawMessage(`"192.0.2.1"`)}},
Scene{SceneID: "scene", Extra: map[string]json.RawMessage{"scene_id": json.RawMessage(`"wrong"`), "isOnline": json.RawMessage(`true`)}},
Consumable{DID: "filter", Extra: map[string]json.RawMessage{"did": json.RawMessage(`"wrong"`), "room_id": json.RawMessage(`"kitchen"`)}},
}
for _, response := range responses {
payload, err := json.Marshal(response)
if err != nil {
t.Fatalf("json.Marshal(%T) error = %v", response, err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatalf("json.Unmarshal(%T payload) error = %v", response, err)
}
if string(fields["id"]) == `"wrong"` || string(fields["did"]) == `"wrong"` || string(fields["scene_id"]) == `"wrong"` {
t.Fatalf("json.Marshal(%T) allowed Extra conflict: %s", response, payload)
}
if fields["room_id"] == nil && fields["localip"] == nil && fields["isOnline"] == nil {
t.Fatalf("json.Marshal(%T) dropped Extra: %s", response, payload)
}
}
}
func TestPropertiesEmptyRequestsDoNotUseNetwork(t *testing.T) {
client, verify := newAPIClient(t, nil)
properties, err := client.GetProperties(context.Background(), nil)
if err != nil || properties == nil || len(properties) != 0 {
t.Fatalf("GetProperties(nil) = %#v, %v", properties, err)
}
setResults, err := client.SetProperties(context.Background(), []PropertySetRequest{})
if err != nil || setResults == nil || len(setResults) != 0 {
t.Fatalf("SetProperties(empty) = %#v, %v", setResults, err)
}
verify()
}
func TestBatchRequestsReturnPartialResults(t *testing.T) {
t.Run("actions", func(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/action", body: `{"params":{"did":"a","siid":2,"aiid":1}}`, result: `{"did":"a","siid":2,"aiid":1,"code":0}`},
{path: "/miotspec/action", body: `{"params":{"did":"b","siid":2,"aiid":1}}`, status: http.StatusInternalServerError, result: `failed`},
})
results, err := client.RunActions(context.Background(), []ActionRequest{{DID: "a", SIID: 2, AIID: 1}, {DID: "b", SIID: 2, AIID: 1}})
if err == nil || len(results) != 1 || results[0].DID != "a" {
t.Fatalf("RunActions() = %#v, %v", results, err)
}
verify()
})
t.Run("statistics", func(t *testing.T) {
requests := []StatisticsRequest{{DID: "a"}, {DID: "b"}}
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/user/statistics", body: `{"data_type":"","did":"a","key":"","limit":0,"time_end":0,"time_start":0}`, result: `[1]`},
{path: "/v2/user/statistics", body: `{"data_type":"","did":"b","key":"","limit":0,"time_end":0,"time_start":0}`, status: http.StatusInternalServerError, result: `failed`},
})
results, err := client.GetStatistics(context.Background(), requests)
if err == nil || len(results) != 1 || string(results[0]) != `[1]` {
t.Fatalf("GetStatistics() = %#v, %v", results, err)
}
verify()
})
}
func TestResultMessagesPreserveUnknownDiagnostics(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/set", body: `{"params":[{"did":"a","siid":2,"piid":1,"value":true},{"did":"b","siid":2,"piid":1,"value":true},{"did":"c","siid":2,"piid":1,"value":true}]}`, result: `[{"did":"a","code":0,"message":"wrong"},{"did":"b","code":-704030023,"message":"server known"},{"did":"c","code":-99999,"message":"server detail"}]`},
{path: "/miotspec/action", body: `{"params":{"did":"d","siid":2,"aiid":1}}`, result: `{"did":"d","code":-99998,"message":"action detail"}`},
{path: "/miotspec/action", body: `{"params":{"did":"e","siid":2,"aiid":1}}`, result: `{"did":"e","code":-99997}`},
})
properties, err := client.SetProperties(context.Background(), []PropertySetRequest{{DID: "a", SIID: 2, PIID: 1, Value: true}, {DID: "b", SIID: 2, PIID: 1, Value: true}, {DID: "c", SIID: 2, PIID: 1, Value: true}})
if err != nil || properties[0].Message != "成功" || properties[1].Message != ErrorMessage(-704030023) || properties[2].Message != "server detail" {
t.Fatalf("SetProperties() = %#v, %v", properties, err)
}
actions, err := client.RunActions(context.Background(), []ActionRequest{{DID: "d", SIID: 2, AIID: 1}, {DID: "e", SIID: 2, AIID: 1}})
if err != nil || actions[0].Message != "action detail" || actions[1].Message != "未知错误" {
t.Fatalf("RunActions() = %#v, %v", actions, err)
}
verify()
}
func TestActionRequestMarshalJSON(t *testing.T) {
t.Run("nil extra matches legacy encoding", func(t *testing.T) {
payload, err := json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2})
if err != nil || string(payload) != `{"did":"d","siid":1,"aiid":2}` {
t.Fatalf("json.Marshal() = %s, %v", payload, err)
}
payload, err = json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2, Value: []any{5}})
if err != nil || string(payload) != `{"did":"d","siid":1,"aiid":2,"value":[5]}` {
t.Fatalf("json.Marshal() = %s, %v", payload, err)
}
})
t.Run("extra keys are merged", func(t *testing.T) {
payload, err := json.Marshal(ActionRequest{DID: "d", SIID: 5, AIID: 4, Extra: map[string]any{"in": []any{"打开空调", 1}}})
if err != nil || !jsonEqual(payload, []byte(`{"did":"d","siid":5,"aiid":4,"in":["打开空调",1]}`)) {
t.Fatalf("json.Marshal() = %s, %v", payload, err)
}
})
t.Run("reserved key conflicts fail", func(t *testing.T) {
for _, key := range []string{"did", "siid", "aiid", "value"} {
if _, err := json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2, Extra: map[string]any{key: "x"}}); err == nil {
t.Fatalf("json.Marshal() with extra key %q error = nil", key)
}
}
})
}
func TestRunActionsSendsExtraInsideParams(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/action", body: `{"params":{"did":"speaker","siid":5,"aiid":4,"in":["打开空调",1]}}`, result: `{"did":"speaker","siid":5,"aiid":4,"code":0}`},
})
results, err := client.RunActions(context.Background(), []ActionRequest{{DID: "speaker", SIID: 5, AIID: 4, Extra: map[string]any{"in": []any{"打开空调", 1}}}})
if err != nil || len(results) != 1 || results[0].Code != 0 {
t.Fatalf("RunActions() = %#v, %v", results, err)
}
verify()
client, verify = newAPIClient(t, nil)
if _, err := client.RunActions(context.Background(), []ActionRequest{{DID: "speaker", SIID: 5, AIID: 4, Extra: map[string]any{"value": 1}}}); err == nil {
t.Fatal("RunActions() with conflicting extra key error = nil")
}
verify()
}
const homesBody = `{"app_ver":7,"fetch_cariot":true,"fetch_share":true,"fetch_share_dev":true,"fg":true,"limit":300,"plat_form":0}`
func newAPIClient(t *testing.T, expectations []apiExpectation) (*Client, func()) {
t.Helper()
var mutex sync.Mutex
requestIndex := 0
var handlerErrors []error
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
mutex.Lock()
defer mutex.Unlock()
if requestIndex >= len(expectations) {
handlerErrors = append(handlerErrors, fmt.Errorf("unexpected request %s", request.URL.Path))
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
return
}
expectation := expectations[requestIndex]
requestIndex++
if request.URL.Path != expectation.path {
handlerErrors = append(handlerErrors, fmt.Errorf("request %d path = %s, want %s", requestIndex, request.URL.Path, expectation.path))
}
if err := request.ParseForm(); err != nil {
handlerErrors = append(handlerErrors, err)
return
}
nonce := request.PostForm.Get("_nonce")
signed, err := signedNonce(testSsecurity, nonce)
if err != nil {
handlerErrors = append(handlerErrors, err)
return
}
payload, err := decryptRC4(signed, request.PostForm.Get("data"))
if err != nil {
handlerErrors = append(handlerErrors, err)
return
}
if !jsonEqual(payload, []byte(expectation.body)) {
handlerErrors = append(handlerErrors, fmt.Errorf("request %d body = %s, want %s", requestIndex, payload, expectation.body))
}
if expectation.status != 0 {
writer.WriteHeader(expectation.status)
_, _ = io.WriteString(writer, expectation.result)
return
}
_, _ = io.WriteString(writer, `{"code":0,"result":`+expectation.result+`}`)
}))
client := testClient(t, server.Client())
client.baseURL = server.URL
return client, func() {
t.Helper()
server.Close()
mutex.Lock()
count := requestIndex
errors := append([]error(nil), handlerErrors...)
mutex.Unlock()
for _, err := range errors {
t.Error(err)
}
if count != len(expectations) {
t.Errorf("requests = %d, want %d", count, len(expectations))
}
}
}
func jsonEqual(left, right []byte) bool {
var leftValue any
var rightValue any
return json.Unmarshal(left, &leftValue) == nil && json.Unmarshal(right, &rightValue) == nil && reflect.DeepEqual(leftValue, rightValue)
}
+520
View File
@@ -0,0 +1,520 @@
package mijia
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/mdp/qrterminal/v3"
"rsc.io/qr"
)
const qrLoginTimeout = 120 * time.Second
type AuthData struct {
UA string `json:"ua"`
DeviceID string `json:"deviceId"`
PassO string `json:"pass_o"`
Psecurity string `json:"psecurity"`
Nonce string `json:"nonce"`
Ssecurity string `json:"ssecurity"`
PassToken string `json:"passToken"`
UserID string `json:"userId"`
CUserID string `json:"cUserId"`
ServiceToken string `json:"serviceToken"`
YetAnotherServiceToken string `json:"yetAnotherServiceToken"`
ExpireTime int64 `json:"expireTime"`
SaveTime int64 `json:"saveTime"`
Extra map[string]string `json:"-"`
}
func (data AuthData) MarshalJSON() ([]byte, error) {
fields := map[string]any{
"ua": data.UA, "deviceId": data.DeviceID, "pass_o": data.PassO,
"psecurity": data.Psecurity, "nonce": data.Nonce, "ssecurity": data.Ssecurity,
"passToken": data.PassToken, "userId": data.UserID, "cUserId": data.CUserID,
"serviceToken": data.ServiceToken, "yetAnotherServiceToken": data.YetAnotherServiceToken,
"expireTime": data.ExpireTime, "saveTime": data.SaveTime,
}
for key, value := range data.Extra {
if _, stable := fields[key]; !stable {
fields[key] = value
}
}
return json.Marshal(fields)
}
func (data *AuthData) UnmarshalJSON(payload []byte) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(payload, &fields); err != nil {
return err
}
stable := map[string]any{
"ua": &data.UA, "deviceId": &data.DeviceID, "pass_o": &data.PassO,
"psecurity": &data.Psecurity, "nonce": &data.Nonce, "ssecurity": &data.Ssecurity,
"passToken": &data.PassToken, "userId": &data.UserID, "cUserId": &data.CUserID,
"serviceToken": &data.ServiceToken, "yetAnotherServiceToken": &data.YetAnotherServiceToken,
"expireTime": &data.ExpireTime, "saveTime": &data.SaveTime,
}
data.Extra = make(map[string]string)
for key, raw := range fields {
if target, ok := stable[key]; ok {
if err := json.Unmarshal(raw, target); err != nil {
return fmt.Errorf("decode auth field %s: %w", key, err)
}
continue
}
var value string
if json.Unmarshal(raw, &value) == nil {
data.Extra[key] = value
}
}
return nil
}
func (data AuthData) complete() bool {
return data.UA != "" && data.Ssecurity != "" && data.UserID != "" && data.CUserID != "" && data.ServiceToken != ""
}
func (data AuthData) yetAnotherServiceToken() string {
if data.YetAnotherServiceToken != "" {
return data.YetAnotherServiceToken
}
if data.Extra != nil && data.Extra["yetAnotherServiceToken"] != "" {
return data.Extra["yetAnotherServiceToken"]
}
return data.ServiceToken
}
func (data AuthData) clone() AuthData {
clone := data
if data.Extra != nil {
clone.Extra = make(map[string]string, len(data.Extra))
for key, value := range data.Extra {
clone.Extra[key] = value
}
}
return clone
}
// AuthData returns a snapshot of the client's current authentication data.
func (client *Client) AuthData() AuthData {
client.authMu.RLock()
defer client.authMu.RUnlock()
return client.authData.clone()
}
func (client *Client) setAuthData(authData AuthData) {
client.authMu.Lock()
client.authData = authData.clone()
client.authMu.Unlock()
}
func (client *Client) updateAuthData(update func(*AuthData)) AuthData {
client.authMu.Lock()
defer client.authMu.Unlock()
update(&client.authData)
return client.authData.clone()
}
func (client *Client) loadAuthData() error {
payload, err := os.ReadFile(client.authPath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read auth data: %w", err)
}
var authData AuthData
if err := json.Unmarshal(payload, &authData); err != nil {
return fmt.Errorf("decode auth data: %w", err)
}
client.setAuthData(authData)
return nil
}
func (client *Client) ensureIdentity() {
client.updateAuthData(func(authData *AuthData) {
if authData.PassO == "" {
authData.PassO = randomString(16, "0123456789abcdef")
}
if authData.DeviceID == "" {
authData.DeviceID = randomString(16, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-")
}
if authData.UA != "" {
return
}
countryCode := "CN"
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
countryCode = parts[1]
}
id1 := randomString(40, "0123456789ABCDEF")
id2 := randomString(32, "0123456789ABCDEF")
id3 := randomString(32, "0123456789ABCDEF")
id4 := randomString(40, "0123456789ABCDEF")
authData.UA = fmt.Sprintf("Android-15-11.0.701-Xiaomi-23046RP50C-OS2.0.212.0.VMYCNXM-%s-%s-%s-%s-SmartHome-MI_APP_STORE-%s|%s|%s-64", id1, countryCode, id3, id2, id1, id4, authData.PassO)
})
}
func randomString(length int, alphabet string) string {
random := make([]byte, length)
if _, err := rand.Read(random); err != nil {
panic(fmt.Sprintf("generate random identity: %v", err))
}
for index := range random {
random[index] = alphabet[int(random[index])%len(alphabet)]
}
return string(random)
}
func (client *Client) saveAuthData() error {
authData := client.updateAuthData(func(authData *AuthData) {
authData.SaveTime = time.Now().UnixMilli()
})
payload, err := json.MarshalIndent(authData, "", " ")
if err != nil {
return fmt.Errorf("encode auth data: %w", err)
}
directory := filepath.Dir(client.authPath)
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("create auth directory: %w", err)
}
temporary, err := os.CreateTemp(directory, ".auth-*.json")
if err != nil {
return fmt.Errorf("create temporary auth file: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("secure temporary auth file: %w", err)
}
if _, err := temporary.Write(payload); err != nil {
temporary.Close()
return fmt.Errorf("write auth data: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("sync auth data: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close auth data: %w", err)
}
if err := os.Rename(temporaryPath, client.authPath); err != nil {
return fmt.Errorf("replace auth data: %w", err)
}
return nil
}
func parseServiceResponse(payload []byte, target any) error {
payload = []byte(strings.TrimPrefix(string(payload), "&&&START&&&"))
if err := json.Unmarshal(payload, target); err != nil {
return fmt.Errorf("decode login response: %w", err)
}
return nil
}
type serviceLoginData struct {
Code int `json:"code"`
Desc string `json:"desc"`
Location string `json:"location"`
Ssecurity string `json:"ssecurity"`
}
type qrLoginData struct {
Code int `json:"code"`
Desc string `json:"desc"`
LoginURL string `json:"loginUrl"`
QR string `json:"qr"`
LP string `json:"lp"`
}
type longPollData struct {
Code int `json:"code"`
Desc string `json:"desc"`
Location string `json:"location"`
Psecurity string `json:"psecurity"`
Nonce string `json:"nonce"`
Ssecurity string `json:"ssecurity"`
PassToken string `json:"passToken"`
UserID string `json:"userId"`
CUserID string `json:"cUserId"`
}
func (client *Client) Login(ctx context.Context) (AuthData, error) {
client.loginMu.Lock()
defer client.loginMu.Unlock()
location, refreshed, err := client.getLocation(ctx)
if err != nil {
return AuthData{}, err
}
if refreshed {
if err := client.saveAuthData(); err != nil {
return AuthData{}, err
}
return client.AuthData(), nil
}
loginData, err := client.getQRLoginData(ctx, location)
if err != nil {
return AuthData{}, err
}
if client.qrWriter != nil {
if _, err := qr.Encode(loginData.LoginURL, qr.L); err != nil {
return AuthData{}, fmt.Errorf("encode login QR code: %w", err)
}
fmt.Fprintf(client.qrWriter, "请使用米家APP扫描下方二维码\n%s\n", loginData.LoginURL)
qrterminal.GenerateHalfBlock(loginData.LoginURL, qrterminal.L, client.qrWriter)
if loginData.QR != "" {
fmt.Fprintf(client.qrWriter, "二维码图片: %s\n", loginData.QR)
}
}
return client.completeQRLogin(ctx, loginData)
}
func (client *Client) getLocation(ctx context.Context) (url.Values, bool, error) {
httpClient := client.newSession()
serviceURL, err := url.Parse(client.serviceLoginURL)
if err != nil {
return nil, false, fmt.Errorf("parse service login URL: %w", err)
}
query := serviceURL.Query()
query.Set("_json", "true")
query.Set("sid", "mijia")
query.Set("_locale", client.locale)
serviceURL.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceURL.String(), nil)
if err != nil {
return nil, false, err
}
client.setLoginHeaders(request, true)
var data serviceLoginData
if err := client.doLoginRequestWithClient(httpClient, request, false, &data); err != nil {
return nil, false, err
}
if data.Location == "" {
return nil, false, &LoginError{Code: data.Code, Message: "登录响应缺少 location"}
}
if data.Code == 0 {
refreshRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
if err != nil {
return nil, false, err
}
client.setLoginHeaders(refreshRequest, false)
response, err := httpClient.Do(refreshRequest)
if err != nil {
return nil, false, fmt.Errorf("refresh login token: %w", err)
}
body, readErr := readHTTPResponse(response)
response.Body.Close()
if readErr != nil {
return nil, false, fmt.Errorf("read token refresh response: %w", readErr)
}
if response.StatusCode == http.StatusOK && string(body) == "ok" {
candidate := client.AuthData()
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
candidate.Ssecurity = data.Ssecurity
if !serviceTokenReceived || !candidate.complete() {
return nil, false, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"}
}
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
client.setAuthData(candidate)
return nil, true, nil
}
}
locationURL, err := url.Parse(data.Location)
if err != nil {
return nil, false, fmt.Errorf("parse login location: %w", err)
}
return locationURL.Query(), false, nil
}
func (client *Client) getQRLoginData(ctx context.Context, location url.Values) (qrLoginData, error) {
location.Set("theme", "")
location.Set("bizDeviceType", "")
location.Set("_hasLogo", "false")
location.Set("_qrsize", "240")
location.Set("_dc", fmt.Sprintf("%d", time.Now().UnixMilli()))
loginURL, err := url.Parse(client.loginURL)
if err != nil {
return qrLoginData{}, err
}
loginURL.RawQuery = location.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
if err != nil {
return qrLoginData{}, err
}
client.setLoginHeaders(request, false)
var data qrLoginData
if err := client.doLoginRequest(request, true, &data); err != nil {
return qrLoginData{}, err
}
if data.LoginURL == "" || data.LP == "" {
return qrLoginData{}, &LoginError{Code: data.Code, Message: "二维码登录响应不完整"}
}
return data, nil
}
func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData) (AuthData, error) {
pollContext, cancel := context.WithTimeout(ctx, qrLoginTimeout)
defer cancel()
httpClient := client.newSession()
request, err := http.NewRequestWithContext(pollContext, http.MethodGet, loginData.LP, nil)
if err != nil {
return AuthData{}, err
}
client.setLoginHeaders(request, false)
var data longPollData
if err := client.doLoginRequestWithClient(httpClient, request, true, &data); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return AuthData{}, &LoginError{Code: -1, Message: "超时,请重试"}
}
return AuthData{}, err
}
callback, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
if err != nil {
return AuthData{}, err
}
client.setLoginHeaders(callback, false)
response, err := httpClient.Do(callback)
if err != nil {
return AuthData{}, fmt.Errorf("complete login callback: %w", err)
}
_, readErr := readHTTPResponse(response)
response.Body.Close()
if readErr != nil {
return AuthData{}, fmt.Errorf("read login callback response: %w", readErr)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return AuthData{}, &LoginError{Code: response.StatusCode, Message: "登录回调失败"}
}
candidate := client.AuthData()
candidate.Ssecurity = ""
candidate.UserID = ""
candidate.CUserID = ""
candidate.ServiceToken = ""
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
candidate.Psecurity = data.Psecurity
candidate.Nonce = data.Nonce
candidate.Ssecurity = data.Ssecurity
candidate.PassToken = data.PassToken
candidate.UserID = data.UserID
candidate.CUserID = data.CUserID
if !serviceTokenReceived || !candidate.complete() {
return AuthData{}, &LoginError{Code: -1, Message: "登录回调认证信息不完整"}
}
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
client.setAuthData(candidate)
if err := client.saveAuthData(); err != nil {
return AuthData{}, err
}
return client.AuthData(), nil
}
func (client *Client) doLoginRequest(request *http.Request, verifyCode bool, target any) error {
return client.doLoginRequestWithClient(client.session(), request, verifyCode, target)
}
func (client *Client) doLoginRequestWithClient(httpClient *http.Client, request *http.Request, verifyCode bool, target any) error {
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("send login request: %w", err)
}
defer response.Body.Close()
body, err := readHTTPResponse(response)
if err != nil {
return fmt.Errorf("read login response: %w", err)
}
if response.StatusCode != http.StatusOK {
return &LoginError{Code: response.StatusCode, Message: string(body)}
}
if err := parseServiceResponse(body, target); err != nil {
return err
}
if verifyCode {
encoded, _ := json.Marshal(target)
var status struct {
Code int `json:"code"`
Desc string `json:"desc"`
}
_ = json.Unmarshal(encoded, &status)
if status.Code != 0 {
return &LoginError{Code: status.Code, Message: status.Desc}
}
}
return nil
}
func (client *Client) setLoginHeaders(request *http.Request, withCookies bool) {
authData := client.AuthData()
request.Header.Set("User-Agent", authData.UA)
request.Header.Set("Connection", "keep-alive")
request.Header.Set("Accept-Encoding", "gzip")
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if withCookies {
request.Header.Set("Cookie", strings.Join([]string{
"deviceId=" + authData.DeviceID,
"pass_o=" + authData.PassO,
"passToken=" + authData.PassToken,
"userId=" + authData.UserID,
"cUserId=" + authData.CUserID,
"uLocale=" + client.locale,
}, ";"))
}
}
func updateAuthDataFromCookies(authData *AuthData, httpClient *http.Client, target *url.URL) bool {
if httpClient.Jar == nil || target == nil {
return false
}
cookies := httpClient.Jar.Cookies(target)
if authData.Extra == nil {
authData.Extra = make(map[string]string)
}
serviceTokenReceived := false
for _, cookie := range cookies {
switch cookie.Name {
case "serviceToken":
if cookie.Value != "" {
authData.ServiceToken = cookie.Value
serviceTokenReceived = true
}
case "yetAnotherServiceToken":
authData.YetAnotherServiceToken = cookie.Value
case "cUserId":
authData.CUserID = cookie.Value
default:
authData.Extra[cookie.Name] = cookie.Value
}
}
return serviceTokenReceived
}
func (client *Client) refreshToken(ctx context.Context) error {
client.loginMu.Lock()
defer client.loginMu.Unlock()
available, _ := client.Available(ctx)
if available {
return nil
}
_, refreshed, err := client.getLocation(ctx)
if err != nil {
return err
}
if !refreshed {
return &LoginError{Code: -1, Message: "刷新Token失败,请重新登录"}
}
if err := client.saveAuthData(); err != nil {
return err
}
return nil
}
+270
View File
@@ -0,0 +1,270 @@
package mijia
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseServiceResponse(t *testing.T) {
var result struct {
Code int `json:"code"`
}
if err := parseServiceResponse([]byte(`&&&START&&&{"code":0}`), &result); err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Fatalf("code = %d", result.Code)
}
}
func TestSaveAuthDataUses0600AndStableFields(t *testing.T) {
directory := t.TempDir()
client, err := NewClient(directory)
if err != nil {
t.Fatal(err)
}
client.setAuthData(AuthData{UA: "agent", DeviceID: "device", Extra: map[string]string{"yetAnotherServiceToken": "extra"}})
if err := client.saveAuthData(); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(directory, "auth.json"))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
}
contents, err := os.ReadFile(filepath.Join(directory, "auth.json"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(contents, []byte(`"deviceId"`)) || !bytes.Contains(contents, []byte(`"yetAnotherServiceToken"`)) {
t.Fatalf("saved JSON = %s", contents)
}
}
func TestLoginSilentlyRefreshesPassToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/serviceLogin":
writeGzipResponse(t, writer, `&&&START&&&{"code":0,"location":"`+serverURL(request)+`/refresh","ssecurity":"`+testSsecurity+`"}`)
case "/refresh":
http.SetCookie(writer, &http.Cookie{Name: "serviceToken", Value: "new-token", Path: "/"})
http.SetCookie(writer, &http.Cookie{Name: "cUserId", Value: "new-c-user", Path: "/"})
writeGzipResponse(t, writer, "ok")
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client := testClient(t, server.Client())
client.serviceLoginURL = server.URL + "/serviceLogin"
client.updateAuthData(func(authData *AuthData) { authData.PassToken = "pass-token" })
auth, err := client.Login(context.Background())
if err != nil {
t.Fatal(err)
}
if auth.ServiceToken != "new-token" || auth.CUserID != "new-c-user" {
t.Fatalf("auth = %#v", auth)
}
}
func TestRefreshRejectsMissingNewServiceTokenWithoutSaving(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/v2/message/v2/check_new_msg":
_, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`)
case "/serviceLogin":
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"location":"`+server.URL+`/refresh","ssecurity":"`+testSsecurity+`"}`)
case "/refresh":
http.SetCookie(writer, &http.Cookie{Name: "cUserId", Value: "new-c-user", Path: "/"})
_, _ = io.WriteString(writer, "ok")
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
client.serviceLoginURL = server.URL + "/serviceLogin"
client.availabilityValid = false
client.updateAuthData(func(authData *AuthData) { authData.PassToken = "pass-token" })
if err := client.saveAuthData(); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(client.authPath)
if err != nil {
t.Fatal(err)
}
err = client.refreshToken(context.Background())
var loginErr *LoginError
if !errors.As(err, &loginErr) {
t.Fatalf("refreshToken() error = %v, want LoginError", err)
}
after, readErr := os.ReadFile(client.authPath)
if readErr != nil {
t.Fatal(readErr)
}
if !bytes.Equal(after, before) {
t.Fatalf("auth file changed after failed refresh\nbefore: %s\nafter: %s", before, after)
}
if auth := client.AuthData(); auth.ServiceToken != "service-token" || auth.CUserID != "c-user" {
t.Fatalf("auth changed after failed refresh: %#v", auth)
}
}
func TestAuthDataReturnsDeepCopy(t *testing.T) {
client := testClient(t, http.DefaultClient)
client.updateAuthData(func(authData *AuthData) { authData.Extra = map[string]string{"cookie": "original"} })
snapshot := client.AuthData()
snapshot.Extra["cookie"] = "changed"
if got := client.AuthData().Extra["cookie"]; got != "original" {
t.Fatalf("stored extra cookie = %q, want original", got)
}
}
func TestLoginRejectsOversizedQRCode(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/serviceLogin":
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/prepare"}`)
case "/loginUrl":
loginURL := strings.Repeat("x", 10000)
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"`+loginURL+`","lp":"`+server.URL+`/lp"}`)
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()))
if err != nil {
t.Fatal(err)
}
client.serviceLoginURL = server.URL + "/serviceLogin"
client.loginURL = server.URL + "/loginUrl"
client.qrWriter = io.Discard
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Login(ctx); err == nil || !strings.Contains(err.Error(), "QR code") {
t.Fatalf("Login() error = %v, want QR encoding error", err)
}
}
func TestLoginQRCoreFlow(t *testing.T) {
var output bytes.Buffer
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/serviceLogin":
location := server.URL + `/prepare?sid=mijia&foo=bar`
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+location+`"}`)
case "/loginUrl":
query := request.URL.Query()
for _, key := range []string{"theme", "bizDeviceType", "_hasLogo", "_qrsize", "_dc", "sid", "foo"} {
if _, ok := query[key]; !ok {
t.Errorf("login query missing %q: %s", key, request.URL.RawQuery)
}
}
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","qr":"https://qr.example/image","lp":"`+server.URL+`/lp"}`)
case "/lp":
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"psecurity":"p","nonce":"n","ssecurity":"`+testSsecurity+`","passToken":"pass","userId":"user","cUserId":"cuser","location":"`+server.URL+`/callback"}`)
case "/callback":
http.SetCookie(writer, &http.Cookie{Name: "serviceToken", Value: "service", Path: "/"})
http.SetCookie(writer, &http.Cookie{Name: "yetAnotherServiceToken", Value: "another", Path: "/"})
_, _ = io.WriteString(writer, "ok")
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()))
if err != nil {
t.Fatal(err)
}
client.serviceLoginURL = server.URL + "/serviceLogin"
client.loginURL = server.URL + "/loginUrl"
client.qrWriter = &output
auth, err := client.Login(context.Background())
if err != nil {
t.Fatal(err)
}
if auth.ServiceToken != "service" || auth.Psecurity != "p" || auth.ExpireTime <= auth.SaveTime {
t.Fatalf("auth = %#v", auth)
}
if auth.YetAnotherServiceToken != "another" {
t.Fatalf("yetAnotherServiceToken = %q", auth.YetAnotherServiceToken)
}
if !strings.Contains(output.String(), "qr.example/login") {
t.Fatalf("QR output does not contain login URL")
}
contents, err := os.ReadFile(client.authPath)
if err != nil {
t.Fatal(err)
}
var saved AuthData
if err := json.Unmarshal(contents, &saved); err != nil || saved.ServiceToken != "service" {
t.Fatalf("saved auth = %#v, error = %v", saved, err)
}
}
func TestQRLoginRejectsIncompleteCallbackWithoutSaving(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/lp":
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"ssecurity":"`+testSsecurity+`","passToken":"pass","userId":"user","cUserId":"cuser","location":"`+server.URL+`/callback"}`)
case "/callback":
_, _ = io.WriteString(writer, "ok")
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client := testClient(t, server.Client())
_, err := client.completeQRLogin(context.Background(), qrLoginData{LP: server.URL + "/lp"})
var loginErr *LoginError
if !errors.As(err, &loginErr) {
t.Fatalf("completeQRLogin() error = %v, want LoginError", err)
}
if _, statErr := os.Stat(client.authPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("auth file stat error = %v, want not exist", statErr)
}
}
func serverURL(request *http.Request) string {
return "http://" + request.Host
}
func writeGzipResponse(t *testing.T, writer http.ResponseWriter, body string) {
t.Helper()
writer.Header().Set("Content-Encoding", "gzip")
gzipWriter := gzip.NewWriter(writer)
if _, err := io.WriteString(gzipWriter, body); err != nil {
t.Errorf("write gzip response: %v", err)
}
if err := gzipWriter.Close(); err != nil {
t.Errorf("close gzip response: %v", err)
}
}
+303
View File
@@ -0,0 +1,303 @@
package mijia
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const (
defaultBaseURL = "https://api.mijia.tech/app"
defaultLoginURL = "https://account.xiaomi.com/longPolling/loginUrl"
defaultServiceLoginURL = "https://account.xiaomi.com/pass/serviceLogin"
availabilityTTL = 60 * time.Second
)
type Option func(*Client) error
// WithHTTPClient configures the HTTP transport used by the client.
func WithHTTPClient(httpClient *http.Client) Option {
return func(client *Client) error {
if httpClient == nil {
return fmt.Errorf("HTTP client must not be nil")
}
client.httpClient = httpClient
return nil
}
}
type Client struct {
authPath string
authMu sync.RWMutex
authData AuthData
loginMu sync.Mutex
httpClientMu sync.RWMutex
httpClient *http.Client
baseURL string
loginURL string
serviceLoginURL string
locale string
qrWriter io.Writer
availabilityMu sync.Mutex
availability bool
availabilityValid bool
availabilityAt time.Time
}
func NewClient(authPath string, options ...Option) (*Client, error) {
resolvedPath, err := resolveAuthPath(authPath)
if err != nil {
return nil, err
}
client := &Client{
authPath: resolvedPath,
httpClient: http.DefaultClient,
baseURL: defaultBaseURL,
loginURL: defaultLoginURL,
serviceLoginURL: defaultServiceLoginURL,
locale: systemLocale(),
qrWriter: os.Stdout,
}
for _, option := range options {
if option == nil {
continue
}
if err := option(client); err != nil {
return nil, err
}
}
if err := client.loadAuthData(); err != nil {
return nil, err
}
client.ensureIdentity()
client.initSession()
return client, nil
}
func resolveAuthPath(authPath string) (string, error) {
if authPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".config", "mijia-api", "auth.json"), nil
}
info, err := os.Stat(authPath)
if err == nil && info.IsDir() {
return filepath.Join(authPath, "auth.json"), nil
}
if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("inspect auth path: %w", err)
}
return authPath, nil
}
func systemLocale() string {
locale := strings.Split(os.Getenv("LC_ALL"), ".")[0]
if locale == "" {
locale = strings.Split(os.Getenv("LANG"), ".")[0]
}
if parts := strings.Split(locale, "_"); len(parts) == 2 && len(parts[1]) >= 2 {
return parts[0] + "_" + strings.ToUpper(parts[1][:2])
}
return "zh_CN"
}
func (client *Client) initSession() {
client.httpClientMu.Lock()
defer client.httpClientMu.Unlock()
client.httpClient = cloneHTTPClient(client.httpClient)
}
func cloneHTTPClient(httpClient *http.Client) *http.Client {
clone := *httpClient
clone.Jar, _ = cookiejar.New(nil)
return &clone
}
func (client *Client) session() *http.Client {
client.httpClientMu.RLock()
defer client.httpClientMu.RUnlock()
return client.httpClient
}
func (client *Client) newSession() *http.Client {
return cloneHTTPClient(client.session())
}
func (client *Client) request(ctx context.Context, uri string, data any, refresh bool) (json.RawMessage, error) {
if refresh {
if err := client.refreshToken(ctx); err != nil {
return nil, err
}
}
payload, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal request data: %w", err)
}
nonce, err := generateNonce()
if err != nil {
return nil, err
}
authData := client.AuthData()
signedNonceValue, err := signedNonce(authData.Ssecurity, nonce)
if err != nil {
return nil, err
}
params, err := generateEncryptedParams(uri, http.MethodPost, signedNonceValue, nonce, string(payload), authData.Ssecurity)
if err != nil {
return nil, err
}
form := url.Values{
"data": {params.Data},
"rc4_hash__": {params.RC4Hash},
"signature": {params.Signature},
"ssecurity": {params.Ssecurity},
"_nonce": {params.Nonce},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+uri, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("create API request: %w", err)
}
client.setAPIHeaders(req, authData)
response, err := client.session().Do(req)
if err != nil {
return nil, fmt.Errorf("send API request: %w", err)
}
defer response.Body.Close()
body, err := readHTTPResponse(response)
if err != nil {
return nil, fmt.Errorf("read API response: %w", err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
}
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
if err != nil {
return nil, fmt.Errorf("decode API response from %s: %w", uri, err)
}
return result, nil
}
func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, error) {
var envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Desc string `json:"desc"`
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
decrypted, decryptErr := decryptPayload(ssecurity, nonce, string(body))
if decryptErr != nil {
return nil, fmt.Errorf("decode API response: %w", decryptErr)
}
if err := json.Unmarshal([]byte(decrypted), &envelope); err != nil {
return nil, fmt.Errorf("decode decrypted API response: %w", err)
}
}
if envelope.Code != 0 || envelope.Result == nil {
message := envelope.Message
if message == "" {
message = envelope.Desc
}
if message == "" {
message = "未知错误"
}
return nil, &APIError{Code: envelope.Code, Message: message}
}
return envelope.Result, nil
}
func (client *Client) setAPIHeaders(request *http.Request, authData AuthData) {
request.Header.Set("User-Agent", authData.UA)
request.Header.Set("Accept-Encoding", "identity")
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("miot-accept-encoding", "GZIP")
request.Header.Set("miot-encrypt-algorithm", "ENCRYPT-RC4")
request.Header.Set("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2")
request.Header.Set("Cookie", client.apiCookieHeader(authData))
}
func (client *Client) apiCookieHeader(authData AuthData) string {
now := time.Now()
_, offset := now.Zone()
zoneName := now.Location().String()
if zoneName == "Local" {
zoneName, _ = now.Zone()
}
isDaylight, dstOffset := daylightValues(now)
countryCode := "CN"
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
countryCode = parts[1]
}
timezone := fmt.Sprintf("GMT%+03d:%02d", offset/3600, abs(offset/60)%60)
values := []string{
"cUserId=" + authData.CUserID,
"yetAnotherServiceToken=" + authData.yetAnotherServiceToken(),
"serviceToken=" + authData.ServiceToken,
"timezone_id=" + zoneName,
"timezone=" + timezone,
"is_daylight=" + strconv.Itoa(isDaylight),
"dst_offset=" + strconv.Itoa(dstOffset),
"channel=MI_APP_STORE",
"countryCode=" + countryCode,
"PassportDeviceId=" + authData.DeviceID,
"locale=" + client.locale,
}
return strings.Join(values, ";")
}
func daylightValues(now time.Time) (int, int) {
location := now.Location()
dstOffset := 0
if now.In(location).IsDST() {
dstOffset = 60 * 60 * 1000
}
for date := time.Date(now.Year(), time.January, 1, 12, 0, 0, 0, location); date.Year() == now.Year(); date = date.AddDate(0, 0, 1) {
if date.IsDST() {
return 1, dstOffset
}
}
return 0, dstOffset
}
func abs(value int) int {
if value < 0 {
return -value
}
return value
}
func (client *Client) Available(ctx context.Context) (bool, error) {
if !client.AuthData().complete() {
return false, nil
}
client.availabilityMu.Lock()
defer client.availabilityMu.Unlock()
if client.availabilityValid && time.Since(client.availabilityAt) < availabilityTTL {
return client.availability, nil
}
_, err := client.request(ctx, "/v2/message/v2/check_new_msg", map[string]int64{"begin_at": time.Now().Unix() - 3600}, false)
if err != nil {
client.availability = false
client.availabilityValid = false
client.availabilityAt = time.Time{}
return false, err
}
client.availability = true
client.availabilityValid = true
client.availabilityAt = time.Now()
return true, nil
}
+456
View File
@@ -0,0 +1,456 @@
package mijia
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
func TestRequestEncryptedPostAndPlainResponse(t *testing.T) {
var received url.Values
handlerErrors := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/test" {
handlerErrors <- fmt.Errorf("request = %s %s, want POST /test", request.Method, request.URL.Path)
return
}
if err := request.ParseForm(); err != nil {
handlerErrors <- err
return
}
received = request.PostForm
assertAPIHeaders(t, request)
_, _ = io.WriteString(writer, `{"code":0,"result":{"ok":true}}`)
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
result, err := client.request(context.Background(), "/test", map[string]any{"name": "lamp"}, false)
if err != nil {
t.Fatalf("request() error = %v", err)
}
select {
case err := <-handlerErrors:
t.Fatal(err)
default:
}
if string(result) != `{"ok":true}` {
t.Fatalf("result = %s", result)
}
for _, key := range []string{"data", "rc4_hash__", "signature", "ssecurity", "_nonce"} {
if received.Get(key) == "" {
t.Errorf("missing encrypted form field %q", key)
}
}
if received.Get("ssecurity") != testSsecurity {
t.Errorf("ssecurity = %q", received.Get("ssecurity"))
}
assertAPICookies(t, receivedCookieHeader)
}
var receivedCookieHeader string
func assertAPIHeaders(t *testing.T, request *http.Request) {
t.Helper()
receivedCookieHeader = request.Header.Get("Cookie")
want := map[string]string{
"Accept-Encoding": "identity",
"Content-Type": "application/x-www-form-urlencoded",
"Miot-Accept-Encoding": "GZIP",
"Miot-Encrypt-Algorithm": "ENCRYPT-RC4",
"X-Xiaomi-Protocal-Flag-Cli": "PROTOCAL-HTTP2",
}
for key, value := range want {
if request.Header.Get(key) != value {
t.Errorf("header %s = %q, want %q", key, request.Header.Get(key), value)
}
}
}
func assertAPICookies(t *testing.T, cookieHeader string) {
t.Helper()
for _, value := range []string{
"cUserId=c-user", "yetAnotherServiceToken=service-token", "serviceToken=service-token",
"timezone_id=", "timezone=GMT", "is_daylight=", "dst_offset=", "channel=MI_APP_STORE",
"countryCode=CN", "PassportDeviceId=device-id", "locale=zh_CN",
} {
if !strings.Contains(cookieHeader, value) {
t.Errorf("Cookie %q does not contain %q", cookieHeader, value)
}
}
}
func TestDaylightValues(t *testing.T) {
tests := []struct {
name string
locationName string
month time.Month
wantDaylight int
wantDSTOffset int
}{
{name: "northern winter", locationName: "America/New_York", month: time.January, wantDaylight: 1, wantDSTOffset: 0},
{name: "northern summer", locationName: "America/New_York", month: time.July, wantDaylight: 1, wantDSTOffset: 3600000},
{name: "southern summer", locationName: "Australia/Sydney", month: time.January, wantDaylight: 1, wantDSTOffset: 3600000},
{name: "southern winter", locationName: "Australia/Sydney", month: time.July, wantDaylight: 1, wantDSTOffset: 0},
{name: "Dublin negative DST winter", locationName: "Europe/Dublin", month: time.January, wantDaylight: 1, wantDSTOffset: 3600000},
{name: "Dublin standard summer", locationName: "Europe/Dublin", month: time.July, wantDaylight: 1, wantDSTOffset: 0},
{name: "no daylight saving", locationName: "Asia/Shanghai", month: time.July, wantDaylight: 0, wantDSTOffset: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
location, err := time.LoadLocation(test.locationName)
if err != nil {
t.Fatal(err)
}
now := time.Date(2024, test.month, 15, 12, 0, 0, 0, location)
daylight, dstOffset := daylightValues(now)
if daylight != test.wantDaylight || dstOffset != test.wantDSTOffset {
t.Fatalf("daylightValues(%s) = (%d, %d), want (%d, %d)", now, daylight, dstOffset, test.wantDaylight, test.wantDSTOffset)
}
})
}
}
func TestRequestDecryptsResponseAndReturnsAPIErrors(t *testing.T) {
tests := []struct {
name string
response func(*http.Request) string
wantCode int
wantResult string
}{
{
name: "encrypted",
response: func(request *http.Request) string {
_ = request.ParseForm()
nonce := request.PostForm.Get("_nonce")
signed, _ := signedNonce(testSsecurity, nonce)
ciphertext, _ := encryptRC4(signed, `{"code":0,"result":[1,2]}`)
return ciphertext
},
wantResult: `[1,2]`,
},
{name: "api error", response: func(*http.Request) string { return `{"code":-10002,"message":"bad"}` }, wantCode: -10002},
{name: "missing result", response: func(*http.Request) string { return `{"code":0}` }, wantCode: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = io.WriteString(writer, test.response(request))
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
result, err := client.request(context.Background(), "/test", map[string]any{}, false)
if test.wantCode != 0 || test.name == "missing result" {
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != test.wantCode {
t.Fatalf("error = %v, want APIError code %d", err, test.wantCode)
}
return
}
if err != nil || string(result) != test.wantResult {
t.Fatalf("result = %s, error = %v", result, err)
}
})
}
}
func TestRequestRejectsHTTPStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
_, err := client.request(context.Background(), "/test", nil, false)
if err == nil || !strings.Contains(err.Error(), "503") {
t.Fatalf("error = %v, want HTTP status error", err)
}
}
func TestRequestRejectsOversizedRawResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
_, err := client.request(context.Background(), "/test", nil, false)
if err == nil || !strings.Contains(err.Error(), "raw HTTP response") || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("error = %v, want oversized raw response error", err)
}
}
func TestRequestRejectsOversizedGzipResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Encoding", "gzip")
gzipWriter := gzip.NewWriter(writer)
_, _ = gzipWriter.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
_ = gzipWriter.Close()
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
_, err := client.request(context.Background(), "/test", nil, false)
if err == nil || !strings.Contains(err.Error(), "decompress gzip HTTP response") || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("error = %v, want oversized gzip response error", err)
}
}
func TestConcurrentRequestsUseConsistentAuthSnapshot(t *testing.T) {
handlerErrors := make(chan error, 100)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if err := request.ParseForm(); err != nil {
handlerErrors <- err
return
}
security := request.PostForm.Get("ssecurity")
cookie := request.Header.Get("Cookie")
if security == testSsecurity && strings.Contains(cookie, "cUserId=c-user-a") && strings.Contains(cookie, "serviceToken=token-a") {
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
return
}
if security == "QUJDREVGR0hJSktMTU5PUA==" && strings.Contains(cookie, "cUserId=c-user-b") && strings.Contains(cookie, "serviceToken=token-b") {
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
return
}
handlerErrors <- fmt.Errorf("mixed credentials: ssecurity=%q cookie=%q", security, cookie)
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
first := client.AuthData()
first.CUserID = "c-user-a"
first.ServiceToken = "token-a"
second := first.clone()
second.Ssecurity = "QUJDREVGR0hJSktMTU5PUA=="
second.CUserID = "c-user-b"
second.ServiceToken = "token-b"
client.setAuthData(first)
var waitGroup sync.WaitGroup
for index := range 100 {
client.setAuthData(first)
if index%2 == 1 {
client.setAuthData(second)
}
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
if _, err := client.request(context.Background(), "/test", nil, false); err != nil {
handlerErrors <- err
}
}()
}
waitGroup.Wait()
close(handlerErrors)
for err := range handlerErrors {
t.Error(err)
}
}
func TestAvailableCachesSuccessfulProbe(t *testing.T) {
requests := 0
handlerErrors := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
if request.URL.Path != "/v2/message/v2/check_new_msg" {
handlerErrors <- fmt.Errorf("path = %s", request.URL.Path)
return
}
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
client.availabilityValid = false
for range 2 {
available, err := client.Available(context.Background())
if err != nil || !available {
t.Fatalf("Available() = %v, %v", available, err)
}
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
select {
case err := <-handlerErrors:
t.Fatal(err)
default:
}
}
func TestRequestRefreshesAfterFailedProbeBeforeBusinessRequest(t *testing.T) {
var server *httptest.Server
probeRequests := 0
businessRequests := 0
refreshRequests := 0
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/v2/message/v2/check_new_msg":
probeRequests++
_, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`)
case "/serviceLogin":
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"location":"`+server.URL+`/refresh","ssecurity":"`+testSsecurity+`"}`)
case "/refresh":
refreshRequests++
http.SetCookie(writer, &http.Cookie{Name: "serviceToken", Value: "new-token", Path: "/"})
http.SetCookie(writer, &http.Cookie{Name: "cUserId", Value: "new-c-user", Path: "/"})
_, _ = io.WriteString(writer, "ok")
case "/business":
businessRequests++
if !strings.Contains(request.Header.Get("Cookie"), "serviceToken=new-token") {
t.Errorf("business Cookie = %q", request.Header.Get("Cookie"))
}
_, _ = io.WriteString(writer, `{"code":0,"result":{"ok":true}}`)
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
client.serviceLoginURL = server.URL + "/serviceLogin"
client.availabilityValid = false
client.updateAuthData(func(authData *AuthData) { authData.PassToken = "pass-token" })
result, err := client.request(context.Background(), "/business", nil, true)
if err != nil || string(result) != `{"ok":true}` {
t.Fatalf("request() = %s, %v", result, err)
}
if probeRequests != 1 || refreshRequests != 1 || businessRequests != 1 {
t.Fatalf("requests: probe=%d refresh=%d business=%d", probeRequests, refreshRequests, businessRequests)
}
}
func TestRefreshTokenUsesAvailableCache(t *testing.T) {
probeRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/v2/message/v2/check_new_msg" {
http.NotFound(writer, request)
return
}
probeRequests++
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
client.availabilityValid = false
for range 2 {
if err := client.refreshToken(context.Background()); err != nil {
t.Fatal(err)
}
}
if probeRequests != 1 {
t.Fatalf("probe requests = %d, want 1", probeRequests)
}
}
func TestRequestUsesDistinctYetAnotherServiceToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
cookie := request.Header.Get("Cookie")
if !strings.Contains(cookie, "yetAnotherServiceToken=another-token") || !strings.Contains(cookie, "serviceToken=service-token") {
t.Errorf("Cookie = %q", cookie)
}
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
}))
defer server.Close()
client := testClient(t, server.Client())
client.baseURL = server.URL
client.updateAuthData(func(authData *AuthData) { authData.YetAnotherServiceToken = "another-token" })
if _, err := client.request(context.Background(), "/test", nil, false); err != nil {
t.Fatal(err)
}
}
func TestAvailableRequiresAuthFields(t *testing.T) {
client, err := NewClient(t.TempDir(), WithHTTPClient(http.DefaultClient))
if err != nil {
t.Fatal(err)
}
available, err := client.Available(context.Background())
if err != nil || available {
t.Fatalf("Available() = %v, %v, want false, nil", available, err)
}
}
func testClient(t *testing.T, httpClient *http.Client) *Client {
t.Helper()
client, err := NewClient(t.TempDir(), WithHTTPClient(httpClient))
if err != nil {
t.Fatal(err)
}
client.locale = "zh_CN"
client.setAuthData(AuthData{
UA: "test-agent", DeviceID: "device-id", PassO: "pass-o", Ssecurity: testSsecurity,
UserID: "user", CUserID: "c-user", ServiceToken: "service-token",
ExpireTime: time.Now().Add(24 * time.Hour).UnixMilli(),
})
client.availability = true
client.availabilityValid = true
client.availabilityAt = time.Now()
return client
}
func TestClientsDoNotShareInjectedCookieJar(t *testing.T) {
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
source := &http.Client{
Transport: http.DefaultTransport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
Jar: jar,
Timeout: time.Second,
}
first, err := NewClient(t.TempDir(), WithHTTPClient(source))
if err != nil {
t.Fatal(err)
}
second, err := NewClient(t.TempDir(), WithHTTPClient(source))
if err != nil {
t.Fatal(err)
}
target, _ := url.Parse("https://example.com/")
first.session().Jar.SetCookies(target, []*http.Cookie{{Name: "client", Value: "first"}})
if cookies := second.session().Jar.Cookies(target); len(cookies) != 0 {
t.Fatalf("second client cookies = %v, want none", cookies)
}
if first.session().Transport != source.Transport || first.session().Timeout != source.Timeout || first.session().CheckRedirect == nil {
t.Fatal("HTTP client configuration was not preserved")
}
}
func TestAuthDataJSONFieldNames(t *testing.T) {
data, err := json.Marshal(AuthData{})
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"ua", "deviceId", "pass_o", "psecurity", "nonce", "ssecurity", "passToken", "userId", "cUserId", "serviceToken", "yetAnotherServiceToken", "expireTime", "saveTime"} {
if !strings.Contains(string(data), `"`+field+`"`) {
t.Errorf("JSON %s missing field %q", data, field)
}
}
}
+180
View File
@@ -0,0 +1,180 @@
package mijia
import (
"bytes"
"compress/gzip"
"crypto/rand"
"crypto/rc4"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
)
const (
rc4DropBytes = 1024
maxDecompressedResponseBytes = 16 << 20
)
type orderedParam struct {
Key string
Value string
}
type encryptedParams struct {
Data string
RC4Hash string
Signature string
Ssecurity string
Nonce string
}
func generateNonce() (string, error) {
randomBytes := make([]byte, 8)
if _, err := rand.Read(randomBytes); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
minute := uint64(time.Now().Unix() / 60)
minuteBytes := make([]byte, 0, 8)
for value := minute; value > 0; value >>= 8 {
minuteBytes = append(minuteBytes, byte(value))
}
if len(minuteBytes) == 0 {
minuteBytes = append(minuteBytes, 0)
}
for left, right := 0, len(minuteBytes)-1; left < right; left, right = left+1, right-1 {
minuteBytes[left], minuteBytes[right] = minuteBytes[right], minuteBytes[left]
}
nonce := append(randomBytes, minuteBytes...)
return base64.StdEncoding.EncodeToString(nonce), nil
}
func signedNonce(ssecurity, nonce string) (string, error) {
securityBytes, err := base64.StdEncoding.DecodeString(ssecurity)
if err != nil {
return "", fmt.Errorf("decode ssecurity: %w", err)
}
nonceBytes, err := base64.StdEncoding.DecodeString(nonce)
if err != nil {
return "", fmt.Errorf("decode nonce: %w", err)
}
digest := sha256.New()
_, _ = digest.Write(securityBytes)
_, _ = digest.Write(nonceBytes)
return base64.StdEncoding.EncodeToString(digest.Sum(nil)), nil
}
func encryptedSignature(uri, method string, params []orderedParam, nonce string) string {
parts := make([]string, 0, len(params)+3)
parts = append(parts, strings.ToUpper(method), uri)
for _, param := range params {
parts = append(parts, param.Key+"="+param.Value)
}
parts = append(parts, nonce)
digest := sha1.Sum([]byte(strings.Join(parts, "&")))
return base64.StdEncoding.EncodeToString(digest[:])
}
func generateEncryptedParams(uri, method, signedNonceValue, nonce, data, ssecurity string) (encryptedParams, error) {
plainParams := []orderedParam{{Key: "data", Value: data}}
rc4Hash := encryptedSignature(uri, method, plainParams, signedNonceValue)
encryptedData, err := encryptRC4(signedNonceValue, data)
if err != nil {
return encryptedParams{}, fmt.Errorf("encrypt data: %w", err)
}
encryptedRC4Hash, err := encryptRC4(signedNonceValue, rc4Hash)
if err != nil {
return encryptedParams{}, fmt.Errorf("encrypt rc4_hash__: %w", err)
}
encrypted := []orderedParam{
{Key: "data", Value: encryptedData},
{Key: "rc4_hash__", Value: encryptedRC4Hash},
}
return encryptedParams{
Data: encryptedData,
RC4Hash: encryptedRC4Hash,
Signature: encryptedSignature(uri, method, encrypted, signedNonceValue),
Ssecurity: ssecurity,
Nonce: nonce,
}, nil
}
func encryptRC4(password, payload string) (string, error) {
return encryptRC4Bytes(password, []byte(payload))
}
func encryptRC4Bytes(password string, payload []byte) (string, error) {
result, err := cryptRC4(password, payload)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(result), nil
}
func decryptRC4(password, payload string) ([]byte, error) {
ciphertext, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, fmt.Errorf("decode RC4 payload: %w", err)
}
return cryptRC4(password, ciphertext)
}
func cryptRC4(password string, payload []byte) ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(password)
if err != nil {
return nil, fmt.Errorf("decode RC4 key: %w", err)
}
cipher, err := rc4.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create RC4 cipher: %w", err)
}
discard := make([]byte, rc4DropBytes)
cipher.XORKeyStream(discard, discard)
result := make([]byte, len(payload))
cipher.XORKeyStream(result, payload)
return result, nil
}
func decryptPayload(ssecurity, nonce, payload string) (string, error) {
signedNonceValue, err := signedNonce(ssecurity, nonce)
if err != nil {
return "", err
}
decrypted, err := decryptRC4(signedNonceValue, payload)
if err != nil {
return "", err
}
if utf8.Valid(decrypted) {
return string(decrypted), nil
}
reader, err := gzip.NewReader(bytes.NewReader(decrypted))
if err != nil {
return "", fmt.Errorf("open gzip response: %w", err)
}
decompressed, readErr := io.ReadAll(io.LimitReader(reader, maxDecompressedResponseBytes+1))
closeErr := reader.Close()
if readErr != nil {
return "", fmt.Errorf("decompress response: %w", readErr)
}
if closeErr != nil {
return "", fmt.Errorf("close gzip response: %w", closeErr)
}
if len(decompressed) > maxDecompressedResponseBytes {
return "", fmt.Errorf("decompressed response exceeds %d bytes", maxDecompressedResponseBytes)
}
if !utf8.Valid(decompressed) {
return "", fmt.Errorf("decrypted response is not valid UTF-8")
}
return string(decompressed), nil
}
+171
View File
@@ -0,0 +1,171 @@
package mijia
import (
"bytes"
"compress/gzip"
"encoding/base64"
"strings"
"testing"
"time"
)
const (
testSsecurity = "MDEyMzQ1Njc4OWFiY2RlZg=="
testNonce = "AAECAwQFBgcICQoL"
testSignedNonce = "16/CeTzC9IqVVbiZ01Hy/Qd8rtVo5ybLo+ph/Vvh52k="
)
func TestGenerateNonce(t *testing.T) {
before := time.Now().Unix() / 60
nonce, err := generateNonce()
if err != nil {
t.Fatalf("generateNonce() error = %v", err)
}
after := time.Now().Unix() / 60
decoded, err := base64.StdEncoding.DecodeString(nonce)
if err != nil {
t.Fatalf("generated nonce is not base64: %v", err)
}
if len(decoded) <= 8 {
t.Fatalf("decoded nonce length = %d, want more than 8", len(decoded))
}
minute := decodeBigEndian(decoded[8:])
if minute < before || minute > after {
t.Fatalf("nonce minute = %d, want between %d and %d", minute, before, after)
}
if len(decoded[8:]) > 1 && decoded[8] == 0 {
t.Fatal("nonce minute is not minimally encoded")
}
}
func TestSignedNonce(t *testing.T) {
got, err := signedNonce(testSsecurity, testNonce)
if err != nil {
t.Fatalf("signedNonce() error = %v", err)
}
if got != testSignedNonce {
t.Fatalf("signedNonce() = %q, want %q", got, testSignedNonce)
}
}
func TestRC4Drop1024(t *testing.T) {
ciphertext, err := encryptRC4(testSignedNonce, "hello world")
if err != nil {
t.Fatalf("encryptRC4() error = %v", err)
}
if ciphertext != "9ve6riTrkW1oJUE=" {
t.Fatalf("encryptRC4() = %q, want fixed ciphertext", ciphertext)
}
plaintext, err := decryptRC4(testSignedNonce, ciphertext)
if err != nil {
t.Fatalf("decryptRC4() error = %v", err)
}
if string(plaintext) != "hello world" {
t.Fatalf("decryptRC4() = %q, want %q", plaintext, "hello world")
}
}
func TestGenerateEncryptedParams(t *testing.T) {
params, err := generateEncryptedParams("/miotspec/prop/get", "post", testSignedNonce, testNonce, "{\"k\":\"v\"}", testSsecurity)
if err != nil {
t.Fatalf("generateEncryptedParams() error = %v", err)
}
want := encryptedParams{
Data: "5bC94HHpkCBn",
RC4Hash: "19WFsROvqTVVE0wemsEqz8GI7Ib8ZRiiLJS5hQ==",
Signature: "uRPqk6IHkFCAg+Skfo+5l/KbTpM=",
Ssecurity: testSsecurity,
Nonce: testNonce,
}
if params != want {
t.Fatalf("generateEncryptedParams() = %#v, want %#v", params, want)
}
firstSignature := encryptedSignature("/miotspec/prop/get", "post", []orderedParam{{Key: "data", Value: "{\"k\":\"v\"}"}}, testSignedNonce)
if firstSignature != "IGSsXdO7OZiyN58pywgUYELur6g=" {
t.Fatalf("first signature = %q, want fixed signature", firstSignature)
}
}
func TestDecryptPayload(t *testing.T) {
plaintext, err := decryptPayload(testSsecurity, testNonce, "9ve6riTrkW1oJUE=")
if err != nil {
t.Fatalf("decryptPayload() plain error = %v", err)
}
if plaintext != "hello world" {
t.Fatalf("decryptPayload() plain = %q, want %q", plaintext, "hello world")
}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write([]byte("gzip response")); err != nil {
t.Fatalf("gzip write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("gzip close: %v", err)
}
ciphertext, err := encryptRC4Bytes(testSignedNonce, compressed.Bytes())
if err != nil {
t.Fatalf("encrypt compressed response: %v", err)
}
decrypted, err := decryptPayload(testSsecurity, testNonce, ciphertext)
if err != nil {
t.Fatalf("decryptPayload() gzip error = %v", err)
}
if decrypted != "gzip response" {
t.Fatalf("decryptPayload() gzip = %q, want %q", decrypted, "gzip response")
}
}
func TestDecryptPayloadInvalidBase64(t *testing.T) {
_, err := decryptPayload(testSsecurity, testNonce, "not-base64")
if err == nil || !strings.Contains(err.Error(), "decode RC4 payload") {
t.Fatalf("decryptPayload() error = %v, want invalid base64 error", err)
}
}
func TestDecryptPayloadDamagedGzip(t *testing.T) {
ciphertext, err := encryptRC4Bytes(testSignedNonce, []byte{0x1f, 0x8b, 0xff})
if err != nil {
t.Fatalf("encrypt damaged gzip response: %v", err)
}
_, err = decryptPayload(testSsecurity, testNonce, ciphertext)
if err == nil || !strings.Contains(err.Error(), "open gzip response") {
t.Fatalf("decryptPayload() error = %v, want damaged gzip error", err)
}
}
func TestDecryptPayloadDecompressedLimit(t *testing.T) {
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
oversized := bytes.Repeat([]byte{0xff}, maxDecompressedResponseBytes+1)
if _, err := writer.Write(oversized); err != nil {
t.Fatalf("gzip write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("gzip close: %v", err)
}
ciphertext, err := encryptRC4Bytes(testSignedNonce, compressed.Bytes())
if err != nil {
t.Fatalf("encrypt oversized gzip response: %v", err)
}
_, err = decryptPayload(testSsecurity, testNonce, ciphertext)
if err == nil || !strings.Contains(err.Error(), "decompressed response exceeds") {
t.Fatalf("decryptPayload() error = %v, want decompressed limit error", err)
}
}
func decodeBigEndian(value []byte) int64 {
var result int64
for _, current := range value {
result = result<<8 | int64(current)
}
return result
}
+1022
View File
File diff suppressed because it is too large Load Diff
+707
View File
@@ -0,0 +1,707 @@
package mijia
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
type deviceTestServer struct {
t *testing.T
fixture []byte
mu sync.Mutex
specCalls int
apiResults []string
requests []map[string]any
status int
server *httptest.Server
specPaths []string
}
func newDeviceTestServer(t *testing.T, fixture []byte, apiResults []string) *deviceTestServer {
t.Helper()
testServer := &deviceTestServer{t: t, fixture: fixture, apiResults: apiResults}
testServer.server = httptest.NewServer(http.HandlerFunc(testServer.handle))
oldSpecURL := deviceSpecURL
deviceSpecURL = testServer.server.URL + "/spec/"
t.Cleanup(func() {
deviceSpecURL = oldSpecURL
testServer.server.Close()
})
return testServer
}
func (testServer *deviceTestServer) handle(writer http.ResponseWriter, request *http.Request) {
testServer.mu.Lock()
defer testServer.mu.Unlock()
if strings.HasPrefix(request.URL.Path, "/spec/") {
testServer.specCalls++
testServer.specPaths = append(testServer.specPaths, request.URL.Path)
if request.Method != http.MethodGet {
testServer.t.Errorf("spec method = %q", request.Method)
}
if request.Header.Get("User-Agent") != "mijiaAPI/4.1.2" {
testServer.t.Errorf("spec User-Agent = %q", request.Header.Get("User-Agent"))
}
status := testServer.status
if status == 0 {
status = http.StatusOK
}
writer.WriteHeader(status)
_, _ = writer.Write(testServer.fixture)
return
}
if err := request.ParseForm(); err != nil {
testServer.t.Error(err)
return
}
auth := testAuthData()
decoded, err := decryptPayload(auth.Ssecurity, request.PostForm.Get("_nonce"), request.PostForm.Get("data"))
if err != nil {
testServer.t.Error(err)
return
}
var payload map[string]any
if err := json.Unmarshal([]byte(decoded), &payload); err != nil {
testServer.t.Error(err)
return
}
testServer.requests = append(testServer.requests, payload)
if len(testServer.apiResults) == 0 {
testServer.t.Errorf("unexpected API request %s", request.URL.Path)
http.Error(writer, "unexpected request", http.StatusInternalServerError)
return
}
result := testServer.apiResults[0]
testServer.apiResults = testServer.apiResults[1:]
_, _ = fmt.Fprintf(writer, `{"code":0,"result":%s}`, result)
}
func testAuthData() AuthData {
return AuthData{UA: "test", DeviceID: "device", PassO: "pass", Ssecurity: testSsecurity, UserID: "user", CUserID: "cuser", ServiceToken: "token", ExpireTime: time.Now().Add(time.Hour).UnixMilli()}
}
func loadSpecFixture(t *testing.T) []byte {
t.Helper()
fixture, err := os.ReadFile("testdata/miot-spec.html")
if err != nil {
t.Fatal(err)
}
return fixture
}
func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
httpClient := testServer.server.Client()
cacheDir := t.TempDir()
info, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir)
if err != nil {
t.Fatal(err)
}
if info.Name != "Test Lamp" || info.Model != "test.light.v1" || len(info.Properties) != 8 || len(info.Actions) != 2 {
t.Fatalf("info = %#v", info)
}
if got := info.Properties[0]; got.Name != "power" || got.Description != "Power / 电源" || got.Type != "bool" || got.RW != "rw" || got.SIID != 2 || got.PIID != 1 {
t.Fatalf("power = %#v", got)
}
if got := info.Properties[1]; got.Type != "uint" || len(got.Range) != 3 || got.Range[0].String() != "1" {
t.Fatalf("brightness = %#v", got)
}
if got := info.Properties[2].ValueList[1]; got.Value.String() != "1" || got.Description != "On" || got.DescZhCN != "开启" {
t.Fatalf("value list = %#v", got)
}
if info.Properties[7].Name != "outlet-power" || info.Actions[1].Name != "outlet-toggle" {
t.Fatalf("duplicates = %#v / %#v", info.Properties[7], info.Actions[1])
}
if testServer.specCalls != 1 || testServer.specPaths[0] != "/spec/test.light.v1" {
t.Fatalf("spec requests = %v, want GET /spec/test.light.v1", testServer.specPaths)
}
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
stat, err := os.Stat(cachePath)
if err != nil || stat.Mode().Perm() != 0o600 {
t.Fatalf("cache stat = %v, %v", stat, err)
}
cacheData, err := os.ReadFile(cachePath)
if err != nil {
t.Fatal(err)
}
var pythonCache struct {
Properties []struct {
Method cacheMethod `json:"method"`
} `json:"properties"`
Actions []struct {
Method cacheMethod `json:"method"`
} `json:"actions"`
}
if err := json.Unmarshal(cacheData, &pythonCache); err != nil || pythonCache.Properties[0].Method.SIID != 2 || pythonCache.Properties[0].Method.PIID != 1 || pythonCache.Actions[0].Method.SIID != 2 || pythonCache.Actions[0].Method.AIID != 1 {
t.Fatalf("Python-compatible cache = %#v, %v", pythonCache, err)
}
testServer.fixture = nil
cached, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir)
if err != nil || cached.Name != info.Name || testServer.specCalls != 1 {
t.Fatalf("cached = %#v, %v, calls=%d", cached, err, testServer.specCalls)
}
}
func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
const pythonCache = `{
"name": "Python Lamp",
"model": "test.light.v1",
"properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}],
"actions": [{"name":"toggle","description":"Toggle","method":{"siid":9,"aiid":10}}]
}`
testServer := newDeviceTestServer(t, nil, []string{
`{"homelist":[{"id":"10","uid":1}]}`,
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
`[{"did":"a","siid":7,"piid":8,"code":0}]`,
`{"did":"a","siid":9,"aiid":10,"code":0}`,
})
cacheDir := t.TempDir()
if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(pythonCache), 0o600); err != nil {
t.Fatal(err)
}
client := testClient(t, testServer.server.Client())
client.baseURL = testServer.server.URL
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0), WithDeviceCacheDir(cacheDir))
if err != nil {
t.Fatal(err)
}
if _, err := device.Get(context.Background(), "power"); err != nil {
t.Fatal(err)
}
if err := device.Set(context.Background(), "power", true); err != nil {
t.Fatal(err)
}
if _, err := device.RunAction(context.Background(), "toggle", nil); err != nil {
t.Fatal(err)
}
for index, want := range []map[string]any{
{"siid": float64(7), "piid": float64(8)},
{"siid": float64(7), "piid": float64(8)},
{"siid": float64(9), "aiid": float64(10)},
} {
params := testServer.requests[index+2]["params"]
var request map[string]any
if list, ok := params.([]any); ok {
request = list[0].(map[string]any)
} else {
request = params.(map[string]any)
}
for key, value := range want {
if request[key] != value {
t.Fatalf("request %d %s = %#v, want %#v", index, key, request[key], value)
}
}
}
if testServer.specCalls != 0 {
t.Fatalf("spec calls = %d, want 0", testServer.specCalls)
}
}
func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) {
tests := []struct {
name string
cache string
}{
{name: "empty object", cache: `{}`},
{name: "wrong model", cache: `{"model":"other.model","properties":[],"actions":[]}`},
{name: "zero ID", cache: `{"model":"test.light.v1","properties":[{"name":"power","type":"bool","rw":"rw","siid":0,"piid":1}],"actions":[]}`},
{name: "trailing JSON", cache: `{"model":"test.light.v1","properties":[],"actions":[]} {}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
cacheDir := t.TempDir()
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
if err := os.WriteFile(cachePath, []byte(test.cache), 0o600); err != nil {
t.Fatal(err)
}
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
if err != nil || info.Model != "test.light.v1" || testServer.specCalls != 1 {
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls)
}
data, err := os.ReadFile(cachePath)
if err != nil {
t.Fatal(err)
}
if _, err := decodeDeviceInfo(data, "test.light.v1"); err != nil {
t.Fatalf("refreshed cache is invalid: %v", err)
}
})
}
}
func TestInvalidDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
testServer.status = http.StatusServiceUnavailable
cacheDir := t.TempDir()
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
if err := os.WriteFile(cachePath, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
if err == nil || !strings.Contains(err.Error(), "invalid device info cache") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") {
t.Fatalf("error = %v", err)
}
data, readErr := os.ReadFile(cachePath)
if readErr != nil || string(data) != `{}` {
t.Fatalf("cache changed after failed refresh: %q, %v", data, readErr)
}
}
func TestGetDeviceInfoErrors(t *testing.T) {
tests := []struct {
name string
status int
fixture string
}{
{name: "http", status: http.StatusNotFound, fixture: "not found"},
{name: "missing script", fixture: "<html></html>"},
{name: "invalid json", fixture: `<script data-page="app" type="application/json">{</script>`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
testServer := newDeviceTestServer(t, []byte(test.fixture), nil)
testServer.status = test.status
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "bad.model", "")
var infoErr *GetDeviceInfoError
if !errors.As(err, &infoErr) {
t.Fatalf("error = %v", err)
}
if test.status != 0 && !strings.Contains(err.Error(), fmt.Sprint(test.status)) {
t.Fatalf("error = %v, want status %d", err, test.status)
}
})
}
}
func TestGetDeviceInfoRejectsUnsafeModelsBeforeFileAccess(t *testing.T) {
cacheDir := t.TempDir()
parent := filepath.Dir(cacheDir)
for _, model := range []string{"", "../escaped", "/tmp/absolute", `vendor\\model`, "vendor/model"} {
t.Run(model, func(t *testing.T) {
_, err := GetDeviceInfo(context.Background(), http.DefaultClient, model, cacheDir)
if err == nil {
t.Fatal("GetDeviceInfo() error = nil")
}
})
}
entries, err := os.ReadDir(cacheDir)
if err != nil || len(entries) != 0 {
t.Fatalf("cache entries = %v, %v", entries, err)
}
if _, err := os.Stat(filepath.Join(parent, "escaped.json")); !os.IsNotExist(err) {
t.Fatalf("escaped cache file exists: %v", err)
}
}
func TestGetDeviceInfoRejectsOversizedContent(t *testing.T) {
t.Run("cache", func(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
cacheDir := t.TempDir()
cachePath := filepath.Join(cacheDir, "test.model.json")
if err := os.WriteFile(cachePath, make([]byte, deviceSpecMaxSize+1), 0o600); err != nil {
t.Fatal(err)
}
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", cacheDir)
if err == nil || !strings.Contains(err.Error(), "model") || testServer.specCalls != 1 {
t.Fatalf("error = %v, calls=%d", err, testServer.specCalls)
}
})
t.Run("HTML", func(t *testing.T) {
testServer := newDeviceTestServer(t, make([]byte, deviceSpecMaxSize+1), nil)
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", "")
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("error = %v, want HTML size error", err)
}
})
}
func TestParseDeviceInfoHTMLScriptTagVariants(t *testing.T) {
fixture := string(loadSpecFixture(t))
const opening = `<script data-page="app" type="application/json">`
const closing = `</script>`
variants := []struct {
name string
opening string
closing string
}{
{name: "reordered", opening: `<script type="application/json" data-page="app">`, closing: closing},
{name: "extra attributes", opening: `<script defer data-extra='value' data-page='app' type='application/json'>`, closing: closing},
{name: "whitespace", opening: "<script\n type = 'application/json'\n async\n data-page = \"app\" >", closing: closing},
{name: "uppercase tags and attributes", opening: `<SCRIPT DATA-PAGE="app" TYPE="application/json">`, closing: `</SCRIPT>`},
{name: "unquoted attributes", opening: `<script TYPE=application/json DATA-PAGE=app>`, closing: closing},
}
for _, variant := range variants {
t.Run(variant.name, func(t *testing.T) {
body := strings.Replace(fixture, opening, variant.opening, 1)
openingIndex := strings.Index(body, variant.opening)
body = body[:openingIndex] + strings.Replace(body[openingIndex:], closing, variant.closing, 1)
info, err := parseDeviceInfoHTML([]byte(body))
if err != nil || info.Model != "test.light.v1" {
t.Fatalf("parseDeviceInfoHTML() = %#v, %v", info, err)
}
})
}
}
func TestNewDeviceSelectorsAndAliases(t *testing.T) {
tests := []struct {
name string
selector DeviceSelector
devices string
wantDID string
wantErrAs any
}{
{name: "did wins", selector: DeviceSelector{DID: "a", Name: "duplicate"}, devices: `[{"did":"a","name":"Lamp","model":"test.light.v1"}]`, wantDID: "a"},
{name: "unique name", selector: DeviceSelector{Name: "Lamp"}, devices: `[{"did":"a","name":"Lamp","model":"test.light.v1"}]`, wantDID: "a"},
{name: "missing selector", selector: DeviceSelector{}, devices: `[]`, wantErrAs: &DeviceNotFoundError{}},
{name: "not found", selector: DeviceSelector{DID: "x"}, devices: `[]`, wantErrAs: &DeviceNotFoundError{}},
{name: "duplicate name", selector: DeviceSelector{Name: "Lamp"}, devices: `[{"did":"a","name":"Lamp"},{"did":"b","name":"Lamp"}]`, wantErrAs: &MultipleDevicesFoundError{}},
{name: "duplicate did", selector: DeviceSelector{DID: "a"}, devices: `[{"did":"a","name":"Lamp"},{"did":"a","name":"Clone"}]`, wantErrAs: &MultipleDevicesFoundError{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":` + test.devices + `,"has_more":false}`})
client := testClient(t, testServer.server.Client())
client.baseURL = testServer.server.URL
device, err := NewDevice(context.Background(), client, test.selector, WithDeviceDelay(0), WithDeviceCacheDir(t.TempDir()))
if test.wantErrAs != nil {
if err == nil || !errors.As(err, &test.wantErrAs) {
t.Fatalf("error = %v", err)
}
return
}
if err != nil || device.DID != test.wantDID || device.Properties()["serial_number"].Name != "serial-number" {
t.Fatalf("device = %#v, %v", device, err)
}
})
}
}
func TestNewDeviceDefaults(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","model":"test.light.v1"}],"has_more":false}`})
client := testClient(t, testServer.server.Client())
client.baseURL = testServer.server.URL
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"})
if err != nil {
t.Fatal(err)
}
if device.delay != 500*time.Millisecond {
t.Fatalf("delay = %v, want 500ms", device.delay)
}
cachePath := filepath.Join(filepath.Dir(client.authPath), "test.light.v1.json")
if _, err := os.Stat(cachePath); err != nil {
t.Fatalf("default cache path %s: %v", cachePath, err)
}
}
func TestDeviceGetSetAndAction(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{
`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
`[{"did":"a","siid":2,"piid":1,"value":true,"code":0}]`,
`[{"did":"a","siid":2,"piid":1,"code":1}]`,
`{"did":"a","siid":2,"aiid":1,"code":1,"out":{"ok":true}}`,
})
client := testClient(t, testServer.server.Client())
client.baseURL = testServer.server.URL
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0), WithDeviceCacheDir(t.TempDir()))
if err != nil {
t.Fatal(err)
}
properties := device.Properties()
properties["power"] = PropertySpec{}
properties["brightness"].Range[0] = "999"
actions := device.Actions()
actions["toggle"] = ActionSpec{}
delete(properties, "serial-number")
delete(actions, "outlet-toggle")
if device.Properties()["power"].SIID != 2 || device.Properties()["brightness"].Range[0].String() != "1" || device.Actions()["toggle"].SIID != 2 {
t.Fatal("mutating snapshots changed internal device metadata")
}
value, err := device.Get(context.Background(), "power")
if err != nil || value != true {
t.Fatalf("Get() = %v, %v", value, err)
}
if err := device.Set(context.Background(), "power", "1"); err != nil {
t.Fatal(err)
}
result, err := device.RunAction(context.Background(), "toggle", nil)
if err != nil || string(result.Out) != `{"ok":true}` {
t.Fatalf("RunAction() = %#v, %v", result, err)
}
if testServer.requests[3]["params"].([]any)[0].(map[string]any)["value"] != true {
t.Fatalf("set request = %#v", testServer.requests[3])
}
if _, exists := testServer.requests[4]["params"].(map[string]any)["value"]; exists {
t.Fatalf("nil action value was sent: %#v", testServer.requests[4])
}
}
func TestDeviceMetadataSnapshotsSupportConcurrentReads(t *testing.T) {
device := fixtureDevice(t)
var waitGroup sync.WaitGroup
for range 100 {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
if device.Properties()["power"].Name != "power" || device.Actions()["toggle"].Name != "toggle" {
t.Error("metadata snapshot changed during concurrent read")
}
}()
}
waitGroup.Wait()
}
func TestDeviceValidation(t *testing.T) {
device := fixtureDevice(t)
successes := []struct {
property string
value any
want any
}{
{property: "power", value: int8(0), want: false},
{property: "brightness", value: "20", want: uint64(20)},
{property: "mode", value: json.Number("1.0"), want: uint64(1)},
{property: "temperature", value: 0.3, want: 0.3},
{property: "serial-number", value: "abc", want: "abc"},
}
for _, test := range successes {
property := device.Properties()[test.property]
got, err := convertPropertyValue(property, test.value)
if err != nil || got != test.want {
t.Errorf("convertPropertyValue(%s, %v) = %#v, %v, want %#v", test.property, test.value, got, err, test.want)
}
}
tests := []struct {
name string
prop string
value any
}{
{name: "unknown", prop: "missing", value: 1},
{name: "read only", prop: "read-only", value: 1},
{name: "bad bool integer", prop: "power", value: 2},
{name: "bad bool string", prop: "power", value: "yes"},
{name: "fractional integer", prop: "brightness", value: 1.5},
{name: "integer range", prop: "brightness", value: 101},
{name: "integer step", prop: "brightness", value: 2.5},
{name: "float step", prop: "temperature", value: 0.35},
{name: "value list", prop: "mode", value: json.Number("2")},
{name: "string", prop: "serial_number", value: 3},
{name: "non finite", prop: "temperature", value: math.Inf(1)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := device.Set(context.Background(), test.prop, test.value); err == nil {
t.Fatal("Set() error = nil")
}
})
}
if _, err := device.Get(context.Background(), "write-only"); err == nil {
t.Fatal("Get(write-only) error = nil")
}
if _, err := device.RunAction(context.Background(), "missing", nil); err == nil {
t.Fatal("RunAction(missing) error = nil")
}
}
func TestIntegerValidationIsExact(t *testing.T) {
tests := []struct {
name string
property PropertySpec
value any
want any
wantErr bool
}{
{name: "int above 2^53", property: PropertySpec{Type: "int"}, value: "9007199254740993", want: int64(9007199254740993)},
{name: "max int64", property: PropertySpec{Type: "int", Range: []json.Number{"-9223372036854775808", "9223372036854775807"}}, value: json.Number("9223372036854775807"), want: int64(math.MaxInt64)},
{name: "max uint64", property: PropertySpec{Type: "uint", Range: []json.Number{"0", "18446744073709551615"}}, value: "18446744073709551615", want: uint64(math.MaxUint64)},
{name: "int overflow string", property: PropertySpec{Type: "int"}, value: "9223372036854775808", wantErr: true},
{name: "uint overflow string", property: PropertySpec{Type: "uint"}, value: "18446744073709551616", wantErr: true},
{name: "fractional number", property: PropertySpec{Type: "int"}, value: json.Number("9007199254740993.5"), wantErr: true},
{name: "imprecise float", property: PropertySpec{Type: "uint"}, value: float64(9007199254740994), wantErr: true},
{name: "exact value list", property: PropertySpec{Type: "uint", ValueList: []ValueListItem{{Value: "9007199254740993"}}}, value: "9007199254740993", want: uint64(9007199254740993)},
{name: "adjacent value list rejected", property: PropertySpec{Type: "uint", ValueList: []ValueListItem{{Value: "9007199254740993"}}}, value: "9007199254740992", wantErr: true},
{name: "exact range and step", property: PropertySpec{Type: "uint", Range: []json.Number{"9007199254740993", "9007199254740999", "2"}}, value: "9007199254740997", want: uint64(9007199254740997)},
{name: "exact step rejected", property: PropertySpec{Type: "uint", Range: []json.Number{"9007199254740993", "9007199254740999", "2"}}, value: "9007199254740996", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := convertPropertyValue(test.property, test.value)
if test.wantErr {
if err == nil {
t.Fatalf("convertPropertyValue() = %#v, want error", got)
}
return
}
if err != nil || got != test.want {
t.Fatalf("convertPropertyValue() = %#v, %v, want %#v", got, err, test.want)
}
marshaled, err := json.Marshal(got)
if err != nil || string(marshaled) != fmt.Sprint(test.want) {
t.Fatalf("json.Marshal() = %s, %v", marshaled, err)
}
})
}
}
func TestFloatIntegerInputsMustBeExactlyRepresentable(t *testing.T) {
property := PropertySpec{Type: "float"}
tests := []struct {
name string
value any
wantErr bool
}{
{name: "positive boundary", value: int64(1 << 53)},
{name: "negative boundary", value: int64(-(1 << 53))},
{name: "int above boundary", value: int64(1<<53 + 1), wantErr: true},
{name: "uint above boundary", value: uint64(1<<53 + 1), wantErr: true},
{name: "JSON number boundary", value: json.Number("9007199254740992")},
{name: "JSON number above boundary", value: json.Number("9007199254740993"), wantErr: true},
{name: "string positive boundary", value: "9007199254740992"},
{name: "string negative boundary", value: "-9007199254740992"},
{name: "string above boundary", value: "9007199254740993", wantErr: true},
{name: "string below boundary", value: "-9007199254740993", wantErr: true},
{name: "fractional string", value: "9007199254740993.5"},
{name: "max uint64", value: uint64(math.MaxUint64), wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := convertPropertyValue(property, test.value)
if (err != nil) != test.wantErr {
t.Fatalf("convertPropertyValue(%v) error = %v, wantErr %v", test.value, err, test.wantErr)
}
})
}
}
func TestDeviceOperationErrorsAndCancellation(t *testing.T) {
tests := []struct {
name string
result string
operation func(*Device) error
want any
}{
{name: "get", result: `[{"code":-704030013}]`, operation: func(device *Device) error { _, err := device.Get(context.Background(), "power"); return err }, want: &DeviceGetError{}},
{name: "set", result: `[{"code":-704030023}]`, operation: func(device *Device) error { return device.Set(context.Background(), "power", true) }, want: &DeviceSetError{}},
{name: "action", result: `{"code":-704040005}`, operation: func(device *Device) error {
_, err := device.RunAction(context.Background(), "toggle", nil)
return err
}, want: &DeviceActionError{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
device := fixtureDeviceWithResults(t, []string{test.result}, 0)
err := test.operation(device)
if !errors.As(err, &test.want) {
t.Fatalf("error = %v", err)
}
})
}
device := fixtureDeviceWithResults(t, []string{`[{"value":true,"code":0}]`}, time.Hour)
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(10*time.Millisecond, cancel)
started := time.Now()
_, err := device.Get(ctx, "power")
if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second {
t.Fatalf("Get() cancellation = %v after %v", err, time.Since(started))
}
device = fixtureDeviceWithResults(t, []string{`{"code":0}`}, time.Hour)
ctx, cancel = context.WithCancel(context.Background())
cancel()
started = time.Now()
_, err = device.RunAction(ctx, "toggle", nil)
if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second {
t.Fatalf("RunAction() cancellation = %v after %v", err, time.Since(started))
}
}
func TestDeviceRunActionWith(t *testing.T) {
t.Run("sends extra in field like xiaoai speaker", func(t *testing.T) {
device, testServer := fixtureDeviceWithServer(t, []string{`{"did":"a","siid":2,"aiid":1,"code":0}`})
result, err := device.RunActionWith(context.Background(), "toggle", nil, map[string]any{"in": []any{"打开空调", 1}})
if err != nil || result.Code != 0 {
t.Fatalf("RunActionWith() = %#v, %v", result, err)
}
params, ok := testServer.requests[2]["params"].(map[string]any)
if !ok {
t.Fatalf("params = %#v", testServer.requests[2]["params"])
}
if len(params) != 4 || params["did"] != "a" || params["siid"] != float64(2) || params["aiid"] != float64(1) {
t.Fatalf("params = %#v", params)
}
in, ok := params["in"].([]any)
if !ok || len(in) != 2 || in[0] != "打开空调" || in[1] != float64(1) {
t.Fatalf("in = %#v", params["in"])
}
})
t.Run("conflicting extra key fails before sending", func(t *testing.T) {
device, testServer := fixtureDeviceWithServer(t, nil)
for _, key := range []string{"did", "siid", "aiid", "value"} {
if _, err := device.RunActionWith(context.Background(), "toggle", nil, map[string]any{key: "x"}); err == nil {
t.Fatalf("RunActionWith() with extra key %q error = nil", key)
}
}
if len(testServer.requests) != 2 {
t.Fatalf("requests = %d, conflicting extra keys must not reach the API", len(testServer.requests))
}
})
t.Run("nil extra matches RunAction", func(t *testing.T) {
device, testServer := fixtureDeviceWithServer(t, []string{`{"did":"a","siid":2,"aiid":1,"code":0}`, `{"did":"a","siid":2,"aiid":1,"code":0}`})
if _, err := device.RunAction(context.Background(), "toggle", []any{5}); err != nil {
t.Fatal(err)
}
if _, err := device.RunActionWith(context.Background(), "toggle", []any{5}, nil); err != nil {
t.Fatal(err)
}
legacy, _ := json.Marshal(testServer.requests[2]["params"])
with, _ := json.Marshal(testServer.requests[3]["params"])
if string(legacy) != string(with) {
t.Fatalf("RunActionWith(nil) body %s != RunAction body %s", with, legacy)
}
})
}
func fixtureDevice(t *testing.T) *Device {
return fixtureDeviceWithResults(t, nil, 0)
}
func fixtureDeviceWithResults(t *testing.T, operationResults []string, delay time.Duration) *Device {
t.Helper()
device, _ := fixtureDeviceWithOptions(t, operationResults, delay)
return device
}
func fixtureDeviceWithServer(t *testing.T, operationResults []string) (*Device, *deviceTestServer) {
t.Helper()
return fixtureDeviceWithOptions(t, operationResults, 0)
}
func fixtureDeviceWithOptions(t *testing.T, operationResults []string, delay time.Duration) (*Device, *deviceTestServer) {
t.Helper()
results := append([]string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`}, operationResults...)
testServer := newDeviceTestServer(t, loadSpecFixture(t), results)
client := testClient(t, testServer.server.Client())
client.baseURL = testServer.server.URL
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(delay), WithDeviceCacheDir(t.TempDir()))
if err != nil {
t.Fatal(err)
}
return device, testServer
}
@@ -0,0 +1,115 @@
# mijia-api Go Refactor Implementation Plan
> [!NOTE]
> This document may not reflect the current implementation.
> See the final report for up-to-date state:
> [Final Report](../reports/mijia-go-api.md)
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a pure Go library equivalent to the Python mijia-api core and high-level device API, excluding CLI, skills, MCP, and packet-decryption tools.
**Architecture:** A single `mijia` package separates deterministic cryptography, authenticated HTTP transport, public API methods, and MIoT device metadata. Public methods accept `context.Context`; flexible Xiaomi payloads use typed request structures plus `json.RawMessage` results where schemas vary.
**Tech Stack:** Go 1.22+, standard library, `github.com/mdp/qrterminal/v3` only for terminal QR rendering.
## Global Constraints
- Module path is `git.misaka.ren/m1saka/mijia-go-api` and package name is `mijia`.
- Preserve Python v4.1.2 endpoints, compact JSON encoding, ordered signing parameters, and RC4-drop-1024 behavior.
- Include login/token refresh, homes, devices, shared devices, scenes, consumables, properties, actions, statistics, and high-level device access.
- Exclude CLI, MCP, skills, documentation site, and HAR/decrypt utilities.
- Keep network dependencies injectable through `*http.Client`; tests must not require Xiaomi services.
---
### Task 1: Module, errors, and encryption
**Covers:** [S2, S3, S4, S7, S8]
**Files:**
- Create: `go.mod`
- Create: `errors.go`
- Create: `crypto.go`
- Test: `crypto_test.go`
**Interfaces:**
- Produces: `APIError`, device error types, `generateNonce`, `signedNonce`, `encryptRC4`, `decryptPayload`, `generateEncryptedParams`.
- [ ] Write table-driven tests using the fixed vector `ssecurity=MDEyMzQ1Njc4OWFiY2RlZg==`, `nonce=AAECAwQFBgcICQoL`, signed nonce `16/CeTzC9IqVVbiZ01Hy/Qd8rtVo5ybLo+ph/Vvh52k=`, and RC4 ciphertext `9ve6riTrkW1oJUE=`.
- [ ] Run `go test ./...`; expect failure because encryption functions are undefined.
- [ ] Implement SHA-256 signed nonce, RC4-drop-1024, SHA-1 ordered signatures, encrypted form parameters, plain/gzip response decryption, and the Xiaomi error-code map.
- [ ] Run `gofmt -w errors.go crypto.go crypto_test.go && go test ./...`; expect PASS.
### Task 2: Client transport and QR authentication
**Covers:** [S3, S4, S7]
**Files:**
- Create: `client.go`
- Create: `auth.go`
- Test: `client_test.go`
- Test: `auth_test.go`
**Interfaces:**
- Consumes: `generateEncryptedParams`, `decryptPayload`, `APIError`.
- Produces: `Client`, `AuthData`, `NewClient(authPath string, options ...Option)`, `WithHTTPClient`, `Login(context.Context)`, `Available(context.Context)`, and internal `request`.
- [ ] Add `httptest.Server` tests for encrypted POST form fields, decrypted JSON responses, API errors, `&&&START&&&` parsing, auth persistence, and cookie/header construction.
- [ ] Run `go test ./...`; expect failures for missing client/auth symbols.
- [ ] Implement auth-file loading, generated UA/device ID/pass_o, API session headers, compact request JSON, response decoding, and 60-second availability caching.
- [ ] Implement service-login discovery, passToken refresh, QR login data retrieval, terminal QR rendering, 120-second long polling, callback cookies, and atomic auth-file persistence.
- [ ] Run `gofmt -w client.go auth.go client_test.go auth_test.go && go test ./...`; expect PASS.
### Task 3: Public Xiaomi API methods
**Covers:** [S3, S5, S7]
**Files:**
- Create: `types.go`
- Create: `api.go`
- Test: `api_test.go`
**Interfaces:**
- Consumes: `Client.request`.
- Produces: `GetHomes`, `GetDevices`, `GetSharedDevices`, `GetScenes`, `RunScene`, `GetConsumables`, `GetProperties`, `SetProperties`, `RunActions`, `GetStatistics`, and `CheckNewMessages` with typed parameters/results.
- [ ] Add HTTP fixture tests asserting every URI and exact decoded request body, including device pagination and one-request-per-action/statistic behavior.
- [ ] Run `go test ./...`; expect failures for missing API methods.
- [ ] Define stable public structs for homes/devices/property/action/statistic calls while preserving unknown response fields in `json.RawMessage` where needed.
- [ ] Implement all endpoint methods, owner lookup, all-home aggregation, home ID annotation, pagination, and Xiaomi result-code messages.
- [ ] Run `gofmt -w types.go api.go api_test.go && go test ./...`; expect PASS.
### Task 4: High-level MIoT device API
**Covers:** [S3, S6, S7]
**Files:**
- Create: `device.go`
- Test: `device_test.go`
- Create: `testdata/miot-spec.html`
**Interfaces:**
- Consumes: `Client.GetDevices`, `Client.GetProperties`, `Client.SetProperties`, `Client.RunActions`.
- Produces: `Device`, `DeviceInfo`, `PropertySpec`, `ActionSpec`, `NewDevice`, `GetDeviceInfo`, `Device.Get`, `Device.Set`, and `Device.RunAction`.
- [ ] Add local HTML fixture tests for MIoT embedded JSON parsing, duplicate-name qualification, underscore aliases, cache read/write, device selection, value conversion, range/step checks, and API error propagation.
- [ ] Run `go test ./...`; expect failures for missing device symbols.
- [ ] Implement spec download/parsing/cache and high-level device construction by DID or unique name.
- [ ] Implement readable/writable checks, bool/number/string conversion, range/value-list validation, property calls, action calls, and configurable post-command delay.
- [ ] Run `gofmt -w device.go device_test.go && go test ./...`; expect PASS.
### Task 5: Documentation and final verification
**Covers:** [S1, S2, S7, S8]
**Files:**
- Create: `README.md`
**Interfaces:**
- Consumes: all exported package APIs.
- Produces: install, login, low-level property/action, and high-level device usage examples.
- [ ] Write concise Go examples and explicitly document unsupported CLI/MCP/skills features and auth-file security.
- [ ] Run `gofmt -w *.go && go vet ./... && go test -race ./...`; expect all commands to pass.
- [ ] Review exported API names with `go doc ./...` and remove any unused or speculative surface.
@@ -0,0 +1,47 @@
# Initialize Git Repository Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Initialize the Go library as a Git repository and publish its first commit to git.misaka.ren.
**Architecture:** Track the root Go module as the repository. Exclude local MiMoCode state, authentication data, generated MIoT cache data, and the nested Python reference repository.
**Tech Stack:** Git, Go 1.22+
## Global Constraints
- Use `https://git.misaka.ren/m1saka/mijia-go-api.git` as `origin`.
- Keep `mijia-api/` untracked.
- Use `feat: add Go API library` as the initial commit message.
---
### Task 1: Initialize And Publish Repository
**Files:**
- Create: `.gitignore`
- [ ] **Step 1: Verify the Go library**
Run: `go test ./...`
Expected: all packages pass.
- [ ] **Step 2: Initialize the repository**
Run: `git init -b main`
Expected: an empty Git repository on branch `main`.
- [ ] **Step 3: Configure the remote**
Run: `git remote add origin https://git.misaka.ren/m1saka/mijia-go-api.git`
Expected: `origin` points to the target repository.
- [ ] **Step 4: Create the initial commit**
Run: `git add . && git commit -m "feat: add Go API library"`
Expected: one root commit containing the Go library and documentation, excluding ignored local data.
- [ ] **Step 5: Publish and verify**
Run: `git push -u origin main`
Expected: `main` tracks `origin/main` and the worktree is clean.
+79
View File
@@ -0,0 +1,79 @@
---
feature: mijia-go-api
status: delivered
specs:
- docs/compose/specs/2026-07-16-mijia-go-api-design.md
plans:
- docs/compose/plans/2026-07-16-mijia-go-api.md
branch: none
commits: none
---
# mijia-api Go 重构 — Final Report
## What Was Built
项目提供纯 Go `mijia` 库,等价实现 Python mijia-api v4.1.2 的核心能力:小米账号二维码登录、passToken 静默刷新、RC4 加密 API 请求、家庭和设备查询、场景、耗材、属性读写、动作执行、统计数据,以及基于 MIoT spec 的高级设备封装。项目不包含 CLI、MCP、skills 或抓包解密工具。
客户端支持认证文件安全持久化、请求前在线 token 有效性缓存、每实例 CookieJar 隔离、并发认证快照和有界 gzip 响应读取。动态 API 字段通过 `Extra` 保留,属性大整数通过 `json.Number` 保持精度。
## Architecture
- `crypto.go` 实现 signed nonce、RC4-drop-1024、有序双重签名和响应解密。
- `auth.go``client.go` 实现认证文件、二维码流程、token 刷新、Cookie 和加密 HTTP 传输;`response.go` 统一限制原始与解压响应大小。
- `api.go``types.go` 提供家庭、设备、共享设备、场景、耗材、property、action 和 statistics API。
- `device.go` 获取、解析并缓存 MIoT spec,提供 `NewDevice``Get``Set``RunAction`
### Design Decisions
- 保持 Python v4.1.2 的实际网络契约,因为目标是等价重构;包括共享设备的 `owner=true` 筛选、action 的 `value` 字段和耗材首分组行为。
- 单次请求使用同一认证快照,因为签名、Cookie 和响应解密不能混用不同代 token。
- 设备 spec 缓存同时兼容 Go 顶层标识和 Python `method` 格式,因为两个实现默认共享认证目录和缓存文件名。
- 整数规格校验使用精确十进制/有理数比较,因为 `float64` 无法安全表达 `2^53` 以上整数。
## Usage
安装:
```bash
go get git.misaka.ren/m1saka/mijia-go-api
```
创建客户端并登录:
```go
client, err := mijia.NewClient("")
if err != nil { /* handle */ }
auth, err := client.Login(ctx)
```
底层控制使用 `GetDevices``GetProperties``SetProperties``RunActions`。高级控制使用 `NewDevice` 按 DID 或唯一名称选择设备,再调用 `device.Get``device.Set``device.RunAction`。完整示例和 option 名称见根目录 `README.md`
认证文件默认位于 `~/.config/mijia-api/auth.json`,包含敏感 token,库以 `0600` 权限原子写入,不应提交到版本控制。
## Verification
- `gofmt -w *.go`
- `go test -count=1 ./...`:通过
- `go vet ./...`:通过
- 独立规格审查和逐阶段代码质量审查均通过。
- 测试覆盖固定加密向量、gzip/大小限制、QR 与静默刷新、本地加密 HTTP 端点、分页异常、精确大整数、MIoT HTML 变体、Python 缓存迁移和 context 取消。
真实小米服务仍存在账号区域、Cookie 策略、限流和设备型号页面变化等集成风险;本地测试不使用真实账号或外网。
## Journey Log
> Brief notes on what informed the final design. Not required reading.
- [lesson] 手动设置 `Accept-Encoding: gzip` 会关闭 Go Transport 自动解压,因此登录和 API 响应必须显式、有界解压。
- [pivot] 认证状态从公开可变字段改为深拷贝快照,避免并发请求混用 token 和外部 map 竞态。
- [lesson] IANA 时区存在负 DST(例如 Dublin),必须使用 `time.Time.IsDST()`,不能从 UTC offset 大小推断。
- [pivot] MIoT 数值校验改为精确有理数,避免大整数通过 `float64` 静默失真。
- [lesson] Python 与 Go 共用 spec 缓存路径时,格式迁移和语义校验属于实际兼容需求。
## Source Materials
| File | Role | Notes |
|------|------|-------|
| `docs/compose/specs/2026-07-16-mijia-go-api-design.md` | Initial design | 功能范围与协议约束 |
| `docs/compose/plans/2026-07-16-mijia-go-api.md` | Implementation plan | 五阶段实施与验证计划 |
@@ -0,0 +1,68 @@
# mijia-api Go 重构设计(2026-07-16)
> [!NOTE]
> This document may not reflect the current implementation.
> See the final report for up-to-date state:
> [Final Report](../reports/mijia-go-api.md)
## [S1] 问题
将 Python 版 mijia-api(v4.1.2)重构为纯 Go 库,只保留 API 核心功能,排除 skills、MCP server、CLI、decrypt 调试脚本、文档站。
## [S2] 方案概览
- 项目形态:纯 Go 库,module 路径 `git.misaka.ren/m1saka/mijia-go-api`,包名 `mijia`,代码放仓库根目录。
- 功能范围:完整底层 API + 高级设备封装(mijiaDevice 等价物)。
- Go 版本:1.22+,尽量只用标准库;二维码终端输出用 `github.com/mdp/qrterminal/v3`
## [S3] 包结构
```
/ (package mijia)
crypto.go ← miutils.py: GenNonce / SignedNonce / RC4-drop1024 加解密 / 签名 / GenerateEncParams(有序 KV)
auth.go ← 登录: QRLogin、passToken 静默刷新、auth.json 读写、UA/deviceId 生成
client.go ← Client 结构、session headers/cookies、统一加密请求 request()
api.go ← GetHomesList / GetDevicesList(分页) / GetSharedDevicesList / GetScenesList / RunScene /
GetConsumableItems / GetDevicesProp / SetDevicesProp / RunAction / GetStatistics / CheckNewMsg
device.go ← Device 高级封装: miot-spec 抓取解析+缓存、Get/Set(类型与 range 校验)/RunAction
errors.go ← ERROR_CODE 表 + 错误类型(LoginError/APIError/DeviceGetError/DeviceSetError 等)
types.go ← 请求/响应结构体
```
## [S4] 认证与加密关键点
1. 二维码扫码登录:`account.xiaomi.com/pass/serviceLogin``longPolling/loginUrl` → 终端二维码 → 长轮询(120s)→ 回调取 serviceToken;响应需去 `&&&START&&&` 前缀。
2. passToken 有效时静默刷新 token;auth.json 保存 ua/deviceId/pass_o/ssecurity/serviceToken/passToken/userId/cUserId/expireTime(30 天)等。
3. 加密请求(base `https://api.mijia.tech/app`,全部 POST form):
- nonce = base64(8 字节随机 + 分钟时间戳字节);signedNonce = base64(SHA256(ssecurity||nonce))。
- RC4 必须先丢弃 1024 字节 keystream(标准库 crypto/rc4 手动 drop)。
- 双重签名:先对明文 params 签 `rc4_hash__`,RC4 加密后再签 `signature`;签名串顺序固定 `POST&uri&data=..&rc4_hash__=..&signedNonce`,Go 用有序 KV 切片而非 map。
- data JSON 必须紧凑无空格。
- 响应可能是 gzip 压缩后的 RC4 密文:先直接 JSON 解析,失败则解密,解密后 UTF-8 无效再 gzip 解压。
4. `Available()`:检查 auth 字段完备 + 调 check_new_msg 验证,结果缓存 60 秒。
## [S5] API 方法映射
与 Python 版一一对应(URI、请求体 JSON 完全一致),注意:
- GetDevicesList 分页(start_did/max_did,直到 has_more=false),home_id 为空时遍历所有家庭。
- RunAction 逐条请求,prop get/set 批量。
- 多数接口需 home 的 owner uid(从 GetHomesList 查)。
- SetDevicesProp code==1 表示网关已接收但结果未知;非 0/1 查 ERROR_CODE 附中文消息。
## [S6] 高级设备封装
`NewDevice(api, did 或 name)`:
- 从设备列表解析 model → GET `https://home.miot-spec.com/spec/{model}` 正则提取内嵌 JSON → 解析 services/properties/actions(siid/piid/aiid、类型、rw、range、value-list)→ 本地 JSON 缓存(auth.json 同目录)。
- `Get(name)` / `Set(name, value)`(bool/int/float 类型转换、range 与枚举校验)/ `RunAction(name, args...)`;属性名 `-``_` 互为别名。不做 Python 的动态属性语法糖。
## [S7] 错误处理与测试
- 错误:哨兵/自定义错误类型 + ERROR_CODE map(码→中文)。
- 测试:crypto.go 的纯函数(nonce 格式、signedNonce、RC4-drop1024、签名串)用与 Python 实现对照生成的固定向量做单测;spec HTML 解析用本地样本。网络调用不做集成测试。
## [S8] 排除项
mcp_server.py、skills/、docs/、decrypt/、`__main__.py` CLI、qrcode 图片生成(仅终端二维码)。
+133
View File
@@ -0,0 +1,133 @@
package mijia
import "fmt"
var errorCodeMessages = map[int]string{
-10000: "未知错误",
-10001: "服务不可用",
-10002: "参数无效",
-10003: "资源不足",
-10004: "内部错误",
-10005: "权限不足",
-10006: "执行超时",
-10007: "设备离线或者不存在",
-10020: "未授权OAuth2",
-10030: "无效的tokenHTTP",
-10040: "无效的消息格式",
-10050: "无效的证书",
-704000000: "未知错误",
-704010000: "未授权(设备可能被删除)",
-704014006: "没找到设备描述",
-704030013: "Property不可读",
-704030023: "Property不可写",
-704030033: "Property不可订阅",
-704040002: "Service不存在",
-704040003: "Property不存在",
-704040004: "Event不存在",
-704040005: "Action不存在",
-704040999: "功能未上线",
-704042001: "Device不存在",
-704042011: "设备离线",
-704053036: "设备操作超时",
-704053100: "设备在当前状态下无法执行此操作",
-704083036: "设备操作超时",
-704090001: "Device不存在",
-704220008: "无效的ID",
-704220025: "Action参数个数不匹配",
-704220035: "Action参数错误",
-704220043: "Property值错误",
-704222034: "Action返回值错误",
-705004000: "未知错误",
-705004501: "未知错误",
-705201013: "Property不可读",
-705201015: "Action执行错误",
-705201023: "Property不可写",
-705201033: "Property不可订阅",
-706012000: "未知错误",
-706012013: "Property不可读",
-706012015: "Action执行错误",
-706012023: "Property不可写",
-706012033: "Property不可订阅",
-706012043: "Property值错误",
-706014006: "没找到设备描述",
}
type LoginError struct {
Code int
Message string
}
func (err *LoginError) Error() string {
return fmt.Sprintf("code: %d, message: %s", err.Code, err.Message)
}
type APIError struct {
Code int
Message string
}
func (err *APIError) Error() string {
return fmt.Sprintf("code: %d, message: %s", err.Code, err.Message)
}
type DeviceNotFoundError struct {
DID string
}
func (err *DeviceNotFoundError) Error() string {
return fmt.Sprintf("未找到 did 为 '%s' 的设备,请检查 did 是否正确", err.DID)
}
type MultipleDevicesFoundError struct {
Message string
}
func (err *MultipleDevicesFoundError) Error() string {
return err.Message
}
type DeviceGetError struct {
DeviceName string
Name string
Code int
}
func (err *DeviceGetError) Error() string {
return fmt.Sprintf("获取设备 '%s' 的属性 '%s' 时失败, code: %d, message: %s", err.DeviceName, err.Name, err.Code, ErrorMessage(err.Code))
}
type DeviceSetError struct {
DeviceName string
Name string
Code int
}
func (err *DeviceSetError) Error() string {
return fmt.Sprintf("设置设备 '%s' 的属性 '%s' 时失败, code: %d, message: %s", err.DeviceName, err.Name, err.Code, ErrorMessage(err.Code))
}
type DeviceActionError struct {
DeviceName string
Name string
Code int
}
func (err *DeviceActionError) Error() string {
return fmt.Sprintf("执行设备 '%s' 的动作 '%s' 时失败, code: %d, message: %s", err.DeviceName, err.Name, err.Code, ErrorMessage(err.Code))
}
type GetDeviceInfoError struct {
DeviceModel string
}
func (err *GetDeviceInfoError) Error() string {
return fmt.Sprintf("获取设备型号 '%s' 的设备信息失败", err.DeviceModel)
}
// ErrorMessage returns the description for a Xiaomi error code.
func ErrorMessage(code int) string {
if message, ok := errorCodeMessages[code]; ok {
return message
}
return "未知错误"
}
+12
View File
@@ -0,0 +1,12 @@
package mijia
import "testing"
func TestErrorMessage(t *testing.T) {
if message := ErrorMessage(-10001); message != "服务不可用" {
t.Fatalf("ErrorMessage(-10001) = %q, want %q", message, "服务不可用")
}
if message := ErrorMessage(1); message != "未知错误" {
t.Fatalf("ErrorMessage(1) = %q, want %q", message, "未知错误")
}
}
+13
View File
@@ -0,0 +1,13 @@
module git.misaka.ren/m1saka/mijia-go-api
go 1.22
require (
github.com/mdp/qrterminal/v3 v3.2.1
rsc.io/qr v0.2.0
)
require (
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.13.0 // indirect
)
+8
View File
@@ -0,0 +1,8 @@
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+56
View File
@@ -0,0 +1,56 @@
package mijia
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"net/http"
"strings"
)
const maxHTTPResponseBytes = 16 << 20
func readHTTPResponse(response *http.Response) ([]byte, error) {
rawBody, err := readBounded(response.Body, maxHTTPResponseBytes)
if err != nil {
return nil, fmt.Errorf("read raw HTTP response: %w", err)
}
if !headerContainsToken(response.Header.Get("Content-Encoding"), "gzip") {
return rawBody, nil
}
reader, err := gzip.NewReader(bytes.NewReader(rawBody))
if err != nil {
return nil, fmt.Errorf("open gzip HTTP response: %w", err)
}
body, readErr := readBounded(reader, maxHTTPResponseBytes)
closeErr := reader.Close()
if readErr != nil {
return nil, fmt.Errorf("decompress gzip HTTP response: %w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("close gzip HTTP response: %w", closeErr)
}
return body, nil
}
func readBounded(reader io.Reader, maximum int64) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(reader, maximum+1))
if err != nil {
return nil, err
}
if int64(len(body)) > maximum {
return nil, fmt.Errorf("response exceeds %d bytes", maximum)
}
return body, nil
}
func headerContainsToken(value, token string) bool {
for _, encoding := range strings.Split(value, ",") {
if strings.EqualFold(strings.TrimSpace(encoding), token) {
return true
}
}
return false
}
+7
View File
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<head><script>window.noise = "<script>not JSON</script>";</script></head>
<body>
<script data-page="app" type="application/json">{"props":{"product":{"name":"Test Lamp","model":"test.light.v1"},"i18n":{"zh_cn":{"service:002:property:001":"电源","service:002:property:002":"亮度","service:002:property:003":"模式","service:002:property:004":"温度","service:002:property:005":"序列号","service:002:property:006":"只写","service:002:property:007":"只读","service:002:action:001":"切换","service:003:property:001":"插座电源","service:003:action:001":"插座切换","mode.off":"关闭","mode.on":"开启"}},"tree":{"services":[{"iid":2,"type":"light","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write","notify"]},{"iid":2,"type":"brightness","description":"Brightness","format":"uint8","access":["read","write"],"valueRange":[1,100,1]},{"iid":3,"type":"mode","description":"Mode","format":"uint16","access":["read","write"],"valueList":[{"value":0,"description":"Off","i18nKey":"mode.off"},{"value":1,"description":"On","i18nKey":"mode.on"}]},{"iid":4,"type":"temperature","description":"Temperature","format":"float","access":["read","write"],"valueRange":[0,1,0.1]},{"iid":5,"type":"serial-number","description":"Serial number","format":"string","access":["read","write"]},{"iid":6,"type":"write-only","description":"Write only","format":"int32","access":["write"]},{"iid":7,"type":"read-only","description":"Read only","format":"int64","access":["read"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle"}]},{"iid":3,"type":"outlet","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle"}]}]}}}</script>
</body>
</html>
+293
View File
@@ -0,0 +1,293 @@
package mijia
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
)
type Home struct {
ID string `json:"id"`
Name string `json:"name"`
UID int64 `json:"uid"`
RoomList json.RawMessage `json:"roomlist,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
func (home *Home) UnmarshalJSON(payload []byte) error {
type homeFields struct {
ID json.RawMessage `json:"id"`
Name string `json:"name"`
UID int64 `json:"uid"`
RoomList json.RawMessage `json:"roomlist"`
}
var fields homeFields
if err := json.Unmarshal(payload, &fields); err != nil {
return err
}
id, err := decodeStringOrNumber(fields.ID)
if err != nil {
return fmt.Errorf("decode home id: %w", err)
}
home.ID = id
home.Name = fields.Name
home.UID = fields.UID
home.RoomList = fields.RoomList
home.Extra, err = decodeExtraFields(payload, "id", "name", "uid", "roomlist")
if err != nil {
return err
}
return nil
}
func (home Home) MarshalJSON() ([]byte, error) {
type homeFields Home
return encodeJSONWithExtra(homeFields(home), home.Extra, "id", "name", "uid", "roomlist")
}
type Device struct {
DID string `json:"did"`
Name string `json:"name"`
Model string `json:"model"`
UID int64 `json:"uid"`
Owner bool `json:"owner"`
HomeID string `json:"home_id,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
properties map[string]PropertySpec
actions map[string]ActionSpec
client *Client
delay time.Duration
}
func (device *Device) UnmarshalJSON(payload []byte) error {
type deviceFields Device
var fields deviceFields
if err := json.Unmarshal(payload, &fields); err != nil {
return err
}
*device = Device(fields)
extra, err := decodeExtraFields(payload, "did", "name", "model", "uid", "owner", "home_id")
if err != nil {
return err
}
device.Extra = extra
return nil
}
func (device Device) MarshalJSON() ([]byte, error) {
type deviceFields Device
return encodeJSONWithExtra(deviceFields(device), device.Extra, "did", "name", "model", "uid", "owner", "home_id")
}
type Scene struct {
SceneID string `json:"scene_id"`
Name string `json:"name"`
HomeID string `json:"home_id,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
func (scene *Scene) UnmarshalJSON(payload []byte) error {
type sceneFields Scene
var fields sceneFields
if err := json.Unmarshal(payload, &fields); err != nil {
return err
}
*scene = Scene(fields)
extra, err := decodeExtraFields(payload, "scene_id", "name", "home_id")
if err != nil {
return err
}
scene.Extra = extra
return nil
}
func (scene Scene) MarshalJSON() ([]byte, error) {
type sceneFields Scene
return encodeJSONWithExtra(sceneFields(scene), scene.Extra, "scene_id", "name", "home_id")
}
type Consumable struct {
DID string `json:"did"`
Name string `json:"name"`
Details json.RawMessage `json:"details"`
HomeID string `json:"home_id,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
func (consumable *Consumable) UnmarshalJSON(payload []byte) error {
type consumableFields Consumable
var fields consumableFields
if err := json.Unmarshal(payload, &fields); err != nil {
return err
}
*consumable = Consumable(fields)
extra, err := decodeExtraFields(payload, "did", "name", "details", "home_id")
if err != nil {
return err
}
consumable.Extra = extra
return nil
}
func (consumable Consumable) MarshalJSON() ([]byte, error) {
type consumableFields Consumable
return encodeJSONWithExtra(consumableFields(consumable), consumable.Extra, "did", "name", "details", "home_id")
}
type PropertyRequest struct {
DID string `json:"did"`
SIID int `json:"siid"`
PIID int `json:"piid"`
}
type PropertySetRequest struct {
DID string `json:"did"`
SIID int `json:"siid"`
PIID int `json:"piid"`
Value any `json:"value"`
}
type PropertyResult struct {
DID string `json:"did"`
SIID int `json:"siid"`
PIID int `json:"piid"`
Value any `json:"value,omitempty"`
Code int `json:"code"`
UpdateTime int64 `json:"updateTime,omitempty"`
Message string `json:"message,omitempty"`
}
type ActionRequest struct {
DID string `json:"did"`
SIID int `json:"siid"`
AIID int `json:"aiid"`
Value any `json:"value,omitempty"`
Extra map[string]any `json:"-"`
}
// MarshalJSON 先序列化固定字段,再合并 Extra;Extra 与保留键
// (did, siid, aiid, value) 冲突时返回错误。
func (request ActionRequest) MarshalJSON() ([]byte, error) {
type actionRequestFields ActionRequest
payload, err := json.Marshal(actionRequestFields(request))
if err != nil {
return nil, err
}
if len(request.Extra) == 0 {
return payload, nil
}
var combined map[string]json.RawMessage
if err := json.Unmarshal(payload, &combined); err != nil {
return nil, err
}
for key, value := range request.Extra {
if actionRequestReservedKey(key) {
return nil, fmt.Errorf("无效的参数: %s. 请勿使用保留键 (did, siid, aiid, value)", key)
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("编码扩展参数 %s: %w", key, err)
}
combined[key] = encoded
}
return json.Marshal(combined)
}
func actionRequestReservedKey(key string) bool {
switch key {
case "did", "siid", "aiid", "value":
return true
default:
return false
}
}
type ActionResult struct {
DID string `json:"did"`
SIID int `json:"siid"`
AIID int `json:"aiid"`
Code int `json:"code"`
Out json.RawMessage `json:"out,omitempty"`
Message string `json:"message,omitempty"`
}
type StatisticsRequest struct {
DID string `json:"did"`
Key string `json:"key"`
DataType string `json:"data_type"`
Limit int `json:"limit"`
TimeStart int64 `json:"time_start"`
TimeEnd int64 `json:"time_end"`
}
func decodeStringOrNumber(raw json.RawMessage) (string, error) {
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return "", nil
}
var value string
if err := json.Unmarshal(raw, &value); err == nil {
return value, nil
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var number json.Number
if err := decoder.Decode(&number); err != nil {
return "", err
}
return number.String(), nil
}
func decodeJSON(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.UseNumber()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("unexpected trailing JSON value")
}
return err
}
return nil
}
func decodeExtraFields(payload []byte, knownFields ...string) (map[string]json.RawMessage, error) {
var extra map[string]json.RawMessage
if err := json.Unmarshal(payload, &extra); err != nil {
return nil, err
}
for _, field := range knownFields {
delete(extra, field)
}
if len(extra) == 0 {
return nil, nil
}
return extra, nil
}
func encodeJSONWithExtra(fields any, extra map[string]json.RawMessage, knownFields ...string) ([]byte, error) {
payload, err := json.Marshal(fields)
if err != nil {
return nil, err
}
if len(extra) == 0 {
return payload, nil
}
var combined map[string]json.RawMessage
if err := json.Unmarshal(payload, &combined); err != nil {
return nil, err
}
reserved := make(map[string]struct{}, len(knownFields))
for _, field := range knownFields {
reserved[field] = struct{}{}
}
for field, value := range extra {
if _, known := reserved[field]; !known {
combined[field] = value
}
}
return json.Marshal(combined)
}