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) }