From 475565585906fc22ec6a535214480444e1c3487c Mon Sep 17 00:00:00 2001 From: m1saka Date: Sat, 18 Jul 2026 22:40:40 +0800 Subject: [PATCH] feat: add Go API library --- .gitignore | 4 + README.md | 137 +++ api.go | 338 ++++++ api_test.go | 407 +++++++ auth.go | 520 +++++++++ auth_test.go | 270 +++++ client.go | 303 +++++ client_test.go | 456 ++++++++ crypto.go | 180 +++ crypto_test.go | 171 +++ device.go | 1022 +++++++++++++++++ device_test.go | 707 ++++++++++++ docs/compose/plans/2026-07-16-mijia-go-api.md | 115 ++ .../2026-07-18-initialize-git-repository.md | 47 + docs/compose/reports/mijia-go-api.md | 79 ++ .../specs/2026-07-16-mijia-go-api-design.md | 68 ++ errors.go | 133 +++ errors_test.go | 12 + go.mod | 13 + go.sum | 8 + response.go | 56 + testdata/miot-spec.html | 7 + types.go | 293 +++++ 23 files changed, 5346 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api.go create mode 100644 api_test.go create mode 100644 auth.go create mode 100644 auth_test.go create mode 100644 client.go create mode 100644 client_test.go create mode 100644 crypto.go create mode 100644 crypto_test.go create mode 100644 device.go create mode 100644 device_test.go create mode 100644 docs/compose/plans/2026-07-16-mijia-go-api.md create mode 100644 docs/compose/plans/2026-07-18-initialize-git-repository.md create mode 100644 docs/compose/reports/mijia-go-api.md create mode 100644 docs/compose/specs/2026-07-16-mijia-go-api-design.md create mode 100644 errors.go create mode 100644 errors_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 response.go create mode 100644 testdata/miot-spec.html create mode 100644 types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22ca6aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.mimocode/ +mijia-api/ +auth.json +miot-cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..10da20f --- /dev/null +++ b/README.md @@ -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`。 diff --git a/api.go b/api.go new file mode 100644 index 0000000..b48a8bc --- /dev/null +++ b/api.go @@ -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": "", "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) +} diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..991ca92 --- /dev/null +++ b/api_test.go @@ -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":"","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) +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..a3b3b2b --- /dev/null +++ b/auth.go @@ -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 +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..9756a1d --- /dev/null +++ b/auth_test.go @@ -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) + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..0c3857b --- /dev/null +++ b/client.go @@ -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 +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..1a11dc8 --- /dev/null +++ b/client_test.go @@ -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) + } + } +} diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..e5396ec --- /dev/null +++ b/crypto.go @@ -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 +} diff --git a/crypto_test.go b/crypto_test.go new file mode 100644 index 0000000..287ee49 --- /dev/null +++ b/crypto_test.go @@ -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 +} diff --git a/device.go b/device.go new file mode 100644 index 0000000..899a898 --- /dev/null +++ b/device.go @@ -0,0 +1,1022 @@ +package mijia + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/big" + "net/http" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "time" +) + +const ( + deviceSpecUA = "mijiaAPI/4.1.2" + deviceSpecMaxSize = 8 << 20 +) + +var deviceSpecURL = "https://home.miot-spec.com/spec/" + +type DeviceInfo struct { + Name string `json:"name"` + Model string `json:"model"` + Properties []PropertySpec `json:"properties"` + Actions []ActionSpec `json:"actions"` +} + +type PropertySpec struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + RW string `json:"rw"` + Range []json.Number `json:"range,omitempty"` + ValueList []ValueListItem `json:"value-list,omitempty"` + SIID int `json:"siid"` + PIID int `json:"piid"` +} + +type ValueListItem struct { + Value json.Number `json:"value"` + Description string `json:"description"` + DescZhCN string `json:"desc_zh_cn,omitempty"` +} + +type ActionSpec struct { + Name string `json:"name"` + Description string `json:"description"` + SIID int `json:"siid"` + AIID int `json:"aiid"` +} + +type DeviceSelector struct { + DID string + Name string +} + +type DeviceOption func(*deviceConfig) error + +type deviceConfig struct { + httpClient *http.Client + cacheDir string + delay time.Duration +} + +func WithDeviceHTTPClient(httpClient *http.Client) DeviceOption { + return func(config *deviceConfig) error { + if httpClient == nil { + return errors.New("device HTTP client must not be nil") + } + config.httpClient = httpClient + return nil + } +} + +func WithDeviceCacheDir(cacheDir string) DeviceOption { + return func(config *deviceConfig) error { + config.cacheDir = cacheDir + return nil + } +} + +func WithDeviceDelay(delay time.Duration) DeviceOption { + return func(config *deviceConfig) error { + if delay < 0 { + return errors.New("device delay must not be negative") + } + config.delay = delay + return nil + } +} + +func GetDeviceInfo(ctx context.Context, httpClient *http.Client, model string, cacheDir string) (DeviceInfo, error) { + if !validDeviceModel(model) || httpClient == nil { + return DeviceInfo{}, &GetDeviceInfoError{DeviceModel: model} + } + cachePath := "" + var cacheErr error + if cacheDir != "" { + cachePath = filepath.Join(cacheDir, model+".json") + if cache, err := os.Open(cachePath); err == nil { + data, readErr := readLimited(cache, deviceSpecMaxSize, "device info cache") + closeErr := cache.Close() + if readErr != nil { + cacheErr = fmt.Errorf("read: %w", readErr) + } else if closeErr != nil { + cacheErr = fmt.Errorf("close: %w", closeErr) + } else { + info, decodeErr := decodeDeviceInfo(data, model) + if decodeErr == nil { + return info, nil + } + cacheErr = decodeErr + } + } else if !os.IsNotExist(err) { + cacheErr = fmt.Errorf("open: %w", err) + } + } + + info, err := fetchDeviceInfo(ctx, httpClient, model) + if err != nil { + if cacheErr != nil { + return DeviceInfo{}, fmt.Errorf("invalid device info cache %s: %v; refresh failed: %w", cachePath, cacheErr, err) + } + return DeviceInfo{}, err + } + if cachePath != "" { + if err := writeDeviceInfoCache(cachePath, info); err != nil { + return DeviceInfo{}, err + } + } + return info, nil +} + +func fetchDeviceInfo(ctx context.Context, httpClient *http.Client, model string) (DeviceInfo, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, deviceSpecURL+model, nil) + if err != nil { + return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) + } + request.Header.Set("User-Agent", deviceSpecUA) + response, err := httpClient.Do(request) + if err != nil { + return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return DeviceInfo{}, fmt.Errorf("%w: HTTP status %d", &GetDeviceInfoError{DeviceModel: model}, response.StatusCode) + } + body, err := readLimited(response.Body, deviceSpecMaxSize, "device spec HTML") + if err != nil { + return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) + } + info, err := parseDeviceInfoHTML(body) + if err != nil { + return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) + } + if err := validateDeviceInfo(info, model); err != nil { + return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) + } + return info, nil +} + +func validDeviceModel(model string) bool { + if model == "" { + return false + } + for _, character := range []byte(model) { + if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && + (character < '0' || character > '9') && character != '.' && character != '-' && character != '_' { + return false + } + } + return true +} + +func readLimited(reader io.Reader, maximum int64, description string) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, maximum+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maximum { + return nil, fmt.Errorf("%s exceeds %d bytes", description, maximum) + } + return data, nil +} + +func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) { + start, err := deviceSpecJSONStart(body) + if err != nil { + return DeviceInfo{}, err + } + endOffset := indexFoldASCII(body[start:], []byte("")) + if endOffset < 0 { + return DeviceInfo{}, errors.New("device spec JSON script is not closed") + } + + var page struct { + Props struct { + Product struct { + Name string `json:"name"` + Model string `json:"model"` + } `json:"product"` + I18n struct { + ZhCN map[string]string `json:"zh_cn"` + } `json:"i18n"` + Tree struct { + Services []struct { + IID int `json:"iid"` + Type string `json:"type"` + Properties []struct { + IID int `json:"iid"` + Type string `json:"type"` + Description string `json:"description"` + Format string `json:"format"` + Access []string `json:"access"` + ValueRange []json.Number `json:"valueRange"` + ValueList []struct { + Value json.Number `json:"value"` + Description string `json:"description"` + I18nKey string `json:"i18nKey"` + } `json:"valueList"` + } `json:"properties"` + Actions []struct { + IID int `json:"iid"` + Type string `json:"type"` + Description string `json:"description"` + } `json:"actions"` + } `json:"services"` + } `json:"tree"` + } `json:"props"` + } + decoder := json.NewDecoder(bytes.NewReader(body[start : start+endOffset])) + decoder.UseNumber() + if err := decoder.Decode(&page); err != nil { + return DeviceInfo{}, err + } + + info := DeviceInfo{Name: page.Props.Product.Name, Model: page.Props.Product.Model} + propertyNames := make(map[string]struct{}) + actionNames := make(map[string]struct{}) + for _, service := range page.Props.Tree.Services { + for _, property := range service.Properties { + propertyType := property.Format + if strings.HasPrefix(propertyType, "int") { + propertyType = "int" + } else if strings.HasPrefix(propertyType, "uint") { + propertyType = "uint" + } + if !validPropertyType(propertyType) { + return DeviceInfo{}, fmt.Errorf("unsupported property type %q", propertyType) + } + name := property.Type + if _, duplicate := propertyNames[name]; duplicate { + name = service.Type + "-" + name + } + propertyNames[name] = struct{}{} + description := localizedDescription(property.Description, page.Props.I18n.ZhCN[fmt.Sprintf("service:%03d:property:%03d", service.IID, property.IID)]) + valueList := make([]ValueListItem, len(property.ValueList)) + for index, item := range property.ValueList { + valueList[index] = ValueListItem{Value: item.Value, Description: item.Description, DescZhCN: page.Props.I18n.ZhCN[item.I18nKey]} + } + info.Properties = append(info.Properties, PropertySpec{Name: name, Description: description, Type: propertyType, RW: accessString(property.Access), Range: property.ValueRange, ValueList: valueList, SIID: service.IID, PIID: property.IID}) + } + for _, action := range service.Actions { + name := action.Type + if _, duplicate := actionNames[name]; duplicate { + name = service.Type + "-" + name + } + actionNames[name] = struct{}{} + description := localizedDescription(action.Description, page.Props.I18n.ZhCN[fmt.Sprintf("service:%03d:action:%03d", service.IID, action.IID)]) + info.Actions = append(info.Actions, ActionSpec{Name: name, Description: description, SIID: service.IID, AIID: action.IID}) + } + } + return info, nil +} + +func deviceSpecJSONStart(body []byte) (int, error) { + for offset := 0; offset < len(body); { + relative := indexFoldASCII(body[offset:], []byte("= len(body) { + return 0, nil, false + } + if body[offset] == '>' { + return offset, attributes, true + } + nameStart := offset + for offset < len(body) && !isHTMLSpace(body[offset]) && body[offset] != '=' && body[offset] != '>' { + offset++ + } + if nameStart == offset { + return 0, nil, false + } + name := strings.ToLower(string(body[nameStart:offset])) + for offset < len(body) && isHTMLSpace(body[offset]) { + offset++ + } + value := "" + if offset < len(body) && body[offset] == '=' { + offset++ + for offset < len(body) && isHTMLSpace(body[offset]) { + offset++ + } + if offset >= len(body) { + return 0, nil, false + } + if body[offset] == '\'' || body[offset] == '"' { + quote := body[offset] + offset++ + valueStart := offset + for offset < len(body) && body[offset] != quote { + offset++ + } + if offset >= len(body) { + return 0, nil, false + } + value = string(body[valueStart:offset]) + offset++ + } else { + valueStart := offset + for offset < len(body) && !isHTMLSpace(body[offset]) && body[offset] != '>' { + switch body[offset] { + case '"', '\'', '`', '=', '<': + return 0, nil, false + } + offset++ + } + if valueStart == offset { + return 0, nil, false + } + value = string(body[valueStart:offset]) + } + } + attributes[name] = value + } + return 0, nil, false +} + +func isHTMLSpace(character byte) bool { + return character == ' ' || character == '\t' || character == '\n' || character == '\r' || character == '\f' +} + +func indexFoldASCII(data, target []byte) int { + for index := 0; index+len(target) <= len(data); index++ { + matched := true + for targetIndex, targetCharacter := range target { + dataCharacter := data[index+targetIndex] + if dataCharacter >= 'A' && dataCharacter <= 'Z' { + dataCharacter += 'a' - 'A' + } + if dataCharacter != targetCharacter { + matched = false + break + } + } + if matched { + return index + } + } + return -1 +} + +func validPropertyType(propertyType string) bool { + switch propertyType { + case "bool", "int", "uint", "float", "string": + return true + default: + return false + } +} + +func localizedDescription(description, translation string) string { + if translation == "" { + return description + } + if description == "" { + return translation + } + return description + " / " + translation +} + +func accessString(access []string) string { + readable, writable := false, false + for _, item := range access { + readable = readable || item == "read" + writable = writable || item == "write" + } + result := "" + if readable { + result += "r" + } + if writable { + result += "w" + } + return result +} + +func writeDeviceInfoCache(path string, info DeviceInfo) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create device info cache directory: %w", err) + } + cache := deviceInfoCache{Name: info.Name, Model: info.Model} + for _, property := range info.Properties { + cache.Properties = append(cache.Properties, propertyCache{ + PropertySpec: property, + Method: cacheMethod{SIID: property.SIID, PIID: property.PIID}, + }) + } + for _, action := range info.Actions { + cache.Actions = append(cache.Actions, actionCache{ + ActionSpec: action, + Method: cacheMethod{SIID: action.SIID, AIID: action.AIID}, + }) + } + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("encode device info cache: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".miot-spec-*") + if err != nil { + return fmt.Errorf("create temporary device info cache: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("replace device info cache: %w", err) + } + return nil +} + +type cacheMethod struct { + SIID int `json:"siid"` + PIID int `json:"piid,omitempty"` + AIID int `json:"aiid,omitempty"` +} + +type propertyCache struct { + PropertySpec + Method cacheMethod `json:"method"` +} + +type actionCache struct { + ActionSpec + Method cacheMethod `json:"method"` +} + +type deviceInfoCache struct { + Name string `json:"name"` + Model string `json:"model"` + Properties []propertyCache `json:"properties"` + Actions []actionCache `json:"actions"` +} + +func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var cache deviceInfoCache + if err := decoder.Decode(&cache); err != nil { + return DeviceInfo{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return DeviceInfo{}, errors.New("multiple JSON values") + } + return DeviceInfo{}, fmt.Errorf("trailing content: %w", err) + } + + info := DeviceInfo{Name: cache.Name, Model: cache.Model} + for _, cachedProperty := range cache.Properties { + property := cachedProperty.PropertySpec + if property.SIID == 0 { + property.SIID = cachedProperty.Method.SIID + } + if property.PIID == 0 { + property.PIID = cachedProperty.Method.PIID + } + info.Properties = append(info.Properties, property) + } + for _, cachedAction := range cache.Actions { + action := cachedAction.ActionSpec + if action.SIID == 0 { + action.SIID = cachedAction.Method.SIID + } + if action.AIID == 0 { + action.AIID = cachedAction.Method.AIID + } + info.Actions = append(info.Actions, action) + } + if err := validateDeviceInfo(info, model); err != nil { + return DeviceInfo{}, err + } + return info, nil +} + +func validateDeviceInfo(info DeviceInfo, model string) error { + if info.Model == "" || info.Model != model { + return fmt.Errorf("model %q does not match requested model %q", info.Model, model) + } + for index, property := range info.Properties { + if strings.TrimSpace(property.Name) == "" || !validPropertyType(property.Type) || + (property.RW != "r" && property.RW != "w" && property.RW != "rw") || property.SIID <= 0 || property.PIID <= 0 { + return fmt.Errorf("property %d is invalid", index) + } + } + for index, action := range info.Actions { + if strings.TrimSpace(action.Name) == "" || action.SIID <= 0 || action.AIID <= 0 { + return fmt.Errorf("action %d is invalid", index) + } + } + return nil +} + +func NewDevice(ctx context.Context, client *Client, selector DeviceSelector, options ...DeviceOption) (*Device, error) { + if client == nil { + return nil, errors.New("client must not be nil") + } + config := deviceConfig{httpClient: client.session(), cacheDir: filepath.Dir(client.authPath), delay: 500 * time.Millisecond} + for _, option := range options { + if option != nil { + if err := option(&config); err != nil { + return nil, err + } + } + } + devices, err := client.GetDevices(ctx, "") + if err != nil { + return nil, err + } + matches := make([]Device, 0, 1) + for _, device := range devices { + if selector.DID != "" { + if device.DID == selector.DID { + matches = append(matches, device) + } + } else if selector.Name != "" && device.Name == selector.Name { + matches = append(matches, device) + } + } + selected := selector.DID + if selected == "" { + selected = selector.Name + } + if len(matches) == 0 { + return nil, &DeviceNotFoundError{DID: selected} + } + if len(matches) > 1 { + return nil, &MultipleDevicesFoundError{Message: fmt.Sprintf("找到多个标识为 '%s' 的设备", selected)} + } + info, err := GetDeviceInfo(ctx, config.httpClient, matches[0].Model, config.cacheDir) + if err != nil { + return nil, err + } + device := matches[0] + if device.Name == "" { + device.Name = info.Name + } + device.client = client + device.delay = config.delay + device.properties = make(map[string]PropertySpec, len(info.Properties)) + for _, property := range info.Properties { + device.properties[property.Name] = property + if strings.Contains(property.Name, "-") { + device.properties[strings.ReplaceAll(property.Name, "-", "_")] = property + } + } + device.actions = make(map[string]ActionSpec, len(info.Actions)) + for _, action := range info.Actions { + device.actions[action.Name] = action + } + return &device, nil +} + +func (device *Device) Properties() map[string]PropertySpec { + properties := make(map[string]PropertySpec, len(device.properties)) + for name, property := range device.properties { + property.Range = append([]json.Number(nil), property.Range...) + property.ValueList = append([]ValueListItem(nil), property.ValueList...) + properties[name] = property + } + return properties +} + +func (device *Device) Actions() map[string]ActionSpec { + actions := make(map[string]ActionSpec, len(device.actions)) + for name, action := range device.actions { + actions[name] = action + } + return actions +} + +func (device *Device) Get(ctx context.Context, name string) (any, error) { + property, ok := device.properties[name] + if !ok { + return nil, fmt.Errorf("不支持的属性: %s", name) + } + if !strings.Contains(property.RW, "r") { + return nil, fmt.Errorf("属性 %s 不可读取", name) + } + results, err := device.client.GetProperties(ctx, []PropertyRequest{{DID: device.DID, SIID: property.SIID, PIID: property.PIID}}) + if err != nil { + return nil, err + } + if len(results) != 1 { + return nil, fmt.Errorf("获取设备 '%s' 的属性 '%s' 返回了 %d 条结果", device.Name, name, len(results)) + } + if results[0].Code != 0 { + return nil, &DeviceGetError{DeviceName: device.Name, Name: name, Code: results[0].Code} + } + if err := device.wait(ctx); err != nil { + return nil, err + } + return results[0].Value, nil +} + +func (device *Device) Set(ctx context.Context, name string, value any) error { + property, ok := device.properties[name] + if !ok { + return fmt.Errorf("不支持的属性: %s", name) + } + if !strings.Contains(property.RW, "w") { + return fmt.Errorf("属性 %s 不可写入", name) + } + converted, err := convertPropertyValue(property, value) + if err != nil { + return fmt.Errorf("属性 %s: %w", name, err) + } + results, err := device.client.SetProperties(ctx, []PropertySetRequest{{DID: device.DID, SIID: property.SIID, PIID: property.PIID, Value: converted}}) + if err != nil { + return err + } + if len(results) != 1 { + return fmt.Errorf("设置设备 '%s' 的属性 '%s' 返回了 %d 条结果", device.Name, name, len(results)) + } + if results[0].Code != 0 && results[0].Code != 1 { + return &DeviceSetError{DeviceName: device.Name, Name: name, Code: results[0].Code} + } + return device.wait(ctx) +} + +func (device *Device) RunAction(ctx context.Context, name string, value any) (ActionResult, error) { + return device.RunActionWith(ctx, name, value, nil) +} + +// RunActionWith 在 RunAction 基础上支持注入扩展请求字段,对齐 Python +// mijiaDevice.run_action 的 **kwargs(典型用法:小爱音箱 execute-text-directive +// 需要发送 "in" 字段)。extra 直接使用真实键名(如 "in"),无需 Python 那种 +// 前导下划线 workaround(那是绕过 Python 关键字限制的手段)。 +// extra 键与保留键 (did, siid, aiid, value) 冲突时返回错误。 +func (device *Device) RunActionWith(ctx context.Context, name string, value any, extra map[string]any) (ActionResult, error) { + action, ok := device.actions[name] + if !ok { + return ActionResult{}, fmt.Errorf("不支持的动作: %s", name) + } + for key := range extra { + if actionRequestReservedKey(key) { + return ActionResult{}, fmt.Errorf("无效的参数: %s. 请勿使用保留键 (did, siid, aiid, value)", key) + } + } + request := ActionRequest{DID: device.DID, SIID: action.SIID, AIID: action.AIID, Extra: extra} + if value != nil { + request.Value = value + } + results, err := device.client.RunActions(ctx, []ActionRequest{request}) + if err != nil { + return ActionResult{}, err + } + if len(results) != 1 { + return ActionResult{}, fmt.Errorf("执行设备 '%s' 的动作 '%s' 返回了 %d 条结果", device.Name, name, len(results)) + } + if results[0].Code != 0 && results[0].Code != 1 { + return results[0], &DeviceActionError{DeviceName: device.Name, Name: name, Code: results[0].Code} + } + if err := device.wait(ctx); err != nil { + return ActionResult{}, err + } + return results[0], nil +} + +func (device *Device) wait(ctx context.Context) error { + if device.delay == 0 { + return ctx.Err() + } + timer := time.NewTimer(device.delay) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func convertPropertyValue(property PropertySpec, value any) (any, error) { + var converted any + var numeric *big.Rat + var numericValue bool + switch property.Type { + case "bool": + boolean, err := convertBool(value) + if err != nil { + return nil, err + } + converted = boolean + case "int": + integer, err := convertInt64(value) + if err != nil { + return nil, err + } + converted, numeric, numericValue = integer, new(big.Rat).SetInt64(integer), true + case "uint": + integer, err := convertUint64(value) + if err != nil { + return nil, err + } + converted, numeric, numericValue = integer, new(big.Rat).SetUint64(integer), true + case "float": + floating, err := convertFloat64(value) + if err != nil { + return nil, err + } + numeric, err = textRat(strconv.FormatFloat(floating, 'g', -1, 64)) + if err != nil { + return nil, err + } + converted, numericValue = floating, true + case "string": + text, ok := value.(string) + if !ok { + return nil, fmt.Errorf("无效字符串值: %v", value) + } + converted = text + default: + return nil, fmt.Errorf("不支持的类型: %s", property.Type) + } + if numericValue { + if err := validateRange(property.Type, numeric, property.Range); err != nil { + return nil, err + } + } + if len(property.ValueList) > 0 { + matched := false + for _, item := range property.ValueList { + if numericValue { + candidate, err := propertyNumberRat(property.Type, item.Value) + matched = err == nil && numeric.Cmp(candidate) == 0 + } else { + matched = fmt.Sprint(converted) == item.Value.String() + } + if matched { + break + } + } + if !matched { + return nil, fmt.Errorf("无效值: %v", converted) + } + } + return converted, nil +} + +func convertBool(value any) (bool, error) { + if boolean, ok := value.(bool); ok { + return boolean, nil + } + if text, ok := value.(string); ok { + switch strings.ToLower(text) { + case "true", "1": + return true, nil + case "false", "0": + return false, nil + } + } + integer, err := convertInt64(value) + if err == nil && (integer == 0 || integer == 1) { + return integer == 1, nil + } + return false, fmt.Errorf("无效布尔值: %v", value) +} + +func convertInt64(value any) (int64, error) { + if text, ok := integerText(value); ok { + integer, err := signedInteger(text) + if err != nil { + return 0, fmt.Errorf("无效整数值: %v", value) + } + return integer, nil + } + reflected := reflect.ValueOf(value) + if !reflected.IsValid() { + return 0, fmt.Errorf("无效整数值: %v", value) + } + switch reflected.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return reflected.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if reflected.Uint() > math.MaxInt64 { + return 0, fmt.Errorf("整数溢出: %v", value) + } + return int64(reflected.Uint()), nil + case reflect.Float32, reflect.Float64: + floating := reflected.Float() + if !isFinite(floating) || math.Trunc(floating) != floating || math.Abs(floating) > 1<<53 { + return 0, fmt.Errorf("无效整数值: %v", value) + } + return int64(floating), nil + default: + return 0, fmt.Errorf("无效整数值: %v", value) + } +} + +func convertUint64(value any) (uint64, error) { + if text, ok := integerText(value); ok { + integer, err := unsignedInteger(text) + if err != nil { + return 0, fmt.Errorf("无效无符号整数值: %v", value) + } + return integer, nil + } + reflected := reflect.ValueOf(value) + if !reflected.IsValid() { + return 0, fmt.Errorf("无效无符号整数值: %v", value) + } + switch reflected.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return reflected.Uint(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if reflected.Int() < 0 { + return 0, fmt.Errorf("无效无符号整数值: %v", value) + } + return uint64(reflected.Int()), nil + case reflect.Float32, reflect.Float64: + floating := reflected.Float() + if !isFinite(floating) || math.Trunc(floating) != floating || floating < 0 || floating > 1<<53 { + return 0, fmt.Errorf("无效无符号整数值: %v", value) + } + return uint64(floating), nil + default: + return 0, fmt.Errorf("无效无符号整数值: %v", value) + } +} + +func convertFloat64(value any) (float64, error) { + var text string + switch number := value.(type) { + case string: + text = number + case json.Number: + text = number.String() + } + if text != "" { + rational, err := textRat(text) + if err != nil { + return 0, fmt.Errorf("无效浮点值: %v", value) + } + if rational.IsInt() && new(big.Int).Abs(rational.Num()).Cmp(big.NewInt(1<<53)) > 0 { + return 0, fmt.Errorf("浮点整数超出精确范围: %v", value) + } + floating, err := strconv.ParseFloat(text, 64) + if err != nil || !isFinite(floating) { + return 0, fmt.Errorf("无效浮点值: %v", value) + } + return floating, nil + } + reflected := reflect.ValueOf(value) + if !reflected.IsValid() { + return 0, fmt.Errorf("无效浮点值: %v", value) + } + var floating float64 + switch reflected.Kind() { + case reflect.Float32, reflect.Float64: + floating = reflected.Float() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if reflected.Int() < -(1<<53) || reflected.Int() > 1<<53 { + return 0, fmt.Errorf("浮点整数超出精确范围: %v", value) + } + floating = float64(reflected.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if reflected.Uint() > 1<<53 { + return 0, fmt.Errorf("浮点整数超出精确范围: %v", value) + } + floating = float64(reflected.Uint()) + default: + return 0, fmt.Errorf("无效浮点值: %v", value) + } + if !isFinite(floating) { + return 0, fmt.Errorf("无效浮点值: %v", value) + } + return floating, nil +} + +func validateRange(propertyType string, value *big.Rat, valueRange []json.Number) error { + if len(valueRange) == 0 { + return nil + } + if len(valueRange) < 2 { + return errors.New("无效的属性范围") + } + minimum, minErr := propertyNumberRat(propertyType, valueRange[0]) + maximum, maxErr := propertyNumberRat(propertyType, valueRange[1]) + if minErr != nil || maxErr != nil || value.Cmp(minimum) < 0 || value.Cmp(maximum) > 0 { + return fmt.Errorf("%v 超出数值范围 [%s, %s]", value, valueRange[0], valueRange[1]) + } + if len(valueRange) >= 3 { + step, err := propertyNumberRat(propertyType, valueRange[2]) + if err != nil || step.Sign() <= 0 { + return errors.New("无效的属性步长") + } + steps := new(big.Rat).Quo(new(big.Rat).Sub(value, minimum), step) + if !steps.IsInt() { + return fmt.Errorf("无效的值: %v,步长应为 %s", value, valueRange[2]) + } + } + return nil +} + +func integerText(value any) (string, bool) { + switch number := value.(type) { + case string: + return number, true + case json.Number: + return number.String(), true + default: + return "", false + } +} + +func signedInteger(text string) (int64, error) { + rational, err := textRat(text) + if err != nil || !rational.IsInt() || !rational.Num().IsInt64() { + return 0, errors.New("not an int64") + } + return rational.Num().Int64(), nil +} + +func unsignedInteger(text string) (uint64, error) { + rational, err := textRat(text) + if err != nil || !rational.IsInt() || rational.Sign() < 0 || !rational.Num().IsUint64() { + return 0, errors.New("not a uint64") + } + return rational.Num().Uint64(), nil +} + +func numberRat(number json.Number) (*big.Rat, error) { + return textRat(number.String()) +} + +func propertyNumberRat(propertyType string, number json.Number) (*big.Rat, error) { + switch propertyType { + case "int": + integer, err := signedInteger(number.String()) + if err != nil { + return nil, err + } + return new(big.Rat).SetInt64(integer), nil + case "uint": + integer, err := unsignedInteger(number.String()) + if err != nil { + return nil, err + } + return new(big.Rat).SetUint64(integer), nil + default: + return numberRat(number) + } +} + +func textRat(text string) (*big.Rat, error) { + rational, ok := new(big.Rat).SetString(text) + if !ok { + return nil, errors.New("invalid number") + } + return rational, nil +} + +func isFinite(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/device_test.go b/device_test.go new file mode 100644 index 0000000..4f69216 --- /dev/null +++ b/device_test.go @@ -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: ""}, + {name: "invalid json", fixture: ``}, + } + 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 = `` + variants := []struct { + name string + opening string + closing string + }{ + {name: "reordered", opening: ``}, + {name: "unquoted attributes", opening: `"; + + + + diff --git a/types.go b/types.go new file mode 100644 index 0000000..e41e720 --- /dev/null +++ b/types.go @@ -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) +}