feat: add Go API library

This commit is contained in:
2026-07-18 22:40:40 +08:00
commit 4755655859
23 changed files with 5346 additions and 0 deletions
+407
View File
@@ -0,0 +1,407 @@
package mijia
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
)
type apiExpectation struct {
path string
body string
result string
status int
}
func TestCheckNewMessagesAndGetHomes(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/message/v2/check_new_msg", body: `{"begin_at":1700000000}`, result: `{"has_new":true}`},
{path: "/v2/homeroom/gethome_merged", body: `{"app_ver":7,"fetch_cariot":true,"fetch_share":true,"fetch_share_dev":true,"fg":true,"limit":300,"plat_form":0}`, result: `{"homelist":[{"id":"10","name":"Main","uid":100,"room_id":"living"},{"id":20,"name":"Other","uid":200}]}`},
})
message, err := client.CheckNewMessages(context.Background(), time.Unix(1700000000, 999))
if err != nil || string(message) != `{"has_new":true}` {
t.Fatalf("CheckNewMessages() = %s, %v", message, err)
}
homes, err := client.GetHomes(context.Background())
if err != nil {
t.Fatal(err)
}
if len(homes) != 2 || homes[0].ID != "10" || homes[1].ID != "20" || homes[1].UID != 200 {
t.Fatalf("GetHomes() = %#v", homes)
}
if string(homes[0].Extra["room_id"]) != `"living"` {
t.Fatalf("GetHomes()[0].Extra = %#v", homes[0].Extra)
}
verify()
}
func TestGetDevicesReturnsPartialResultsForRepeatedCursor(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":20,"uid":200}]}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a","name":"Lamp","model":"lamp.a"}],"max_did":"a","has_more":true}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"a","support_smart_home":true}`, result: `{"device_info":[{"did":"b","name":"Fan","model":"fan.b"}],"max_did":"a","has_more":true}`},
})
devices, err := client.GetDevices(context.Background(), "")
if err == nil || len(devices) != 2 || devices[0].HomeID != "10" || devices[1].DID != "b" || !strings.Contains(err.Error(), "max_did \"a\" repeats") {
t.Fatalf("GetDevices() = %#v, %v", devices, err)
}
verify()
}
func TestGetDevicesStopsCursorCycle(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100}]}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a"}],"max_did":"a","has_more":true}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"a","support_smart_home":true}`, result: `{"device_info":[{"did":"b"}],"max_did":"b","has_more":true}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"b","support_smart_home":true}`, result: `{"device_info":[{"did":"c"}],"max_did":"a","has_more":true}`},
})
devices, err := client.GetDevices(context.Background(), "10")
if err == nil || len(devices) != 3 || !strings.Contains(err.Error(), "cursor cycle") {
t.Fatalf("GetDevices() = %#v, %v", devices, err)
}
verify()
}
func TestGetDevicesReturnsPartialResultsForEmptyCursor(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100}]}`},
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a"}],"has_more":true}`},
})
devices, err := client.GetDevices(context.Background(), "10")
if err == nil || len(devices) != 1 || !strings.Contains(err.Error(), "max_did is empty") {
t.Fatalf("GetDevices() = %#v, %v", devices, err)
}
verify()
}
func TestGetDevicesSingleHomeErrors(t *testing.T) {
t.Run("owner missing", func(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[]}`}})
_, err := client.GetDevices(context.Background(), "10")
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != -1 {
t.Fatalf("error = %v, want APIError(-1)", err)
}
verify()
})
t.Run("invalid numeric id", func(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"home-a","uid":100}]}`}})
_, err := client.GetDevices(context.Background(), "home-a")
if err == nil {
t.Fatal("GetDevices() error = nil")
}
verify()
})
}
func TestSharedDevicesScenesAndConsumables(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/home/device_list_page", body: `{"bssid":"02:00:00:00:00:00","getHuamiDevices":1,"getVirtualModel":true,"get_cariot_device":true,"get_miwear_device":true,"get_phone_device":true,"get_split_device":true,"get_third_device":true,"ssid":"<unknown ssid>","support_smart_home":true}`, result: `{"list":[{"did":"owned","owner":true,"localip":"192.0.2.1"},{"did":"shared","owner":false},{"did":"missing"}]}`},
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
{path: "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList", body: `{"app_version":12,"get_type":2,"home_id":"10","owner_uid":100}`, result: `{"manual_scene_info_list":[{"scene_id":"s1","name":"Sleep","isOnline":true}]}`},
{path: "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList", body: `{"app_version":12,"get_type":2,"home_id":"20","owner_uid":200}`, result: `{}`},
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
{path: "/appgateway/miot/appsceneservice/AppSceneService/NewRunScene", body: `{"home_id":"10","owner_uid":100,"phone_id":"null","scene_id":"s1","scene_type":2}`, result: `true`},
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
{path: "/v2/home/standard_consumable_items", body: `{"filter_ignore":true,"home_id":10,"owner_id":100}`, result: `{"items":[{"consumes_data":[{"did":"a","name":"Filter","details":[{"id":"filter-life"}],"room_id":"kitchen"}]}]}`},
{path: "/v2/home/standard_consumable_items", body: `{"filter_ignore":true,"home_id":20,"owner_id":200}`, result: `{"items":[]}`},
})
shared, err := client.GetSharedDevices(context.Background())
if err != nil || len(shared) != 1 || shared[0].DID != "owned" || shared[0].HomeID != "shared" || string(shared[0].Extra["localip"]) != `"192.0.2.1"` {
t.Fatalf("GetSharedDevices() = %#v, %v", shared, err)
}
scenes, err := client.GetScenes(context.Background(), "")
if err != nil || len(scenes) != 1 || scenes[0].HomeID != "10" || string(scenes[0].Extra["isOnline"]) != "true" {
t.Fatalf("GetScenes() = %#v, %v", scenes, err)
}
runResult, err := client.RunScene(context.Background(), "s1", "10")
if err != nil || string(runResult) != "true" {
t.Fatalf("RunScene() = %s, %v", runResult, err)
}
consumables, err := client.GetConsumables(context.Background(), "")
if err != nil || len(consumables) != 1 || consumables[0].HomeID != "10" || string(consumables[0].Details) != `{"id":"filter-life"}` || string(consumables[0].Extra["room_id"]) != `"kitchen"` {
t.Fatalf("GetConsumables() = %#v, %v", consumables, err)
}
verify()
}
func TestPropertiesActionsAndStatistics(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"a","siid":2,"piid":1},{"did":"b","siid":3,"piid":2}]}`, result: `[{"did":"a","siid":2,"piid":1,"value":true,"code":0},{"did":"b","siid":3,"piid":2,"code":-704030013}]`},
{path: "/miotspec/prop/set", body: `{"params":[{"did":"a","siid":2,"piid":1,"value":true},{"did":"b","siid":3,"piid":2,"value":5}]}`, result: `[{"did":"a","siid":2,"piid":1,"code":1},{"did":"b","siid":3,"piid":2,"code":-704030023}]`},
{path: "/miotspec/action", body: `{"params":{"did":"a","siid":2,"aiid":1}}`, result: `{"did":"a","siid":2,"aiid":1,"code":0,"out":{"enabled":true}}`},
{path: "/miotspec/action", body: `{"params":{"did":"b","siid":3,"aiid":2,"value":[5]}}`, result: `{"did":"b","siid":3,"aiid":2,"code":-704040005}`},
{path: "/v2/user/statistics", body: `{"data_type":"stat_day_v3","did":"a","key":"2.1","limit":2,"time_end":20,"time_start":10}`, result: `[{"time":10,"value":"[1]"}]`},
{path: "/v2/user/statistics", body: `{"data_type":"stat_hour_v3","did":"b","key":"3.2","limit":1,"time_end":40,"time_start":30}`, result: `[{"time":30,"value":"[2]"}]`},
})
properties, err := client.GetProperties(context.Background(), []PropertyRequest{{DID: "a", SIID: 2, PIID: 1}, {DID: "b", SIID: 3, PIID: 2}})
if err != nil || len(properties) != 2 || properties[0].Value != true {
t.Fatalf("GetProperties() = %#v, %v", properties, err)
}
setResults, err := client.SetProperties(context.Background(), []PropertySetRequest{{DID: "a", SIID: 2, PIID: 1, Value: true}, {DID: "b", SIID: 3, PIID: 2, Value: 5}})
if err != nil || setResults[0].Message != "成功" || setResults[1].Message != ErrorMessage(-704030023) {
t.Fatalf("SetProperties() = %#v, %v", setResults, err)
}
actions, err := client.RunActions(context.Background(), []ActionRequest{{DID: "a", SIID: 2, AIID: 1}, {DID: "b", SIID: 3, AIID: 2, Value: []any{5}}})
if err != nil || actions[0].Message != "成功" || string(actions[0].Out) != `{"enabled":true}` || actions[1].Message != ErrorMessage(-704040005) || actions[1].Out != nil {
t.Fatalf("RunActions() = %#v, %v", actions, err)
}
statistics, err := client.GetStatistics(context.Background(), []StatisticsRequest{
{DID: "a", Key: "2.1", DataType: "stat_day_v3", Limit: 2, TimeStart: 10, TimeEnd: 20},
{DID: "b", Key: "3.2", DataType: "stat_hour_v3", Limit: 1, TimeStart: 30, TimeEnd: 40},
})
if err != nil || len(statistics) != 2 || string(statistics[1]) != `[{"time":30,"value":"[2]"}]` {
t.Fatalf("GetStatistics() = %#v, %v", statistics, err)
}
verify()
}
func TestPropertyResultsPreserveDynamicJSONNumbers(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"integer","siid":2,"piid":1},{"did":"float","siid":2,"piid":2},{"did":"bool","siid":2,"piid":3},{"did":"string","siid":2,"piid":4}]}`, result: `[{"did":"integer","value":9007199254740993},{"did":"float","value":1.25},{"did":"bool","value":true},{"did":"string","value":"on"}]`},
})
properties, err := client.GetProperties(context.Background(), []PropertyRequest{
{DID: "integer", SIID: 2, PIID: 1},
{DID: "float", SIID: 2, PIID: 2},
{DID: "bool", SIID: 2, PIID: 3},
{DID: "string", SIID: 2, PIID: 4},
})
integer, integerOK := properties[0].Value.(json.Number)
decimal, decimalOK := properties[1].Value.(json.Number)
if err != nil || !integerOK || integer.String() != "9007199254740993" || !decimalOK || decimal.String() != "1.25" || properties[2].Value != true || properties[3].Value != "on" {
t.Fatalf("GetProperties() = %#v, %v", properties, err)
}
verify()
}
func TestDecodeErrorsIncludeEndpoint(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"a","siid":2,"piid":1}]}`, result: `{`},
})
_, err := client.GetProperties(context.Background(), []PropertyRequest{{DID: "a", SIID: 2, PIID: 1}})
if err == nil || !strings.Contains(err.Error(), "/miotspec/prop/get") {
t.Fatalf("GetProperties() error = %v", err)
}
verify()
}
func TestResponseTypesMarshalExtraWithoutKnownFieldConflicts(t *testing.T) {
responses := []any{
Home{ID: "10", Extra: map[string]json.RawMessage{"id": json.RawMessage(`"wrong"`), "room_id": json.RawMessage(`"living"`)}},
Device{DID: "device", Extra: map[string]json.RawMessage{"did": json.RawMessage(`"wrong"`), "localip": json.RawMessage(`"192.0.2.1"`)}},
Scene{SceneID: "scene", Extra: map[string]json.RawMessage{"scene_id": json.RawMessage(`"wrong"`), "isOnline": json.RawMessage(`true`)}},
Consumable{DID: "filter", Extra: map[string]json.RawMessage{"did": json.RawMessage(`"wrong"`), "room_id": json.RawMessage(`"kitchen"`)}},
}
for _, response := range responses {
payload, err := json.Marshal(response)
if err != nil {
t.Fatalf("json.Marshal(%T) error = %v", response, err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatalf("json.Unmarshal(%T payload) error = %v", response, err)
}
if string(fields["id"]) == `"wrong"` || string(fields["did"]) == `"wrong"` || string(fields["scene_id"]) == `"wrong"` {
t.Fatalf("json.Marshal(%T) allowed Extra conflict: %s", response, payload)
}
if fields["room_id"] == nil && fields["localip"] == nil && fields["isOnline"] == nil {
t.Fatalf("json.Marshal(%T) dropped Extra: %s", response, payload)
}
}
}
func TestPropertiesEmptyRequestsDoNotUseNetwork(t *testing.T) {
client, verify := newAPIClient(t, nil)
properties, err := client.GetProperties(context.Background(), nil)
if err != nil || properties == nil || len(properties) != 0 {
t.Fatalf("GetProperties(nil) = %#v, %v", properties, err)
}
setResults, err := client.SetProperties(context.Background(), []PropertySetRequest{})
if err != nil || setResults == nil || len(setResults) != 0 {
t.Fatalf("SetProperties(empty) = %#v, %v", setResults, err)
}
verify()
}
func TestBatchRequestsReturnPartialResults(t *testing.T) {
t.Run("actions", func(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/action", body: `{"params":{"did":"a","siid":2,"aiid":1}}`, result: `{"did":"a","siid":2,"aiid":1,"code":0}`},
{path: "/miotspec/action", body: `{"params":{"did":"b","siid":2,"aiid":1}}`, status: http.StatusInternalServerError, result: `failed`},
})
results, err := client.RunActions(context.Background(), []ActionRequest{{DID: "a", SIID: 2, AIID: 1}, {DID: "b", SIID: 2, AIID: 1}})
if err == nil || len(results) != 1 || results[0].DID != "a" {
t.Fatalf("RunActions() = %#v, %v", results, err)
}
verify()
})
t.Run("statistics", func(t *testing.T) {
requests := []StatisticsRequest{{DID: "a"}, {DID: "b"}}
client, verify := newAPIClient(t, []apiExpectation{
{path: "/v2/user/statistics", body: `{"data_type":"","did":"a","key":"","limit":0,"time_end":0,"time_start":0}`, result: `[1]`},
{path: "/v2/user/statistics", body: `{"data_type":"","did":"b","key":"","limit":0,"time_end":0,"time_start":0}`, status: http.StatusInternalServerError, result: `failed`},
})
results, err := client.GetStatistics(context.Background(), requests)
if err == nil || len(results) != 1 || string(results[0]) != `[1]` {
t.Fatalf("GetStatistics() = %#v, %v", results, err)
}
verify()
})
}
func TestResultMessagesPreserveUnknownDiagnostics(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/prop/set", body: `{"params":[{"did":"a","siid":2,"piid":1,"value":true},{"did":"b","siid":2,"piid":1,"value":true},{"did":"c","siid":2,"piid":1,"value":true}]}`, result: `[{"did":"a","code":0,"message":"wrong"},{"did":"b","code":-704030023,"message":"server known"},{"did":"c","code":-99999,"message":"server detail"}]`},
{path: "/miotspec/action", body: `{"params":{"did":"d","siid":2,"aiid":1}}`, result: `{"did":"d","code":-99998,"message":"action detail"}`},
{path: "/miotspec/action", body: `{"params":{"did":"e","siid":2,"aiid":1}}`, result: `{"did":"e","code":-99997}`},
})
properties, err := client.SetProperties(context.Background(), []PropertySetRequest{{DID: "a", SIID: 2, PIID: 1, Value: true}, {DID: "b", SIID: 2, PIID: 1, Value: true}, {DID: "c", SIID: 2, PIID: 1, Value: true}})
if err != nil || properties[0].Message != "成功" || properties[1].Message != ErrorMessage(-704030023) || properties[2].Message != "server detail" {
t.Fatalf("SetProperties() = %#v, %v", properties, err)
}
actions, err := client.RunActions(context.Background(), []ActionRequest{{DID: "d", SIID: 2, AIID: 1}, {DID: "e", SIID: 2, AIID: 1}})
if err != nil || actions[0].Message != "action detail" || actions[1].Message != "未知错误" {
t.Fatalf("RunActions() = %#v, %v", actions, err)
}
verify()
}
func TestActionRequestMarshalJSON(t *testing.T) {
t.Run("nil extra matches legacy encoding", func(t *testing.T) {
payload, err := json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2})
if err != nil || string(payload) != `{"did":"d","siid":1,"aiid":2}` {
t.Fatalf("json.Marshal() = %s, %v", payload, err)
}
payload, err = json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2, Value: []any{5}})
if err != nil || string(payload) != `{"did":"d","siid":1,"aiid":2,"value":[5]}` {
t.Fatalf("json.Marshal() = %s, %v", payload, err)
}
})
t.Run("extra keys are merged", func(t *testing.T) {
payload, err := json.Marshal(ActionRequest{DID: "d", SIID: 5, AIID: 4, Extra: map[string]any{"in": []any{"打开空调", 1}}})
if err != nil || !jsonEqual(payload, []byte(`{"did":"d","siid":5,"aiid":4,"in":["打开空调",1]}`)) {
t.Fatalf("json.Marshal() = %s, %v", payload, err)
}
})
t.Run("reserved key conflicts fail", func(t *testing.T) {
for _, key := range []string{"did", "siid", "aiid", "value"} {
if _, err := json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2, Extra: map[string]any{key: "x"}}); err == nil {
t.Fatalf("json.Marshal() with extra key %q error = nil", key)
}
}
})
}
func TestRunActionsSendsExtraInsideParams(t *testing.T) {
client, verify := newAPIClient(t, []apiExpectation{
{path: "/miotspec/action", body: `{"params":{"did":"speaker","siid":5,"aiid":4,"in":["打开空调",1]}}`, result: `{"did":"speaker","siid":5,"aiid":4,"code":0}`},
})
results, err := client.RunActions(context.Background(), []ActionRequest{{DID: "speaker", SIID: 5, AIID: 4, Extra: map[string]any{"in": []any{"打开空调", 1}}}})
if err != nil || len(results) != 1 || results[0].Code != 0 {
t.Fatalf("RunActions() = %#v, %v", results, err)
}
verify()
client, verify = newAPIClient(t, nil)
if _, err := client.RunActions(context.Background(), []ActionRequest{{DID: "speaker", SIID: 5, AIID: 4, Extra: map[string]any{"value": 1}}}); err == nil {
t.Fatal("RunActions() with conflicting extra key error = nil")
}
verify()
}
const homesBody = `{"app_ver":7,"fetch_cariot":true,"fetch_share":true,"fetch_share_dev":true,"fg":true,"limit":300,"plat_form":0}`
func newAPIClient(t *testing.T, expectations []apiExpectation) (*Client, func()) {
t.Helper()
var mutex sync.Mutex
requestIndex := 0
var handlerErrors []error
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
mutex.Lock()
defer mutex.Unlock()
if requestIndex >= len(expectations) {
handlerErrors = append(handlerErrors, fmt.Errorf("unexpected request %s", request.URL.Path))
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
return
}
expectation := expectations[requestIndex]
requestIndex++
if request.URL.Path != expectation.path {
handlerErrors = append(handlerErrors, fmt.Errorf("request %d path = %s, want %s", requestIndex, request.URL.Path, expectation.path))
}
if err := request.ParseForm(); err != nil {
handlerErrors = append(handlerErrors, err)
return
}
nonce := request.PostForm.Get("_nonce")
signed, err := signedNonce(testSsecurity, nonce)
if err != nil {
handlerErrors = append(handlerErrors, err)
return
}
payload, err := decryptRC4(signed, request.PostForm.Get("data"))
if err != nil {
handlerErrors = append(handlerErrors, err)
return
}
if !jsonEqual(payload, []byte(expectation.body)) {
handlerErrors = append(handlerErrors, fmt.Errorf("request %d body = %s, want %s", requestIndex, payload, expectation.body))
}
if expectation.status != 0 {
writer.WriteHeader(expectation.status)
_, _ = io.WriteString(writer, expectation.result)
return
}
_, _ = io.WriteString(writer, `{"code":0,"result":`+expectation.result+`}`)
}))
client := testClient(t, server.Client())
client.baseURL = server.URL
return client, func() {
t.Helper()
server.Close()
mutex.Lock()
count := requestIndex
errors := append([]error(nil), handlerErrors...)
mutex.Unlock()
for _, err := range errors {
t.Error(err)
}
if count != len(expectations) {
t.Errorf("requests = %d, want %d", count, len(expectations))
}
}
}
func jsonEqual(left, right []byte) bool {
var leftValue any
var rightValue any
return json.Unmarshal(left, &leftValue) == nil && json.Unmarshal(right, &rightValue) == nil && reflect.DeepEqual(leftValue, rightValue)
}