4 Commits
7 changed files with 171 additions and 16 deletions
+2 -1
View File
@@ -117,7 +117,7 @@ _, err = client.RunActions(ctx, []mijia.ActionRequest{
## 高级 Device
`NewDevice` 可通过 `DeviceSelector.DID``DeviceSelector.Name` 选择设备;名称匹配到多个设备时会返回 `MultipleDevicesFoundError`。设备描述默认缓存到认证文件所在目录每次成功的 `Get``Set``RunAction` 后默认等待 `500ms`
`NewDevice` 可通过 `DeviceSelector.DID``DeviceSelector.Name` 选择设备;名称匹配到多个设备时会返回 `MultipleDevicesFoundError`通过文件认证的 `NewClient` 默认把设备描述缓存到认证文件所在目录`NewClientWithAuthData` 默认不启用缓存,调用方需要在创建 `Device` 时传入 `WithDeviceCacheDir` 才会缓存。每次成功的 `Get``Set``RunAction` 后默认等待 `500ms`
```go
device, err := mijia.NewDevice(ctx, client, mijia.DeviceSelector{DID: "设备 DID"})
@@ -149,6 +149,7 @@ device, err := mijia.NewDevice(
client,
mijia.DeviceSelector{Name: "客厅灯"},
mijia.WithDeviceDelay(0),
// 使用 NewClientWithAuthData 时,显式指定目录才能缓存设备描述。
mijia.WithDeviceCacheDir("./miot-cache"),
mijia.WithDeviceHTTPClient(http.DefaultClient),
)
+8 -4
View File
@@ -61,6 +61,7 @@ func WithQRWriter(writer io.Writer) Option {
type Client struct {
authPath string
deviceCacheDir string
authDataChanged func(AuthData) error
authMu sync.RWMutex
authData AuthData
@@ -89,6 +90,7 @@ func NewClient(authPath string, options ...Option) (*Client, error) {
return nil, err
}
client.authPath = resolvedPath
client.deviceCacheDir = filepath.Dir(resolvedPath)
if err := client.loadAuthData(); err != nil {
return nil, err
}
@@ -135,10 +137,12 @@ func newClient(options ...ClientOption) (*Client, error) {
}
// WithAuthDataChanged configures synchronous persistence for in-memory auth updates.
// The callback is serialized by the client's login lock and may acquire unrelated
// application locks, but it must not call Client methods other than AuthData.
// In particular, calling Login or another method that may refresh authentication
// will deadlock. Returning an error leaves the client's authentication unchanged.
// The callback runs under the client's internal login serialization and must not
// use Client directly or indirectly, including by waiting for a goroutine, future,
// channel, or hook whose completion may call Client. It should perform only bounded,
// standalone persistence and return. The callback and Client callers must obey lock
// ordering: neither may hold a lock needed by the other. Returning an error leaves
// authentication unchanged.
func WithAuthDataChanged(callback func(AuthData) error) ClientOption {
return func(client *Client) error {
if callback == nil {
+23 -3
View File
@@ -498,7 +498,24 @@ type cacheMethod struct {
type propertyCache struct {
PropertySpec
Method cacheMethod `json:"method"`
Method cacheMethod `json:"method"`
rwPresent bool
}
func (cache *propertyCache) UnmarshalJSON(data []byte) error {
type propertyCacheAlias propertyCache
decoded := struct {
*propertyCacheAlias
RW *string `json:"rw"`
}{propertyCacheAlias: (*propertyCacheAlias)(cache)}
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
cache.rwPresent = decoded.RW != nil
if decoded.RW != nil {
cache.RW = *decoded.RW
}
return nil
}
type actionCache struct {
@@ -536,6 +553,9 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) {
info := DeviceInfo{Name: cache.Name, Model: cache.Model}
for _, cachedProperty := range cache.Properties {
if !cachedProperty.rwPresent {
return DeviceInfo{}, fmt.Errorf("property %q is missing access metadata", cachedProperty.Name)
}
property := cachedProperty.PropertySpec
if property.SIID == 0 {
property.SIID = cachedProperty.Method.SIID
@@ -572,7 +592,7 @@ func validateDeviceInfo(info *DeviceInfo, model string) error {
properties := make(map[propertyID]PropertySpec, len(info.Properties))
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 {
(property.RW != "" && property.RW != "r" && property.RW != "w" && property.RW != "rw") || property.SIID <= 0 || property.PIID <= 0 {
return fmt.Errorf("property %d is invalid", index)
}
properties[propertyID{siid: property.SIID, piid: property.PIID}] = property
@@ -599,7 +619,7 @@ func NewDevice(ctx context.Context, client *Client, selector DeviceSelector, opt
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}
config := deviceConfig{httpClient: client.session(), cacheDir: client.deviceCacheDir, delay: 500 * time.Millisecond}
for _, option := range options {
if option != nil {
if err := option(&config); err != nil {
+109 -1
View File
@@ -104,6 +104,15 @@ func loadSpecFixture(t *testing.T) []byte {
return fixture
}
func loadNonControllableSpecFixture(t *testing.T) []byte {
t.Helper()
fixture, err := os.ReadFile("testdata/miot-spec-non-controllable.html")
if err != nil {
t.Fatal(err)
}
return fixture
}
func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
httpClient := testServer.server.Client()
@@ -171,6 +180,41 @@ func TestParseDeviceInfoRejectsUnknownActionInput(t *testing.T) {
}
}
func TestGetDeviceInfoPreservesNonControllableProperties(t *testing.T) {
testServer := newDeviceTestServer(t, loadNonControllableSpecFixture(t), nil)
cacheDir := t.TempDir()
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.sensor.v1", cacheDir)
if err != nil {
t.Fatal(err)
}
if len(info.Properties) != 2 || info.Properties[0].Name != "event" || info.Properties[0].RW != "" || info.Properties[1].Name != "command" || info.Properties[1].RW != "" {
t.Fatalf("properties = %#v", info.Properties)
}
if len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 1 || !reflect.DeepEqual(info.Actions[0].Inputs[0], info.Properties[1]) {
t.Fatalf("actions = %#v", info.Actions)
}
testServer.fixture = nil
cached, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.sensor.v1", cacheDir)
if err != nil {
t.Fatal(err)
}
if len(cached.Properties) != 2 || cached.Properties[0].RW != "" || cached.Properties[1].RW != "" ||
len(cached.Actions) != 1 || len(cached.Actions[0].Inputs) != 1 || !reflect.DeepEqual(cached.Actions[0].Inputs[0], cached.Properties[1]) || testServer.specCalls != 1 {
t.Fatalf("cached = %#v, spec calls = %d", cached, testServer.specCalls)
}
}
func TestDeviceRejectsGetSetForNonControllableProperty(t *testing.T) {
device := Device{properties: map[string]PropertySpec{"event": {Name: "event", Type: "string", SIID: 2, PIID: 1}}}
if _, err := device.Get(context.Background(), "event"); err == nil || !strings.Contains(err.Error(), "不可读取") {
t.Fatalf("Get() error = %v", err)
}
if err := device.Set(context.Background(), "event", "value"); err == nil || !strings.Contains(err.Error(), "不可写入") {
t.Fatalf("Set() error = %v", err)
}
}
func TestDeviceActionsDeepCopyInputs(t *testing.T) {
device := Device{actions: map[string]ActionSpec{"toggle": {Inputs: []PropertySpec{{Range: []json.Number{"1", "2"}, ValueList: []ValueListItem{{Value: "1"}}}}}}}
actions := device.Actions()
@@ -282,6 +326,35 @@ func TestVersion2DeviceInfoCacheTrustsActionWithoutInputs(t *testing.T) {
}
}
func TestVersion2DeviceInfoCacheRequiresPropertyAccessField(t *testing.T) {
for _, test := range []struct {
name string
property string
wantCalls int
}{
{name: "missing", property: `{"name":"power","type":"bool","siid":2,"piid":1}`, wantCalls: 1},
{name: "explicit empty", property: `{"name":"power","type":"bool","rw":"","siid":2,"piid":1}`},
} {
t.Run(test.name, func(t *testing.T) {
fixture := loadSpecFixture(t)
if test.wantCalls == 0 {
fixture = nil
}
testServer := newDeviceTestServer(t, fixture, nil)
cacheDir := t.TempDir()
cache := fmt.Sprintf(`{"version":2,"model":"test.light.v1","properties":[%s],"actions":[]}`, test.property)
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 != test.wantCalls || len(info.Properties) == 0 {
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d, want calls=%d", info, err, testServer.specCalls, test.wantCalls)
}
})
}
}
func TestStaleDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
testServer.status = http.StatusServiceUnavailable
@@ -529,6 +602,40 @@ func TestNewDeviceDefaults(t *testing.T) {
}
}
func TestNewDeviceMemoryClientDefaultsToNoCache(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","model":"test.light.v1"}],"has_more":false}`})
client, err := NewClientWithAuthData(testAuthData(), WithHTTPClient(testServer.server.Client()))
if err != nil {
t.Fatal(err)
}
client.baseURL = testServer.server.URL
client.availability = true
client.availabilityValid = true
client.availabilityAt = time.Now()
workingDirectory := t.TempDir()
previousWorkingDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(workingDirectory); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(previousWorkingDirectory); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
if _, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0)); err != nil {
t.Fatal(err)
}
cachePath := filepath.Join(workingDirectory, "test.light.v1.json")
if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("memory client default cache stat error = %v, want not exist", err)
}
}
func TestDeviceGetSetAndAction(t *testing.T) {
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{
`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
@@ -678,7 +785,8 @@ func TestDeviceGetManyReturnsTransportError(t *testing.T) {
}
func TestDeviceGetManyValidatesBeforeNetwork(t *testing.T) {
device, testServer := fixtureDeviceWithServer(t, nil)
tests := [][]string{{"power", "power"}, {"power", "missing"}, {"power", "write-only"}}
device.properties["no-access"] = PropertySpec{Name: "no-access", Type: "bool", SIID: 2, PIID: 4}
tests := [][]string{{"power", "power"}, {"power", "missing"}, {"power", "write-only"}, {"power", "no-access"}}
for _, names := range tests {
if results, err := device.GetMany(context.Background(), names); err == nil || results != nil {
t.Fatalf("GetMany(%v) = %#v, %v", names, results, err)
+4
View File
@@ -30,6 +30,8 @@ commits: none
- 单次请求使用同一认证快照,因为签名、Cookie 和响应解密不能混用不同代 token。
- 设备 spec 缓存同时兼容 Go 顶层标识和 Python `method` 格式,因为两个实现默认共享认证目录和缓存文件名。
- 整数规格校验使用精确十进制/有理数比较,因为 `float64` 无法安全表达 `2^53` 以上整数。
- 内存认证的同步持久化回调保留在登录串行区内,以确保并发登录/刷新严格有序且回调失败时不安装候选认证。把回调移到所有内部锁外会允许旧回调在新认证提交后覆盖外部状态;票据或 worker 若同步等待,调用者持有回调所需锁时仍会形成 ABBA。因此公共契约明确要求回调和 Client 调用遵守锁顺序,而不是承诺任意应用锁安全。
- 文件客户端默认把设备 spec 缓存在认证文件目录;内存客户端默认不缓存,只有显式 `WithDeviceCacheDir` 才写入 spec 文件。
## Usage
@@ -56,6 +58,8 @@ auth, err := client.Login(ctx)
- `gofmt -w *.go`
- `go test -count=1 ./...`:通过
- `go vet ./...`:通过
- `go test ./... -count=20`:通过
- `go test -race ./...`:通过
- 独立规格审查和逐阶段代码质量审查均通过。
- 测试覆盖固定加密向量、gzip/大小限制、QR 与静默刷新、本地加密 HTTP 端点、分页异常、精确大整数、MIoT HTML 变体、Python 缓存迁移和 context 取消。
+19 -7
View File
@@ -10,7 +10,6 @@ import (
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
)
@@ -136,18 +135,31 @@ func TestMemoryRefreshCallbackCanReadCurrentAuth(t *testing.T) {
}
}
func TestMemoryRefreshCallbackCanAcquireApplicationLock(t *testing.T) {
var persistenceMu sync.Mutex
client, _, server := newMemoryRefreshClient(t, func(AuthData) error {
persistenceMu.Lock()
defer persistenceMu.Unlock()
return nil
func TestMemoryRefreshCallbackCanPersistIndependently(t *testing.T) {
persistencePath := filepath.Join(t.TempDir(), "persisted-auth.json")
client, _, server := newMemoryRefreshClient(t, func(authData AuthData) error {
payload, err := authData.MarshalJSON()
if err != nil {
return err
}
return os.WriteFile(persistencePath, payload, 0o600)
})
defer server.Close()
if err := client.refreshToken(context.Background()); err != nil {
t.Fatal(err)
}
payload, err := os.ReadFile(persistencePath)
if err != nil {
t.Fatal(err)
}
var persisted AuthData
if err := persisted.UnmarshalJSON(payload); err != nil {
t.Fatal(err)
}
if persisted.ServiceToken != "new-token" || persisted.CUserID != "new-c-user" {
t.Fatalf("persisted auth = %#v", persisted)
}
}
func TestMemoryRefreshPersistsGeneratedIdentity(t *testing.T) {
+6
View File
@@ -0,0 +1,6 @@
<!doctype html>
<html>
<body>
<script data-page="app" type="application/json">{"props":{"product":{"name":"Test Sensor","model":"test.sensor.v1"},"i18n":{"zh_cn":{}},"tree":{"services":[{"iid":2,"type":"sensor","properties":[{"iid":1,"type":"event","description":"Event","format":"string","access":["notify"]},{"iid":2,"type":"command","description":"Command","format":"uint8","access":[],"valueRange":[0,10,1]}],"actions":[{"iid":1,"type":"execute","description":"Execute","in":[2]}]}]}}}</script>
</body>
</html>