Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6957feacb6 | ||
|
|
8523b01231 |
@@ -117,7 +117,7 @@ _, err = client.RunActions(ctx, []mijia.ActionRequest{
|
|||||||
|
|
||||||
## 高级 Device
|
## 高级 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
|
```go
|
||||||
device, err := mijia.NewDevice(ctx, client, mijia.DeviceSelector{DID: "设备 DID"})
|
device, err := mijia.NewDevice(ctx, client, mijia.DeviceSelector{DID: "设备 DID"})
|
||||||
@@ -149,6 +149,7 @@ device, err := mijia.NewDevice(
|
|||||||
client,
|
client,
|
||||||
mijia.DeviceSelector{Name: "客厅灯"},
|
mijia.DeviceSelector{Name: "客厅灯"},
|
||||||
mijia.WithDeviceDelay(0),
|
mijia.WithDeviceDelay(0),
|
||||||
|
// 使用 NewClientWithAuthData 时,显式指定目录才能缓存设备描述。
|
||||||
mijia.WithDeviceCacheDir("./miot-cache"),
|
mijia.WithDeviceCacheDir("./miot-cache"),
|
||||||
mijia.WithDeviceHTTPClient(http.DefaultClient),
|
mijia.WithDeviceHTTPClient(http.DefaultClient),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func WithQRWriter(writer io.Writer) Option {
|
|||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
authPath string
|
authPath string
|
||||||
|
deviceCacheDir string
|
||||||
authDataChanged func(AuthData) error
|
authDataChanged func(AuthData) error
|
||||||
authMu sync.RWMutex
|
authMu sync.RWMutex
|
||||||
authData AuthData
|
authData AuthData
|
||||||
@@ -89,6 +90,7 @@ func NewClient(authPath string, options ...Option) (*Client, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
client.authPath = resolvedPath
|
client.authPath = resolvedPath
|
||||||
|
client.deviceCacheDir = filepath.Dir(resolvedPath)
|
||||||
if err := client.loadAuthData(); err != nil {
|
if err := client.loadAuthData(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -135,10 +137,12 @@ func newClient(options ...ClientOption) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WithAuthDataChanged configures synchronous persistence for in-memory auth updates.
|
// WithAuthDataChanged configures synchronous persistence for in-memory auth updates.
|
||||||
// The callback is serialized by the client's login lock and may acquire unrelated
|
// The callback runs under the client's internal login serialization and must not
|
||||||
// application locks, but it must not call Client methods other than AuthData.
|
// use Client directly or indirectly, including by waiting for a goroutine, future,
|
||||||
// In particular, calling Login or another method that may refresh authentication
|
// channel, or hook whose completion may call Client. It should perform only bounded,
|
||||||
// will deadlock. Returning an error leaves the client's authentication unchanged.
|
// 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 {
|
func WithAuthDataChanged(callback func(AuthData) error) ClientOption {
|
||||||
return func(client *Client) error {
|
return func(client *Client) error {
|
||||||
if callback == nil {
|
if callback == nil {
|
||||||
|
|||||||
@@ -619,7 +619,7 @@ func NewDevice(ctx context.Context, client *Client, selector DeviceSelector, opt
|
|||||||
if client == nil {
|
if client == nil {
|
||||||
return nil, errors.New("client must not be 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 {
|
for _, option := range options {
|
||||||
if option != nil {
|
if option != nil {
|
||||||
if err := option(&config); err != nil {
|
if err := option(&config); err != nil {
|
||||||
|
|||||||
@@ -602,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) {
|
func TestDeviceGetSetAndAction(t *testing.T) {
|
||||||
testServer := newDeviceTestServer(t, loadSpecFixture(t), []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}`,
|
`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ commits: none
|
|||||||
- 单次请求使用同一认证快照,因为签名、Cookie 和响应解密不能混用不同代 token。
|
- 单次请求使用同一认证快照,因为签名、Cookie 和响应解密不能混用不同代 token。
|
||||||
- 设备 spec 缓存同时兼容 Go 顶层标识和 Python `method` 格式,因为两个实现默认共享认证目录和缓存文件名。
|
- 设备 spec 缓存同时兼容 Go 顶层标识和 Python `method` 格式,因为两个实现默认共享认证目录和缓存文件名。
|
||||||
- 整数规格校验使用精确十进制/有理数比较,因为 `float64` 无法安全表达 `2^53` 以上整数。
|
- 整数规格校验使用精确十进制/有理数比较,因为 `float64` 无法安全表达 `2^53` 以上整数。
|
||||||
|
- 内存认证的同步持久化回调保留在登录串行区内,以确保并发登录/刷新严格有序且回调失败时不安装候选认证。把回调移到所有内部锁外会允许旧回调在新认证提交后覆盖外部状态;票据或 worker 若同步等待,调用者持有回调所需锁时仍会形成 ABBA。因此公共契约明确要求回调和 Client 调用遵守锁顺序,而不是承诺任意应用锁安全。
|
||||||
|
- 文件客户端默认把设备 spec 缓存在认证文件目录;内存客户端默认不缓存,只有显式 `WithDeviceCacheDir` 才写入 spec 文件。
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -56,6 +58,8 @@ auth, err := client.Login(ctx)
|
|||||||
- `gofmt -w *.go`
|
- `gofmt -w *.go`
|
||||||
- `go test -count=1 ./...`:通过
|
- `go test -count=1 ./...`:通过
|
||||||
- `go vet ./...`:通过
|
- `go vet ./...`:通过
|
||||||
|
- `go test ./... -count=20`:通过
|
||||||
|
- `go test -race ./...`:通过
|
||||||
- 独立规格审查和逐阶段代码质量审查均通过。
|
- 独立规格审查和逐阶段代码质量审查均通过。
|
||||||
- 测试覆盖固定加密向量、gzip/大小限制、QR 与静默刷新、本地加密 HTTP 端点、分页异常、精确大整数、MIoT HTML 变体、Python 缓存迁移和 context 取消。
|
- 测试覆盖固定加密向量、gzip/大小限制、QR 与静默刷新、本地加密 HTTP 端点、分页异常、精确大整数、MIoT HTML 变体、Python 缓存迁移和 context 取消。
|
||||||
|
|
||||||
|
|||||||
+19
-7
@@ -10,7 +10,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,18 +135,31 @@ func TestMemoryRefreshCallbackCanReadCurrentAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMemoryRefreshCallbackCanAcquireApplicationLock(t *testing.T) {
|
func TestMemoryRefreshCallbackCanPersistIndependently(t *testing.T) {
|
||||||
var persistenceMu sync.Mutex
|
persistencePath := filepath.Join(t.TempDir(), "persisted-auth.json")
|
||||||
client, _, server := newMemoryRefreshClient(t, func(AuthData) error {
|
client, _, server := newMemoryRefreshClient(t, func(authData AuthData) error {
|
||||||
persistenceMu.Lock()
|
payload, err := authData.MarshalJSON()
|
||||||
defer persistenceMu.Unlock()
|
if err != nil {
|
||||||
return nil
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(persistencePath, payload, 0o600)
|
||||||
})
|
})
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
if err := client.refreshToken(context.Background()); err != nil {
|
if err := client.refreshToken(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestMemoryRefreshPersistsGeneratedIdentity(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user