diff --git a/client.go b/client.go index db53c78..c4e7a9a 100644 --- a/client.go +++ b/client.go @@ -201,6 +201,10 @@ func (client *Client) request(ctx context.Context, uri string, data any, refresh return nil, fmt.Errorf("read API response: %w", err) } if response.StatusCode < 200 || response.StatusCode >= 300 { + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + loginErr := &LoginError{Code: response.StatusCode, Message: strings.TrimSpace(string(body))} + return nil, fmt.Errorf("API HTTP status from %s: %w: %w", uri, ErrReauthenticationRequired, loginErr) + } return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body))) } result, err := decodeAPIResponse(authData.Ssecurity, nonce, body) @@ -234,7 +238,11 @@ func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, e if message == "" { message = "未知错误" } - return nil, &APIError{Code: envelope.Code, Message: message} + apiErr := &APIError{Code: envelope.Code, Message: message} + if envelope.Code == -10020 || envelope.Code == -10030 { + return nil, fmt.Errorf("%w: %w", ErrReauthenticationRequired, apiErr) + } + return nil, apiErr } return envelope.Result, nil } diff --git a/client_test.go b/client_test.go index e326d3e..e5a6bc5 100644 --- a/client_test.go +++ b/client_test.go @@ -231,6 +231,54 @@ func TestRequestRejectsHTTPStatus(t *testing.T) { } } +func TestBusinessRequestClassifiesReauthenticationFailures(t *testing.T) { + tests := []struct { + name string + status int + body string + wantSentinel bool + wantCode int + wantLogin bool + }{ + {name: "HTTP 401", status: http.StatusUnauthorized, body: "expired", wantSentinel: true, wantCode: http.StatusUnauthorized, wantLogin: true}, + {name: "HTTP 403", status: http.StatusForbidden, body: "forbidden", wantSentinel: true, wantCode: http.StatusForbidden, wantLogin: true}, + {name: "API -10020", body: `{"code":-10020,"message":"oauth expired"}`, wantSentinel: true, wantCode: -10020}, + {name: "API -10030", body: `{"code":-10030,"message":"token expired"}`, wantSentinel: true, wantCode: -10030}, + {name: "HTTP 500", status: http.StatusInternalServerError, body: "failed", wantCode: http.StatusInternalServerError}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + if test.status != 0 { + writer.WriteHeader(test.status) + } + _, _ = io.WriteString(writer, test.body) + })) + defer server.Close() + + client := testClient(t, server.Client()) + client.baseURL = server.URL + _, err := client.request(context.Background(), "/business", nil, true) + if errors.Is(err, ErrReauthenticationRequired) != test.wantSentinel { + t.Fatalf("error = %v, ErrReauthenticationRequired = %v", err, errors.Is(err, ErrReauthenticationRequired)) + } + if test.wantLogin { + var loginErr *LoginError + if !errors.As(err, &loginErr) || loginErr.Code != test.wantCode { + t.Fatalf("error = %v, want LoginError code %d", err, test.wantCode) + } + return + } + if test.status == 0 { + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Code != test.wantCode { + t.Fatalf("error = %v, want APIError code %d", err, test.wantCode) + } + } + }) + } +} + func TestRequestRejectsOversizedRawResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { _, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1)) diff --git a/device.go b/device.go index f20abef..9af2e70 100644 --- a/device.go +++ b/device.go @@ -19,8 +19,9 @@ import ( ) const ( - deviceSpecUA = "mijiaAPI/4.1.2" - deviceSpecMaxSize = 8 << 20 + deviceSpecUA = "mijiaAPI/4.1.2" + deviceSpecMaxSize = 8 << 20 + deviceCacheVersion = 2 ) var deviceSpecURL = "https://home.miot-spec.com/spec/" @@ -444,7 +445,7 @@ 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} + cache := deviceInfoCache{Version: deviceCacheVersion, Name: info.Name, Model: info.Model} for _, property := range info.Properties { cache.Properties = append(cache.Properties, propertyCache{ PropertySpec: property, @@ -505,6 +506,7 @@ type actionCache struct { } type deviceInfoCache struct { + Version int `json:"version"` Name string `json:"name"` Model string `json:"model"` Properties []propertyCache `json:"properties"` @@ -524,6 +526,12 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) { } return DeviceInfo{}, fmt.Errorf("trailing content: %w", err) } + if cache.Version < deviceCacheVersion { + return DeviceInfo{}, fmt.Errorf("device info cache stale: version %d, want %d", cache.Version, deviceCacheVersion) + } + if cache.Version != deviceCacheVersion { + return DeviceInfo{}, fmt.Errorf("unsupported device info cache version %d", cache.Version) + } info := DeviceInfo{Name: cache.Name, Model: cache.Model} for _, cachedProperty := range cache.Properties { diff --git a/device_test.go b/device_test.go index 59795a1..d8c3e27 100644 --- a/device_test.go +++ b/device_test.go @@ -141,6 +141,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) { t.Fatal(err) } var pythonCache struct { + Version int `json:"version"` Properties []struct { Method cacheMethod `json:"method"` } `json:"properties"` @@ -148,7 +149,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) { 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 { + if err := json.Unmarshal(cacheData, &pythonCache); err != nil || pythonCache.Version != 2 || 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 @@ -186,7 +187,7 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) { "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{ + testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{ `{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`, `[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`, @@ -213,9 +214,9 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) { 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)}, + {"siid": float64(2), "piid": float64(1)}, + {"siid": float64(2), "piid": float64(1)}, + {"siid": float64(2), "aiid": float64(1)}, } { params := testServer.requests[index+2]["params"] var request map[string]any @@ -230,8 +231,68 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) { } } } - if testServer.specCalls != 0 { - t.Fatalf("spec calls = %d, want 0", testServer.specCalls) + if testServer.specCalls != 1 { + t.Fatalf("spec calls = %d, want 1", testServer.specCalls) + } +} + +func TestOldDeviceInfoCacheRefreshesActionInputs(t *testing.T) { + for _, version := range []int{0, 1} { + t.Run(fmt.Sprintf("version %d", version), func(t *testing.T) { + testServer := newDeviceTestServer(t, loadSpecFixture(t), nil) + cacheDir := t.TempDir() + cache := fmt.Sprintf(`{ + "version":%d, + "name":"Old Lamp", + "model":"test.light.v1", + "properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}], + "actions":[{"name":"toggle","siid":2,"aiid":1}] +}`, version) + if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil { + t.Fatal(err) + } + + info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) + if err != nil || testServer.specCalls != 1 || len(info.Actions) == 0 || len(info.Actions[0].Inputs) != 2 { + t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls) + } + }) + } +} + +func TestVersion2DeviceInfoCacheTrustsActionWithoutInputs(t *testing.T) { + testServer := newDeviceTestServer(t, nil, nil) + cacheDir := t.TempDir() + const cache = `{ + "version":2, + "name":"Cached Lamp", + "model":"test.light.v1", + "properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}], + "actions":[{"name":"toggle","siid":2,"aiid":1}] +}` + if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil { + t.Fatal(err) + } + + info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) + if err != nil || testServer.specCalls != 0 || len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 0 { + t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls) + } +} + +func TestStaleDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) { + testServer := newDeviceTestServer(t, []byte("unavailable"), nil) + testServer.status = http.StatusServiceUnavailable + cacheDir := t.TempDir() + cachePath := filepath.Join(cacheDir, "test.light.v1.json") + const cache = `{"version":1,"model":"test.light.v1","properties":[],"actions":[]}` + if err := os.WriteFile(cachePath, []byte(cache), 0o600); err != nil { + t.Fatal(err) + } + + _, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) + if err == nil || !strings.Contains(err.Error(), "cache stale") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") { + t.Fatalf("error = %v", err) } } @@ -270,6 +331,7 @@ func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) { func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) { const cache = `{ + "version":2, "model":"test.light.v1", "properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","siid":2,"piid":1}], "actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"missing","type":"bool","rw":"rw","siid":2,"piid":99}]}] @@ -282,6 +344,7 @@ func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) { func TestDecodeDeviceInfoCanonicalizesActionInputMetadata(t *testing.T) { const cache = `{ + "version":2, "model":"test.light.v1", "properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","value-list":[{"value":0,"description":"Off"},{"value":1,"description":"On"}],"siid":2,"piid":1}], "actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"forged","description":"Forged","type":"string","rw":"w","range":[1,9,1],"siid":2,"piid":1}]}]