Compare commits
16
Commits
9dcdf9a227
..
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c4fbe089 | ||
|
|
a174a22bcb | ||
|
|
12ba947f9b | ||
|
|
6d7f08bedd | ||
|
|
a22457ce5f | ||
|
|
013056d715 | ||
|
|
3f05417b83 | ||
|
|
2cbac73f2a | ||
|
|
965e035052 | ||
|
|
503f7cd2b4 | ||
|
|
4ffd1b7eac | ||
|
|
8fea0c3c11 | ||
|
|
43c688af6f | ||
|
|
74486ebf08 | ||
|
|
88df3149be | ||
|
|
378ee865e7 |
@@ -1,4 +1,5 @@
|
|||||||
.mimocode/
|
.mimocode/
|
||||||
|
.worktrees/
|
||||||
mijia-api/
|
mijia-api/
|
||||||
auth.json
|
auth.json
|
||||||
miot-cache/
|
miot-cache/
|
||||||
|
|||||||
@@ -41,6 +41,49 @@ func main() {
|
|||||||
|
|
||||||
`auth.json` 包含 `serviceToken`、`passToken`、`ssecurity` 等敏感认证数据。库写入该文件时使用 `0600` 权限;请保持此权限,并且不要将该文件提交到版本库。
|
`auth.json` 包含 `serviceToken`、`passToken`、`ssecurity` 等敏感认证数据。库写入该文件时使用 `0600` 权限;请保持此权限,并且不要将该文件提交到版本库。
|
||||||
|
|
||||||
|
Web/GUI 应用可通过 `WithQRWriter` 接收登录输出,并在阻塞的 `Login` 等待期间实时展示给用户:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
mijia "git.misaka.ren/m1saka/mijia-go-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func streamLogin(ctx context.Context, renderLoginLine func(string)) error {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
client, err := mijia.NewClient("", mijia.WithQRWriter(writer))
|
||||||
|
if err != nil {
|
||||||
|
writer.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
loginDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := client.Login(ctx)
|
||||||
|
writer.CloseWithError(err)
|
||||||
|
loginDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
for scanner.Scan() {
|
||||||
|
renderLoginLine(scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
loginErr := <-loginDone
|
||||||
|
if loginErr != nil {
|
||||||
|
return loginErr
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Login` 会阻塞等待扫码,因此必须同步消费 Writer 输出。Writer 可能由另一个 goroutine 写入,不能无同步地并发读写 `bytes.Buffer`。Writer 接收的内容包含登录二维码 URL,属于敏感登录信息;调用方不得将其写入日志、监控事件或其他持久化记录。
|
||||||
|
|
||||||
## 底层 API
|
## 底层 API
|
||||||
|
|
||||||
以下示例展示设备、属性和 action 的直接调用。`GetDevices` 的 `homeID` 传空字符串时查询所有家庭。
|
以下示例展示设备、属性和 action 的直接调用。`GetDevices` 的 `homeID` 传空字符串时查询所有家庭。
|
||||||
@@ -96,6 +139,8 @@ _, err = device.RunAction(ctx, "toggle", nil)
|
|||||||
_ = value
|
_ = value
|
||||||
```
|
```
|
||||||
|
|
||||||
|
执行 action 前可通过 `device.Actions()["toggle"].Inputs` 检查可信 MIoT 描述中的参数类型、范围和值列表。
|
||||||
|
|
||||||
可通过实际导出的 `DeviceOption` 调整行为:
|
可通过实际导出的 `DeviceOption` 调整行为:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -134,4 +179,12 @@ case errors.As(err, &deviceErr):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Token 刷新失败且必须重新扫码授权时,可使用 `errors.Is` 稳定判断,同时仍可通过 `errors.As` 获取 `LoginError` 详情:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if errors.Is(err, mijia.ErrReauthenticationRequired) {
|
||||||
|
log.Print("认证已失效,请重新扫码授权")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
写属性和执行 action 时,对应错误类型为 `DeviceSetError` 和 `DeviceActionError`;设备选择还可能返回 `DeviceNotFoundError` 或 `MultipleDevicesFoundError`。
|
写属性和执行 action 时,对应错误类型为 `DeviceSetError` 和 `DeviceActionError`;设备选择还可能返回 `DeviceNotFoundError` 或 `MultipleDevicesFoundError`。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -84,6 +85,13 @@ func (data AuthData) complete() bool {
|
|||||||
return data.UA != "" && data.Ssecurity != "" && data.UserID != "" && data.CUserID != "" && data.ServiceToken != ""
|
return data.UA != "" && data.Ssecurity != "" && data.UserID != "" && data.CUserID != "" && data.ServiceToken != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (data AuthData) zero() bool {
|
||||||
|
return data.UA == "" && data.DeviceID == "" && data.PassO == "" && data.Psecurity == "" &&
|
||||||
|
data.Nonce == "" && data.Ssecurity == "" && data.PassToken == "" && data.UserID == "" &&
|
||||||
|
data.CUserID == "" && data.ServiceToken == "" && data.YetAnotherServiceToken == "" &&
|
||||||
|
data.ExpireTime == 0 && data.SaveTime == 0 && len(data.Extra) == 0
|
||||||
|
}
|
||||||
|
|
||||||
func (data AuthData) yetAnotherServiceToken() string {
|
func (data AuthData) yetAnotherServiceToken() string {
|
||||||
if data.YetAnotherServiceToken != "" {
|
if data.YetAnotherServiceToken != "" {
|
||||||
return data.YetAnotherServiceToken
|
return data.YetAnotherServiceToken
|
||||||
@@ -142,7 +150,10 @@ func (client *Client) loadAuthData() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) ensureIdentity() {
|
func (client *Client) ensureIdentity() {
|
||||||
client.updateAuthData(func(authData *AuthData) {
|
client.setAuthData(client.authDataWithIdentity(client.AuthData()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) authDataWithIdentity(authData AuthData) AuthData {
|
||||||
if authData.PassO == "" {
|
if authData.PassO == "" {
|
||||||
authData.PassO = randomString(16, "0123456789abcdef")
|
authData.PassO = randomString(16, "0123456789abcdef")
|
||||||
}
|
}
|
||||||
@@ -150,7 +161,7 @@ func (client *Client) ensureIdentity() {
|
|||||||
authData.DeviceID = randomString(16, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-")
|
authData.DeviceID = randomString(16, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-")
|
||||||
}
|
}
|
||||||
if authData.UA != "" {
|
if authData.UA != "" {
|
||||||
return
|
return authData
|
||||||
}
|
}
|
||||||
countryCode := "CN"
|
countryCode := "CN"
|
||||||
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
|
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
|
||||||
@@ -161,7 +172,7 @@ func (client *Client) ensureIdentity() {
|
|||||||
id3 := randomString(32, "0123456789ABCDEF")
|
id3 := randomString(32, "0123456789ABCDEF")
|
||||||
id4 := randomString(40, "0123456789ABCDEF")
|
id4 := randomString(40, "0123456789ABCDEF")
|
||||||
authData.UA = fmt.Sprintf("Android-15-11.0.701-Xiaomi-23046RP50C-OS2.0.212.0.VMYCNXM-%s-%s-%s-%s-SmartHome-MI_APP_STORE-%s|%s|%s-64", id1, countryCode, id3, id2, id1, id4, authData.PassO)
|
authData.UA = fmt.Sprintf("Android-15-11.0.701-Xiaomi-23046RP50C-OS2.0.212.0.VMYCNXM-%s-%s-%s-%s-SmartHome-MI_APP_STORE-%s|%s|%s-64", id1, countryCode, id3, id2, id1, id4, authData.PassO)
|
||||||
})
|
return authData
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomString(length int, alphabet string) string {
|
func randomString(length int, alphabet string) string {
|
||||||
@@ -176,9 +187,37 @@ func randomString(length int, alphabet string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) saveAuthData() error {
|
func (client *Client) saveAuthData() error {
|
||||||
authData := client.updateAuthData(func(authData *AuthData) {
|
authData := client.AuthData()
|
||||||
authData.SaveTime = time.Now().UnixMilli()
|
authData.SaveTime = time.Now().UnixMilli()
|
||||||
})
|
if err := client.writeAuthData(authData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client.setAuthData(authData)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) commitAuthData(authData AuthData) error {
|
||||||
|
if !authData.complete() {
|
||||||
|
return fmt.Errorf("incomplete auth data")
|
||||||
|
}
|
||||||
|
authData.SaveTime = time.Now().UnixMilli()
|
||||||
|
if client.authPath != "" {
|
||||||
|
if err := client.writeAuthData(authData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if client.authDataChanged != nil {
|
||||||
|
if err := client.authDataChanged(authData.clone()); err != nil {
|
||||||
|
return fmt.Errorf("persist changed auth data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.setAuthData(authData)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) writeAuthData(authData AuthData) error {
|
||||||
|
if client.authPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
payload, err := json.MarshalIndent(authData, "", " ")
|
payload, err := json.MarshalIndent(authData, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode auth data: %w", err)
|
return fmt.Errorf("encode auth data: %w", err)
|
||||||
@@ -251,6 +290,25 @@ type longPollData struct {
|
|||||||
|
|
||||||
type stringOrNumber string
|
type stringOrNumber string
|
||||||
|
|
||||||
|
type recordingWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *recordingWriter) Write(payload []byte) (int, error) {
|
||||||
|
if writer.err != nil {
|
||||||
|
return 0, writer.err
|
||||||
|
}
|
||||||
|
written, err := writer.w.Write(payload)
|
||||||
|
if err == nil && written < len(payload) {
|
||||||
|
err = io.ErrShortWrite
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writer.err = err
|
||||||
|
}
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
|
||||||
func (value *stringOrNumber) UnmarshalJSON(payload []byte) error {
|
func (value *stringOrNumber) UnmarshalJSON(payload []byte) error {
|
||||||
decoded, err := decodeStringOrNumber(payload)
|
decoded, err := decodeStringOrNumber(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -263,18 +321,19 @@ func (value *stringOrNumber) UnmarshalJSON(payload []byte) error {
|
|||||||
func (client *Client) Login(ctx context.Context) (AuthData, error) {
|
func (client *Client) Login(ctx context.Context) (AuthData, error) {
|
||||||
client.loginMu.Lock()
|
client.loginMu.Lock()
|
||||||
defer client.loginMu.Unlock()
|
defer client.loginMu.Unlock()
|
||||||
|
candidate := client.authDataWithIdentity(client.AuthData())
|
||||||
|
|
||||||
location, refreshed, err := client.getLocation(ctx)
|
location, refreshedAuthData, err := client.getLocation(ctx, candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuthData{}, err
|
return AuthData{}, err
|
||||||
}
|
}
|
||||||
if refreshed {
|
if refreshedAuthData != nil {
|
||||||
if err := client.saveAuthData(); err != nil {
|
if err := client.commitAuthData(*refreshedAuthData); err != nil {
|
||||||
return AuthData{}, err
|
return AuthData{}, err
|
||||||
}
|
}
|
||||||
return client.AuthData(), nil
|
return client.AuthData(), nil
|
||||||
}
|
}
|
||||||
loginData, err := client.getQRLoginData(ctx, location)
|
loginData, err := client.getQRLoginData(ctx, location, candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuthData{}, err
|
return AuthData{}, err
|
||||||
}
|
}
|
||||||
@@ -282,20 +341,28 @@ func (client *Client) Login(ctx context.Context) (AuthData, error) {
|
|||||||
if _, err := qr.Encode(loginData.LoginURL, qr.L); err != nil {
|
if _, err := qr.Encode(loginData.LoginURL, qr.L); err != nil {
|
||||||
return AuthData{}, fmt.Errorf("encode login QR code: %w", err)
|
return AuthData{}, fmt.Errorf("encode login QR code: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(client.qrWriter, "请使用米家APP扫描下方二维码\n%s\n", loginData.LoginURL)
|
writer := &recordingWriter{w: client.qrWriter}
|
||||||
qrterminal.GenerateHalfBlock(loginData.LoginURL, qrterminal.L, client.qrWriter)
|
if _, err := fmt.Fprintf(writer, "请使用米家APP扫描下方二维码\n%s\n", loginData.LoginURL); err != nil {
|
||||||
|
return AuthData{}, fmt.Errorf("write QR login output: %w", err)
|
||||||
|
}
|
||||||
|
qrterminal.GenerateHalfBlock(loginData.LoginURL, qrterminal.L, writer)
|
||||||
|
if writer.err != nil {
|
||||||
|
return AuthData{}, fmt.Errorf("write QR login output: %w", writer.err)
|
||||||
|
}
|
||||||
if loginData.QR != "" {
|
if loginData.QR != "" {
|
||||||
fmt.Fprintf(client.qrWriter, "二维码图片: %s\n", loginData.QR)
|
if _, err := fmt.Fprintf(writer, "二维码图片: %s\n", loginData.QR); err != nil {
|
||||||
|
return AuthData{}, fmt.Errorf("write QR login output: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return client.completeQRLogin(ctx, loginData)
|
}
|
||||||
|
return client.completeQRLogin(ctx, loginData, candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) getLocation(ctx context.Context) (url.Values, bool, error) {
|
func (client *Client) getLocation(ctx context.Context, authData AuthData) (url.Values, *AuthData, error) {
|
||||||
httpClient := client.newSession()
|
httpClient := client.newSession()
|
||||||
serviceURL, err := url.Parse(client.serviceLoginURL)
|
serviceURL, err := url.Parse(client.serviceLoginURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("parse service login URL: %w", err)
|
return nil, nil, fmt.Errorf("parse service login URL: %w", err)
|
||||||
}
|
}
|
||||||
query := serviceURL.Query()
|
query := serviceURL.Query()
|
||||||
query.Set("_json", "true")
|
query.Set("_json", "true")
|
||||||
@@ -304,51 +371,54 @@ func (client *Client) getLocation(ctx context.Context) (url.Values, bool, error)
|
|||||||
serviceURL.RawQuery = query.Encode()
|
serviceURL.RawQuery = query.Encode()
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceURL.String(), nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
client.setLoginHeaders(request, true)
|
client.setLoginHeaders(request, authData, true)
|
||||||
var data serviceLoginData
|
var data serviceLoginData
|
||||||
if err := client.doLoginRequestWithClient(httpClient, request, false, &data); err != nil {
|
if err := client.doLoginRequestWithClient(httpClient, request, false, &data); err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if data.Location == "" {
|
if data.Location == "" {
|
||||||
return nil, false, &LoginError{Code: data.Code, Message: "登录响应缺少 location"}
|
return nil, nil, &LoginError{Code: data.Code, Message: "登录响应缺少 location"}
|
||||||
}
|
}
|
||||||
if data.Code == 0 {
|
if data.Code == 0 {
|
||||||
refreshRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
|
refreshRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
client.setLoginHeaders(refreshRequest, false)
|
client.setLoginHeaders(refreshRequest, authData, false)
|
||||||
response, err := httpClient.Do(refreshRequest)
|
response, err := httpClient.Do(refreshRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("refresh login token: %w", err)
|
return nil, nil, fmt.Errorf("refresh login token: %w", err)
|
||||||
}
|
}
|
||||||
body, readErr := readHTTPResponse(response)
|
body, readErr := readHTTPResponse(response)
|
||||||
response.Body.Close()
|
response.Body.Close()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return nil, false, fmt.Errorf("read token refresh response: %w", readErr)
|
return nil, nil, fmt.Errorf("read token refresh response: %w", readErr)
|
||||||
}
|
}
|
||||||
if response.StatusCode == http.StatusOK && string(body) == "ok" {
|
if response.StatusCode != http.StatusOK {
|
||||||
candidate := client.AuthData()
|
return nil, nil, &LoginError{Code: response.StatusCode, Message: string(body)}
|
||||||
|
}
|
||||||
|
if string(body) != "ok" {
|
||||||
|
return nil, nil, &LoginError{Code: -1, Message: string(body)}
|
||||||
|
}
|
||||||
|
candidate := authData
|
||||||
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
||||||
candidate.Ssecurity = data.Ssecurity
|
candidate.Ssecurity = data.Ssecurity
|
||||||
if !serviceTokenReceived || !candidate.complete() {
|
if !serviceTokenReceived || !candidate.complete() {
|
||||||
return nil, false, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"}
|
return nil, nil, fmt.Errorf("%w: %w", ErrReauthenticationRequired, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"})
|
||||||
}
|
}
|
||||||
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||||
client.setAuthData(candidate)
|
return nil, &candidate, nil
|
||||||
return nil, true, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
locationURL, err := url.Parse(data.Location)
|
locationURL, err := url.Parse(data.Location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("parse login location: %w", err)
|
return nil, nil, fmt.Errorf("parse login location: %w", err)
|
||||||
}
|
}
|
||||||
return locationURL.Query(), false, nil
|
return locationURL.Query(), nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) getQRLoginData(ctx context.Context, location url.Values) (qrLoginData, error) {
|
func (client *Client) getQRLoginData(ctx context.Context, location url.Values, authData AuthData) (qrLoginData, error) {
|
||||||
location.Set("theme", "")
|
location.Set("theme", "")
|
||||||
location.Set("bizDeviceType", "")
|
location.Set("bizDeviceType", "")
|
||||||
location.Set("_hasLogo", "false")
|
location.Set("_hasLogo", "false")
|
||||||
@@ -363,7 +433,7 @@ func (client *Client) getQRLoginData(ctx context.Context, location url.Values) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return qrLoginData{}, err
|
return qrLoginData{}, err
|
||||||
}
|
}
|
||||||
client.setLoginHeaders(request, false)
|
client.setLoginHeaders(request, authData, false)
|
||||||
var data qrLoginData
|
var data qrLoginData
|
||||||
if err := client.doLoginRequest(request, true, &data); err != nil {
|
if err := client.doLoginRequest(request, true, &data); err != nil {
|
||||||
return qrLoginData{}, err
|
return qrLoginData{}, err
|
||||||
@@ -374,7 +444,7 @@ func (client *Client) getQRLoginData(ctx context.Context, location url.Values) (
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData) (AuthData, error) {
|
func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData, authData AuthData) (AuthData, error) {
|
||||||
pollContext, cancel := context.WithTimeout(ctx, qrLoginTimeout)
|
pollContext, cancel := context.WithTimeout(ctx, qrLoginTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
httpClient := client.newSession()
|
httpClient := client.newSession()
|
||||||
@@ -382,7 +452,7 @@ func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AuthData{}, err
|
return AuthData{}, err
|
||||||
}
|
}
|
||||||
client.setLoginHeaders(request, false)
|
client.setLoginHeaders(request, authData, false)
|
||||||
var data longPollData
|
var data longPollData
|
||||||
if err := client.doLoginRequestWithClient(httpClient, request, true, &data); err != nil {
|
if err := client.doLoginRequestWithClient(httpClient, request, true, &data); err != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
@@ -394,7 +464,7 @@ func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AuthData{}, err
|
return AuthData{}, err
|
||||||
}
|
}
|
||||||
client.setLoginHeaders(callback, false)
|
client.setLoginHeaders(callback, authData, false)
|
||||||
response, err := httpClient.Do(callback)
|
response, err := httpClient.Do(callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuthData{}, fmt.Errorf("complete login callback: %w", err)
|
return AuthData{}, fmt.Errorf("complete login callback: %w", err)
|
||||||
@@ -407,7 +477,7 @@ func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData
|
|||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
return AuthData{}, &LoginError{Code: response.StatusCode, Message: "登录回调失败"}
|
return AuthData{}, &LoginError{Code: response.StatusCode, Message: "登录回调失败"}
|
||||||
}
|
}
|
||||||
candidate := client.AuthData()
|
candidate := authData
|
||||||
candidate.Ssecurity = ""
|
candidate.Ssecurity = ""
|
||||||
candidate.UserID = ""
|
candidate.UserID = ""
|
||||||
candidate.CUserID = ""
|
candidate.CUserID = ""
|
||||||
@@ -423,8 +493,7 @@ func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData
|
|||||||
return AuthData{}, &LoginError{Code: -1, Message: "登录回调认证信息不完整"}
|
return AuthData{}, &LoginError{Code: -1, Message: "登录回调认证信息不完整"}
|
||||||
}
|
}
|
||||||
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||||
client.setAuthData(candidate)
|
if err := client.commitAuthData(candidate); err != nil {
|
||||||
if err := client.saveAuthData(); err != nil {
|
|
||||||
return AuthData{}, err
|
return AuthData{}, err
|
||||||
}
|
}
|
||||||
return client.AuthData(), nil
|
return client.AuthData(), nil
|
||||||
@@ -464,8 +533,7 @@ func (client *Client) doLoginRequestWithClient(httpClient *http.Client, request
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) setLoginHeaders(request *http.Request, withCookies bool) {
|
func (client *Client) setLoginHeaders(request *http.Request, authData AuthData, withCookies bool) {
|
||||||
authData := client.AuthData()
|
|
||||||
request.Header.Set("User-Agent", authData.UA)
|
request.Header.Set("User-Agent", authData.UA)
|
||||||
request.Header.Set("Connection", "keep-alive")
|
request.Header.Set("Connection", "keep-alive")
|
||||||
request.Header.Set("Accept-Encoding", "gzip")
|
request.Header.Set("Accept-Encoding", "gzip")
|
||||||
@@ -517,14 +585,14 @@ func (client *Client) refreshToken(ctx context.Context) error {
|
|||||||
if available {
|
if available {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, refreshed, err := client.getLocation(ctx)
|
_, refreshedAuthData, err := client.getLocation(ctx, client.AuthData())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !refreshed {
|
if refreshedAuthData == nil {
|
||||||
return &LoginError{Code: -1, Message: "刷新Token失败,请重新登录"}
|
return fmt.Errorf("%w: %w", ErrReauthenticationRequired, &LoginError{Code: -1, Message: "刷新Token失败,请重新登录"})
|
||||||
}
|
}
|
||||||
if err := client.saveAuthData(); err != nil {
|
if err := client.commitAuthData(*refreshedAuthData); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+255
-1
@@ -12,10 +12,83 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mdp/qrterminal/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errQRWriterFailed = errors.New("QR writer failed")
|
||||||
|
|
||||||
|
type failThenBlockWriter struct {
|
||||||
|
calls atomic.Int32
|
||||||
|
failOnCall int32
|
||||||
|
blockWrites chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type shortWriter struct {
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *shortWriter) Write(payload []byte) (int, error) {
|
||||||
|
writer.calls.Add(1)
|
||||||
|
return len(payload) - 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *failThenBlockWriter) Write(payload []byte) (int, error) {
|
||||||
|
call := writer.calls.Add(1)
|
||||||
|
if call == writer.failOnCall {
|
||||||
|
return 0, errQRWriterFailed
|
||||||
|
}
|
||||||
|
if call > writer.failOnCall {
|
||||||
|
<-writer.blockWrites
|
||||||
|
}
|
||||||
|
return len(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordingWriterStopsAfterFirstError(t *testing.T) {
|
||||||
|
underlying := &failThenBlockWriter{failOnCall: 1, blockWrites: make(chan struct{})}
|
||||||
|
writer := &recordingWriter{w: underlying}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
qrterminal.GenerateHalfBlock("https://qr.example/login", qrterminal.L, writer)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
close(underlying.blockWrites)
|
||||||
|
<-done
|
||||||
|
t.Fatal("GenerateHalfBlock blocked after the first write failure")
|
||||||
|
}
|
||||||
|
if calls := underlying.calls.Load(); calls != 1 {
|
||||||
|
t.Fatalf("underlying Write calls = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
if !errors.Is(writer.err, errQRWriterFailed) {
|
||||||
|
t.Fatalf("recorded error = %v, want %v", writer.err, errQRWriterFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordingWriterRecordsShortWriteAndStops(t *testing.T) {
|
||||||
|
underlying := &shortWriter{}
|
||||||
|
writer := &recordingWriter{w: underlying}
|
||||||
|
payload := []byte("payload")
|
||||||
|
|
||||||
|
written, err := writer.Write(payload)
|
||||||
|
if written != len(payload)-1 || !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("first Write() = %d, %v, want %d, %v", written, err, len(payload)-1, io.ErrShortWrite)
|
||||||
|
}
|
||||||
|
written, err = writer.Write(payload)
|
||||||
|
if written != 0 || !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("second Write() = %d, %v, want 0, %v", written, err, io.ErrShortWrite)
|
||||||
|
}
|
||||||
|
if calls := underlying.calls.Load(); calls != 1 {
|
||||||
|
t.Fatalf("underlying Write calls = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseServiceResponse(t *testing.T) {
|
func TestParseServiceResponse(t *testing.T) {
|
||||||
var result struct {
|
var result struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
@@ -128,6 +201,9 @@ func TestRefreshRejectsMissingNewServiceTokenWithoutSaving(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = client.refreshToken(context.Background())
|
err = client.refreshToken(context.Background())
|
||||||
|
if !errors.Is(err, ErrReauthenticationRequired) {
|
||||||
|
t.Fatalf("refreshToken() error = %v, want ErrReauthenticationRequired", err)
|
||||||
|
}
|
||||||
var loginErr *LoginError
|
var loginErr *LoginError
|
||||||
if !errors.As(err, &loginErr) {
|
if !errors.As(err, &loginErr) {
|
||||||
t.Fatalf("refreshToken() error = %v, want LoginError", err)
|
t.Fatalf("refreshToken() error = %v, want LoginError", err)
|
||||||
@@ -144,6 +220,104 @@ func TestRefreshRejectsMissingNewServiceTokenWithoutSaving(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefreshWithoutNewTokenRequiresReauthentication(t *testing.T) {
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/v2/message/v2/check_new_msg":
|
||||||
|
_, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`)
|
||||||
|
case "/serviceLogin":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/qr"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.availabilityValid = false
|
||||||
|
|
||||||
|
err := client.refreshToken(context.Background())
|
||||||
|
if !errors.Is(err, ErrReauthenticationRequired) {
|
||||||
|
t.Fatalf("refreshToken() error = %v, want ErrReauthenticationRequired", err)
|
||||||
|
}
|
||||||
|
var loginErr *LoginError
|
||||||
|
if !errors.As(err, &loginErr) {
|
||||||
|
t.Fatalf("refreshToken() error = %v, want LoginError", err)
|
||||||
|
}
|
||||||
|
if loginErr.Code != -1 || loginErr.Message != "刷新Token失败,请重新登录" {
|
||||||
|
t.Fatalf("LoginError = %#v", loginErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCallbackFailuresDoNotRequireReauthentication(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{name: "server error", statusCode: http.StatusServiceUnavailable, body: "temporarily unavailable", wantCode: http.StatusServiceUnavailable},
|
||||||
|
{name: "unexpected body", statusCode: http.StatusOK, body: "pending", wantCode: -1},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/v2/message/v2/check_new_msg":
|
||||||
|
_, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`)
|
||||||
|
case "/serviceLogin":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"location":"`+server.URL+`/refresh","ssecurity":"`+testSsecurity+`"}`)
|
||||||
|
case "/refresh":
|
||||||
|
writer.WriteHeader(test.statusCode)
|
||||||
|
_, _ = io.WriteString(writer, test.body)
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.availabilityValid = false
|
||||||
|
|
||||||
|
err := client.refreshToken(context.Background())
|
||||||
|
if errors.Is(err, ErrReauthenticationRequired) {
|
||||||
|
t.Fatalf("refreshToken() error = %v, do not want ErrReauthenticationRequired", err)
|
||||||
|
}
|
||||||
|
var loginErr *LoginError
|
||||||
|
if !errors.As(err, &loginErr) {
|
||||||
|
t.Fatalf("refreshToken() error = %v, want LoginError", err)
|
||||||
|
}
|
||||||
|
if loginErr.Code != test.wantCode || !strings.Contains(loginErr.Message, test.body) {
|
||||||
|
t.Fatalf("LoginError = %#v, want code %d containing %q", loginErr, test.wantCode, test.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQRLoginTimeoutDoesNotRequireReauthentication(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := testClient(t, http.DefaultClient)
|
||||||
|
_, err := client.completeQRLogin(ctx, qrLoginData{LP: "https://example.invalid/long-poll"}, client.AuthData())
|
||||||
|
var loginErr *LoginError
|
||||||
|
if !errors.As(err, &loginErr) {
|
||||||
|
t.Fatalf("completeQRLogin() error = %v, want LoginError", err)
|
||||||
|
}
|
||||||
|
if loginErr.Code != -1 {
|
||||||
|
t.Fatalf("LoginError.Code = %d, want -1", loginErr.Code)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrReauthenticationRequired) {
|
||||||
|
t.Fatalf("completeQRLogin() error = %v, do not want ErrReauthenticationRequired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthDataReturnsDeepCopy(t *testing.T) {
|
func TestAuthDataReturnsDeepCopy(t *testing.T) {
|
||||||
client := testClient(t, http.DefaultClient)
|
client := testClient(t, http.DefaultClient)
|
||||||
client.updateAuthData(func(authData *AuthData) { authData.Extra = map[string]string{"cookie": "original"} })
|
client.updateAuthData(func(authData *AuthData) { authData.Extra = map[string]string{"cookie": "original"} })
|
||||||
@@ -244,6 +418,86 @@ func TestLoginQRCoreFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginReturnsQROutputErrorBeforeLongPoll(t *testing.T) {
|
||||||
|
var longPollRequests atomic.Int32
|
||||||
|
qrWriter := &failThenBlockWriter{failOnCall: 2, blockWrites: make(chan struct{})}
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/serviceLogin":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/prepare"}`)
|
||||||
|
case "/loginUrl":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","qr":"https://qr.example/image","lp":"`+server.URL+`/lp"}`)
|
||||||
|
case "/lp":
|
||||||
|
longPollRequests.Add(1)
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()), WithQRWriter(qrWriter))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.loginURL = server.URL + "/loginUrl"
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, loginErr := client.Login(context.Background())
|
||||||
|
done <- loginErr
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err = <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
close(qrWriter.blockWrites)
|
||||||
|
<-done
|
||||||
|
t.Fatal("Login() blocked after QR output failed")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errQRWriterFailed) {
|
||||||
|
t.Fatalf("Login() error = %v, want %v", err, errQRWriterFailed)
|
||||||
|
}
|
||||||
|
if requests := longPollRequests.Load(); requests != 0 {
|
||||||
|
t.Fatalf("long-poll requests = %d, want 0", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginReturnsShortQROutputErrorBeforeLongPoll(t *testing.T) {
|
||||||
|
var longPollRequests atomic.Int32
|
||||||
|
qrWriter := &shortWriter{}
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/serviceLogin":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/prepare"}`)
|
||||||
|
case "/loginUrl":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","lp":"`+server.URL+`/lp"}`)
|
||||||
|
case "/lp":
|
||||||
|
longPollRequests.Add(1)
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()), WithQRWriter(qrWriter))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.loginURL = server.URL + "/loginUrl"
|
||||||
|
|
||||||
|
_, err = client.Login(context.Background())
|
||||||
|
if !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("Login() error = %v, want %v", err, io.ErrShortWrite)
|
||||||
|
}
|
||||||
|
if requests := longPollRequests.Load(); requests != 0 {
|
||||||
|
t.Fatalf("long-poll requests = %d, want 0", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQRLoginRejectsIncompleteCallbackWithoutSaving(t *testing.T) {
|
func TestQRLoginRejectsIncompleteCallbackWithoutSaving(t *testing.T) {
|
||||||
var server *httptest.Server
|
var server *httptest.Server
|
||||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -259,7 +513,7 @@ func TestQRLoginRejectsIncompleteCallbackWithoutSaving(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := testClient(t, server.Client())
|
client := testClient(t, server.Client())
|
||||||
_, err := client.completeQRLogin(context.Background(), qrLoginData{LP: server.URL + "/lp"})
|
_, err := client.completeQRLogin(context.Background(), qrLoginData{LP: server.URL + "/lp"}, client.AuthData())
|
||||||
var loginErr *LoginError
|
var loginErr *LoginError
|
||||||
if !errors.As(err, &loginErr) {
|
if !errors.As(err, &loginErr) {
|
||||||
t.Fatalf("completeQRLogin() error = %v, want LoginError", err)
|
t.Fatalf("completeQRLogin() error = %v, want LoginError", err)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,7 +24,10 @@ const (
|
|||||||
availabilityTTL = 60 * time.Second
|
availabilityTTL = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Option func(*Client) error
|
type ClientOption func(*Client) error
|
||||||
|
|
||||||
|
// Option is kept as an alias for compatibility with existing callers.
|
||||||
|
type Option = ClientOption
|
||||||
|
|
||||||
// WithHTTPClient configures the HTTP transport used by the client.
|
// WithHTTPClient configures the HTTP transport used by the client.
|
||||||
func WithHTTPClient(httpClient *http.Client) Option {
|
func WithHTTPClient(httpClient *http.Client) Option {
|
||||||
@@ -36,8 +40,28 @@ func WithHTTPClient(httpClient *http.Client) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithQRWriter configures where QR login output is written.
|
||||||
|
func WithQRWriter(writer io.Writer) Option {
|
||||||
|
return func(client *Client) error {
|
||||||
|
isNil := writer == nil
|
||||||
|
if !isNil {
|
||||||
|
value := reflect.ValueOf(writer)
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer:
|
||||||
|
isNil = value.IsNil()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isNil {
|
||||||
|
return fmt.Errorf("QR writer must not be nil")
|
||||||
|
}
|
||||||
|
client.qrWriter = writer
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
authPath string
|
authPath string
|
||||||
|
authDataChanged func(AuthData) error
|
||||||
authMu sync.RWMutex
|
authMu sync.RWMutex
|
||||||
authData AuthData
|
authData AuthData
|
||||||
loginMu sync.Mutex
|
loginMu sync.Mutex
|
||||||
@@ -60,8 +84,37 @@ func NewClient(authPath string, options ...Option) (*Client, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
client, err := newClient(options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client.authPath = resolvedPath
|
||||||
|
if err := client.loadAuthData(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client.ensureIdentity()
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientWithAuthData creates a client whose authentication state is kept in memory.
|
||||||
|
// Zero AuthData is accepted for QR login; non-zero AuthData must be complete.
|
||||||
|
func NewClientWithAuthData(authData AuthData, options ...ClientOption) (*Client, error) {
|
||||||
|
if !authData.zero() && !authData.complete() {
|
||||||
|
return nil, fmt.Errorf("incomplete auth data")
|
||||||
|
}
|
||||||
|
client, err := newClient(options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !authData.zero() {
|
||||||
|
authData = client.authDataWithIdentity(authData)
|
||||||
|
}
|
||||||
|
client.setAuthData(authData)
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(options ...ClientOption) (*Client, error) {
|
||||||
client := &Client{
|
client := &Client{
|
||||||
authPath: resolvedPath,
|
|
||||||
httpClient: http.DefaultClient,
|
httpClient: http.DefaultClient,
|
||||||
baseURL: defaultBaseURL,
|
baseURL: defaultBaseURL,
|
||||||
loginURL: defaultLoginURL,
|
loginURL: defaultLoginURL,
|
||||||
@@ -77,14 +130,25 @@ func NewClient(authPath string, options ...Option) (*Client, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := client.loadAuthData(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
client.ensureIdentity()
|
|
||||||
client.initSession()
|
client.initSession()
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func WithAuthDataChanged(callback func(AuthData) error) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
if callback == nil {
|
||||||
|
return fmt.Errorf("auth data changed callback must not be nil")
|
||||||
|
}
|
||||||
|
client.authDataChanged = callback
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveAuthPath(authPath string) (string, error) {
|
func resolveAuthPath(authPath string) (string, error) {
|
||||||
if authPath == "" {
|
if authPath == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
@@ -181,6 +245,10 @@ func (client *Client) request(ctx context.Context, uri string, data any, refresh
|
|||||||
return nil, fmt.Errorf("read API response: %w", err)
|
return nil, fmt.Errorf("read API response: %w", err)
|
||||||
}
|
}
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
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)))
|
return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
|
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
|
||||||
@@ -214,7 +282,11 @@ func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, e
|
|||||||
if message == "" {
|
if message == "" {
|
||||||
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
|
return envelope.Result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,61 @@ import (
|
|||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestWithQRWriter(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
client, err := NewClient(t.TempDir(), WithQRWriter(&output))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if client.qrWriter != &output {
|
||||||
|
t.Fatalf("qrWriter = %v, want custom writer", client.qrWriter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithQRWriterRejectsNil(t *testing.T) {
|
||||||
|
_, err := NewClient(t.TempDir(), WithQRWriter(nil))
|
||||||
|
if err == nil || err.Error() != "QR writer must not be nil" {
|
||||||
|
t.Fatalf("error = %v, want QR writer must not be nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithQRWriterRejectsTypedNil(t *testing.T) {
|
||||||
|
var output *bytes.Buffer
|
||||||
|
_, err := NewClient(t.TempDir(), WithQRWriter(output))
|
||||||
|
if err == nil || err.Error() != "QR writer must not be nil" {
|
||||||
|
t.Fatalf("error = %v, want QR writer must not be nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithQRWriterAcceptsStructWriter(t *testing.T) {
|
||||||
|
if _, err := NewClient(t.TempDir(), WithQRWriter(structWriter{})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type structWriter struct{}
|
||||||
|
|
||||||
|
func (structWriter) Write(data []byte) (int, error) {
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultQRWriterIsStdout(t *testing.T) {
|
||||||
|
client, err := NewClient(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if client.qrWriter != os.Stdout {
|
||||||
|
t.Fatalf("qrWriter = %v, want os.Stdout", client.qrWriter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRequestEncryptedPostAndPlainResponse(t *testing.T) {
|
func TestRequestEncryptedPostAndPlainResponse(t *testing.T) {
|
||||||
var received url.Values
|
var received url.Values
|
||||||
handlerErrors := make(chan error, 1)
|
handlerErrors := make(chan error, 1)
|
||||||
@@ -182,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) {
|
func TestRequestRejectsOversizedRawResponse(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
|
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
deviceSpecUA = "mijiaAPI/4.1.2"
|
deviceSpecUA = "mijiaAPI/4.1.2"
|
||||||
deviceSpecMaxSize = 8 << 20
|
deviceSpecMaxSize = 8 << 20
|
||||||
|
deviceCacheVersion = 2
|
||||||
|
deviceGetBatchSize = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
var deviceSpecURL = "https://home.miot-spec.com/spec/"
|
var deviceSpecURL = "https://home.miot-spec.com/spec/"
|
||||||
@@ -54,6 +56,7 @@ type ActionSpec struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
SIID int `json:"siid"`
|
SIID int `json:"siid"`
|
||||||
AIID int `json:"aiid"`
|
AIID int `json:"aiid"`
|
||||||
|
Inputs []PropertySpec `json:"inputs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeviceSelector struct {
|
type DeviceSelector struct {
|
||||||
@@ -160,7 +163,7 @@ func fetchDeviceInfo(ctx context.Context, httpClient *http.Client, model string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err)
|
return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err)
|
||||||
}
|
}
|
||||||
if err := validateDeviceInfo(info, model); err != nil {
|
if err := validateDeviceInfo(&info, model); err != nil {
|
||||||
return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err)
|
return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err)
|
||||||
}
|
}
|
||||||
return info, nil
|
return info, nil
|
||||||
@@ -230,6 +233,7 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) {
|
|||||||
IID int `json:"iid"`
|
IID int `json:"iid"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Inputs []int `json:"in"`
|
||||||
} `json:"actions"`
|
} `json:"actions"`
|
||||||
} `json:"services"`
|
} `json:"services"`
|
||||||
} `json:"tree"`
|
} `json:"tree"`
|
||||||
@@ -245,6 +249,7 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) {
|
|||||||
propertyNames := make(map[string]struct{})
|
propertyNames := make(map[string]struct{})
|
||||||
actionNames := make(map[string]struct{})
|
actionNames := make(map[string]struct{})
|
||||||
for _, service := range page.Props.Tree.Services {
|
for _, service := range page.Props.Tree.Services {
|
||||||
|
serviceProperties := make(map[int]PropertySpec, len(service.Properties))
|
||||||
for _, property := range service.Properties {
|
for _, property := range service.Properties {
|
||||||
propertyType := property.Format
|
propertyType := property.Format
|
||||||
if strings.HasPrefix(propertyType, "int") {
|
if strings.HasPrefix(propertyType, "int") {
|
||||||
@@ -265,7 +270,9 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) {
|
|||||||
for index, item := range property.ValueList {
|
for index, item := range property.ValueList {
|
||||||
valueList[index] = ValueListItem{Value: item.Value, Description: item.Description, DescZhCN: page.Props.I18n.ZhCN[item.I18nKey]}
|
valueList[index] = ValueListItem{Value: item.Value, Description: item.Description, DescZhCN: page.Props.I18n.ZhCN[item.I18nKey]}
|
||||||
}
|
}
|
||||||
info.Properties = append(info.Properties, PropertySpec{Name: name, Description: description, Type: propertyType, RW: accessString(property.Access), Range: property.ValueRange, ValueList: valueList, SIID: service.IID, PIID: property.IID})
|
propertySpec := PropertySpec{Name: name, Description: description, Type: propertyType, RW: accessString(property.Access), Range: property.ValueRange, ValueList: valueList, SIID: service.IID, PIID: property.IID}
|
||||||
|
serviceProperties[property.IID] = propertySpec
|
||||||
|
info.Properties = append(info.Properties, propertySpec)
|
||||||
}
|
}
|
||||||
for _, action := range service.Actions {
|
for _, action := range service.Actions {
|
||||||
name := action.Type
|
name := action.Type
|
||||||
@@ -274,7 +281,15 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) {
|
|||||||
}
|
}
|
||||||
actionNames[name] = struct{}{}
|
actionNames[name] = struct{}{}
|
||||||
description := localizedDescription(action.Description, page.Props.I18n.ZhCN[fmt.Sprintf("service:%03d:action:%03d", service.IID, action.IID)])
|
description := localizedDescription(action.Description, page.Props.I18n.ZhCN[fmt.Sprintf("service:%03d:action:%03d", service.IID, action.IID)])
|
||||||
info.Actions = append(info.Actions, ActionSpec{Name: name, Description: description, SIID: service.IID, AIID: action.IID})
|
inputs := make([]PropertySpec, len(action.Inputs))
|
||||||
|
for index, propertyIID := range action.Inputs {
|
||||||
|
propertySpec, ok := serviceProperties[propertyIID]
|
||||||
|
if !ok {
|
||||||
|
return DeviceInfo{}, fmt.Errorf("action %q references unknown property IID %d", name, propertyIID)
|
||||||
|
}
|
||||||
|
inputs[index] = clonePropertySpec(propertySpec)
|
||||||
|
}
|
||||||
|
info.Actions = append(info.Actions, ActionSpec{Name: name, Description: description, SIID: service.IID, AIID: action.IID, Inputs: inputs})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return info, nil
|
return info, nil
|
||||||
@@ -431,7 +446,7 @@ func writeDeviceInfoCache(path string, info DeviceInfo) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("create device info cache directory: %w", err)
|
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 {
|
for _, property := range info.Properties {
|
||||||
cache.Properties = append(cache.Properties, propertyCache{
|
cache.Properties = append(cache.Properties, propertyCache{
|
||||||
PropertySpec: property,
|
PropertySpec: property,
|
||||||
@@ -492,6 +507,7 @@ type actionCache struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deviceInfoCache struct {
|
type deviceInfoCache struct {
|
||||||
|
Version int `json:"version"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Properties []propertyCache `json:"properties"`
|
Properties []propertyCache `json:"properties"`
|
||||||
@@ -511,6 +527,12 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) {
|
|||||||
}
|
}
|
||||||
return DeviceInfo{}, fmt.Errorf("trailing content: %w", err)
|
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}
|
info := DeviceInfo{Name: cache.Name, Model: cache.Model}
|
||||||
for _, cachedProperty := range cache.Properties {
|
for _, cachedProperty := range cache.Properties {
|
||||||
@@ -533,26 +555,42 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) {
|
|||||||
}
|
}
|
||||||
info.Actions = append(info.Actions, action)
|
info.Actions = append(info.Actions, action)
|
||||||
}
|
}
|
||||||
if err := validateDeviceInfo(info, model); err != nil {
|
if err := validateDeviceInfo(&info, model); err != nil {
|
||||||
return DeviceInfo{}, err
|
return DeviceInfo{}, err
|
||||||
}
|
}
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDeviceInfo(info DeviceInfo, model string) error {
|
func validateDeviceInfo(info *DeviceInfo, model string) error {
|
||||||
if info.Model == "" || info.Model != model {
|
if info.Model == "" || info.Model != model {
|
||||||
return fmt.Errorf("model %q does not match requested model %q", info.Model, model)
|
return fmt.Errorf("model %q does not match requested model %q", info.Model, model)
|
||||||
}
|
}
|
||||||
|
type propertyID struct {
|
||||||
|
siid int
|
||||||
|
piid int
|
||||||
|
}
|
||||||
|
properties := make(map[propertyID]PropertySpec, len(info.Properties))
|
||||||
for index, property := range info.Properties {
|
for index, property := range info.Properties {
|
||||||
if strings.TrimSpace(property.Name) == "" || !validPropertyType(property.Type) ||
|
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 != "r" && property.RW != "w" && property.RW != "rw") || property.SIID <= 0 || property.PIID <= 0 {
|
||||||
return fmt.Errorf("property %d is invalid", index)
|
return fmt.Errorf("property %d is invalid", index)
|
||||||
}
|
}
|
||||||
|
properties[propertyID{siid: property.SIID, piid: property.PIID}] = property
|
||||||
}
|
}
|
||||||
for index, action := range info.Actions {
|
for index, action := range info.Actions {
|
||||||
if strings.TrimSpace(action.Name) == "" || action.SIID <= 0 || action.AIID <= 0 {
|
if strings.TrimSpace(action.Name) == "" || action.SIID <= 0 || action.AIID <= 0 {
|
||||||
return fmt.Errorf("action %d is invalid", index)
|
return fmt.Errorf("action %d is invalid", index)
|
||||||
}
|
}
|
||||||
|
for inputIndex, input := range action.Inputs {
|
||||||
|
if !validPropertyType(input.Type) || input.SIID <= 0 || input.PIID <= 0 || input.SIID != action.SIID {
|
||||||
|
return fmt.Errorf("action %d input %d is invalid", index, inputIndex)
|
||||||
|
}
|
||||||
|
property, exists := properties[propertyID{siid: input.SIID, piid: input.PIID}]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("action %d input %d does not reference a property", index, inputIndex)
|
||||||
|
}
|
||||||
|
info.Actions[index].Inputs[inputIndex] = property
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -620,9 +658,7 @@ func NewDevice(ctx context.Context, client *Client, selector DeviceSelector, opt
|
|||||||
func (device *Device) Properties() map[string]PropertySpec {
|
func (device *Device) Properties() map[string]PropertySpec {
|
||||||
properties := make(map[string]PropertySpec, len(device.properties))
|
properties := make(map[string]PropertySpec, len(device.properties))
|
||||||
for name, property := range device.properties {
|
for name, property := range device.properties {
|
||||||
property.Range = append([]json.Number(nil), property.Range...)
|
properties[name] = clonePropertySpec(property)
|
||||||
property.ValueList = append([]ValueListItem(nil), property.ValueList...)
|
|
||||||
properties[name] = property
|
|
||||||
}
|
}
|
||||||
return properties
|
return properties
|
||||||
}
|
}
|
||||||
@@ -630,11 +666,22 @@ func (device *Device) Properties() map[string]PropertySpec {
|
|||||||
func (device *Device) Actions() map[string]ActionSpec {
|
func (device *Device) Actions() map[string]ActionSpec {
|
||||||
actions := make(map[string]ActionSpec, len(device.actions))
|
actions := make(map[string]ActionSpec, len(device.actions))
|
||||||
for name, action := range device.actions {
|
for name, action := range device.actions {
|
||||||
|
inputs := make([]PropertySpec, len(action.Inputs))
|
||||||
|
for index, input := range action.Inputs {
|
||||||
|
inputs[index] = clonePropertySpec(input)
|
||||||
|
}
|
||||||
|
action.Inputs = inputs
|
||||||
actions[name] = action
|
actions[name] = action
|
||||||
}
|
}
|
||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clonePropertySpec(property PropertySpec) PropertySpec {
|
||||||
|
property.Range = append([]json.Number(nil), property.Range...)
|
||||||
|
property.ValueList = append([]ValueListItem(nil), property.ValueList...)
|
||||||
|
return property
|
||||||
|
}
|
||||||
|
|
||||||
func (device *Device) Get(ctx context.Context, name string) (any, error) {
|
func (device *Device) Get(ctx context.Context, name string) (any, error) {
|
||||||
property, ok := device.properties[name]
|
property, ok := device.properties[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -659,6 +706,84 @@ func (device *Device) Get(ctx context.Context, name string) (any, error) {
|
|||||||
return results[0].Value, nil
|
return results[0].Value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (device *Device) GetMany(ctx context.Context, names []string) ([]DevicePropertyResult, error) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return []DevicePropertyResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type propertyIdentity struct {
|
||||||
|
did string
|
||||||
|
siid int
|
||||||
|
piid int
|
||||||
|
}
|
||||||
|
requests := make([]PropertyRequest, len(names))
|
||||||
|
seenNames := make(map[string]struct{}, len(names))
|
||||||
|
seenIdentities := make(map[propertyIdentity]struct{}, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
if _, duplicate := seenNames[name]; duplicate {
|
||||||
|
return nil, fmt.Errorf("重复的属性: %s", name)
|
||||||
|
}
|
||||||
|
seenNames[name] = struct{}{}
|
||||||
|
property, ok := device.properties[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("不支持的属性: %s", name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(property.RW, "r") {
|
||||||
|
return nil, fmt.Errorf("属性 %s 不可读取", name)
|
||||||
|
}
|
||||||
|
identity := propertyIdentity{did: device.DID, siid: property.SIID, piid: property.PIID}
|
||||||
|
if _, duplicate := seenIdentities[identity]; duplicate {
|
||||||
|
return nil, fmt.Errorf("属性 %s 与其他请求使用重复的设备属性 identity", name)
|
||||||
|
}
|
||||||
|
seenIdentities[identity] = struct{}{}
|
||||||
|
requests[index] = PropertyRequest{DID: identity.did, SIID: identity.siid, PIID: identity.piid}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]DevicePropertyResult, len(names))
|
||||||
|
for start := 0; start < len(requests); start += deviceGetBatchSize {
|
||||||
|
end := min(start+deviceGetBatchSize, len(requests))
|
||||||
|
chunkResults, err := device.client.GetProperties(ctx, requests[start:end])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byIdentity := make(map[propertyIdentity]PropertyResult, len(chunkResults))
|
||||||
|
duplicateIdentities := make(map[propertyIdentity]struct{})
|
||||||
|
requested := make(map[propertyIdentity]struct{}, end-start)
|
||||||
|
for _, request := range requests[start:end] {
|
||||||
|
requested[propertyIdentity{did: request.DID, siid: request.SIID, piid: request.PIID}] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, result := range chunkResults {
|
||||||
|
identity := propertyIdentity{did: result.DID, siid: result.SIID, piid: result.PIID}
|
||||||
|
if _, expected := requested[identity]; !expected {
|
||||||
|
return nil, fmt.Errorf("get properties protocol error: unexpected identity (%s,%d,%d)", result.DID, result.SIID, result.PIID)
|
||||||
|
}
|
||||||
|
if _, duplicate := byIdentity[identity]; duplicate {
|
||||||
|
duplicateIdentities[identity] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byIdentity[identity] = result
|
||||||
|
}
|
||||||
|
for index, request := range requests[start:end] {
|
||||||
|
identity := propertyIdentity{did: request.DID, siid: request.SIID, piid: request.PIID}
|
||||||
|
name := names[start+index]
|
||||||
|
if _, duplicate := duplicateIdentities[identity]; duplicate {
|
||||||
|
results[start+index] = DevicePropertyResult{Name: name, Code: PropertyResultCodeDuplicate}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, ok := byIdentity[identity]
|
||||||
|
if !ok {
|
||||||
|
results[start+index] = DevicePropertyResult{Name: name, Code: PropertyResultCodeMissing}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[start+index] = DevicePropertyResult{Name: name, Value: result.Value, Code: result.Code}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := device.wait(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (device *Device) Set(ctx context.Context, name string, value any) error {
|
func (device *Device) Set(ctx context.Context, name string, value any) error {
|
||||||
property, ok := device.properties[name]
|
property, ok := device.properties[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+305
-7
@@ -1,6 +1,7 @@
|
|||||||
package mijia
|
package mijia
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,12 +11,15 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var _ = DevicePropertyResult{"x", nil, 0}
|
||||||
|
|
||||||
type deviceTestServer struct {
|
type deviceTestServer struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
fixture []byte
|
fixture []byte
|
||||||
@@ -123,6 +127,9 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
|||||||
if info.Properties[7].Name != "outlet-power" || info.Actions[1].Name != "outlet-toggle" {
|
if info.Properties[7].Name != "outlet-power" || info.Actions[1].Name != "outlet-toggle" {
|
||||||
t.Fatalf("duplicates = %#v / %#v", info.Properties[7], info.Actions[1])
|
t.Fatalf("duplicates = %#v / %#v", info.Properties[7], info.Actions[1])
|
||||||
}
|
}
|
||||||
|
if got := info.Actions[0].Inputs; len(got) != 2 || got[0].PIID != 3 || got[1].PIID != 1 || got[0].Description != "Mode / 模式" || got[0].Type != "uint" || len(got[0].ValueList) != 2 {
|
||||||
|
t.Fatalf("action inputs = %#v", got)
|
||||||
|
}
|
||||||
if testServer.specCalls != 1 || testServer.specPaths[0] != "/spec/test.light.v1" {
|
if testServer.specCalls != 1 || testServer.specPaths[0] != "/spec/test.light.v1" {
|
||||||
t.Fatalf("spec requests = %v, want GET /spec/test.light.v1", testServer.specPaths)
|
t.Fatalf("spec requests = %v, want GET /spec/test.light.v1", testServer.specPaths)
|
||||||
}
|
}
|
||||||
@@ -136,6 +143,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var pythonCache struct {
|
var pythonCache struct {
|
||||||
|
Version int `json:"version"`
|
||||||
Properties []struct {
|
Properties []struct {
|
||||||
Method cacheMethod `json:"method"`
|
Method cacheMethod `json:"method"`
|
||||||
} `json:"properties"`
|
} `json:"properties"`
|
||||||
@@ -143,7 +151,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
|||||||
Method cacheMethod `json:"method"`
|
Method cacheMethod `json:"method"`
|
||||||
} `json:"actions"`
|
} `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)
|
t.Fatalf("Python-compatible cache = %#v, %v", pythonCache, err)
|
||||||
}
|
}
|
||||||
testServer.fixture = nil
|
testServer.fixture = nil
|
||||||
@@ -151,6 +159,27 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
|||||||
if err != nil || cached.Name != info.Name || testServer.specCalls != 1 {
|
if err != nil || cached.Name != info.Name || testServer.specCalls != 1 {
|
||||||
t.Fatalf("cached = %#v, %v, calls=%d", cached, err, testServer.specCalls)
|
t.Fatalf("cached = %#v, %v, calls=%d", cached, err, testServer.specCalls)
|
||||||
}
|
}
|
||||||
|
if len(cached.Actions[0].Inputs) != 2 || cached.Actions[0].Inputs[0].PIID != 3 || cached.Actions[0].Inputs[1].PIID != 1 {
|
||||||
|
t.Fatalf("cached action inputs = %#v", cached.Actions[0].Inputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDeviceInfoRejectsUnknownActionInput(t *testing.T) {
|
||||||
|
fixture := bytes.Replace(loadSpecFixture(t), []byte(`"in":[3,1]`), []byte(`"in":[99]`), 1)
|
||||||
|
if _, err := parseDeviceInfoHTML(fixture); err == nil || !strings.Contains(err.Error(), "unknown property IID 99") {
|
||||||
|
t.Fatalf("parse 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()
|
||||||
|
actions["toggle"].Inputs[0].Range[0] = "changed"
|
||||||
|
actions["toggle"].Inputs[0].ValueList[0].Value = "changed"
|
||||||
|
got := device.actions["toggle"].Inputs[0]
|
||||||
|
if got.Range[0] != "1" || got.ValueList[0].Value != "1" {
|
||||||
|
t.Fatalf("internal action input mutated: %#v", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
||||||
@@ -160,7 +189,7 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
|||||||
"properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}],
|
"properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}],
|
||||||
"actions": [{"name":"toggle","description":"Toggle","method":{"siid":9,"aiid":10}}]
|
"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}]}`,
|
`{"homelist":[{"id":"10","uid":1}]}`,
|
||||||
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
||||||
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
|
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
|
||||||
@@ -187,9 +216,9 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for index, want := range []map[string]any{
|
for index, want := range []map[string]any{
|
||||||
{"siid": float64(7), "piid": float64(8)},
|
{"siid": float64(2), "piid": float64(1)},
|
||||||
{"siid": float64(7), "piid": float64(8)},
|
{"siid": float64(2), "piid": float64(1)},
|
||||||
{"siid": float64(9), "aiid": float64(10)},
|
{"siid": float64(2), "aiid": float64(1)},
|
||||||
} {
|
} {
|
||||||
params := testServer.requests[index+2]["params"]
|
params := testServer.requests[index+2]["params"]
|
||||||
var request map[string]any
|
var request map[string]any
|
||||||
@@ -204,8 +233,68 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if testServer.specCalls != 0 {
|
if testServer.specCalls != 1 {
|
||||||
t.Fatalf("spec calls = %d, want 0", testServer.specCalls)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +331,38 @@ 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}]}]
|
||||||
|
}`
|
||||||
|
_, err := decodeDeviceInfo([]byte(cache), "test.light.v1")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not reference a property") {
|
||||||
|
t.Fatalf("decodeDeviceInfo() error = %v, want missing property error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}]}]
|
||||||
|
}`
|
||||||
|
info, err := decodeDeviceInfo([]byte(cache), "test.light.v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 1 {
|
||||||
|
t.Fatalf("actions = %#v", info.Actions)
|
||||||
|
}
|
||||||
|
if got, want := info.Actions[0].Inputs[0], info.Properties[0]; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("canonical input = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInvalidDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
|
func TestInvalidDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
|
||||||
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
|
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
|
||||||
testServer.status = http.StatusServiceUnavailable
|
testServer.status = http.StatusServiceUnavailable
|
||||||
@@ -450,6 +571,183 @@ func TestDeviceGetSetAndAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetManyChunksProperties(t *testing.T) {
|
||||||
|
for _, count := range []int{20, 21} {
|
||||||
|
t.Run(fmt.Sprint(count), func(t *testing.T) {
|
||||||
|
properties, names := batchPropertyFixture(count)
|
||||||
|
responses := make([]string, 0, (count+19)/20)
|
||||||
|
for start := 0; start < count; start += 20 {
|
||||||
|
end := min(start+20, count)
|
||||||
|
items := make([]PropertyResult, 0, end-start)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
property := properties[names[index]]
|
||||||
|
items = append(items, PropertyResult{DID: "a", SIID: property.SIID, PIID: property.PIID, Value: index, Code: 0})
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
responses = append(responses, string(payload))
|
||||||
|
}
|
||||||
|
device, testServer := fixtureDeviceWithServer(t, responses)
|
||||||
|
device.properties = properties
|
||||||
|
|
||||||
|
results, err := device.GetMany(context.Background(), names)
|
||||||
|
if err != nil || len(results) != count {
|
||||||
|
t.Fatalf("GetMany() = %#v, %v", results, err)
|
||||||
|
}
|
||||||
|
wantCalls := (count + 19) / 20
|
||||||
|
if got := len(testServer.requests) - 2; got != wantCalls {
|
||||||
|
t.Fatalf("property calls = %d, want %d", got, wantCalls)
|
||||||
|
}
|
||||||
|
for index, request := range testServer.requests[2:] {
|
||||||
|
params := request["params"].([]any)
|
||||||
|
wantSize := min(20, count-index*20)
|
||||||
|
if len(params) != wantSize {
|
||||||
|
t.Fatalf("chunk %d size = %d, want %d", index, len(params), wantSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetManyMatchesIdentityAndPreservesBusinessErrors(t *testing.T) {
|
||||||
|
device := fixtureDeviceWithResults(t, []string{`[
|
||||||
|
{"did":"a","siid":2,"piid":2,"code":-704030013},
|
||||||
|
{"did":"a","siid":2,"piid":1,"value":true,"code":0}
|
||||||
|
]`}, 0)
|
||||||
|
|
||||||
|
results, err := device.GetMany(context.Background(), []string{"power", "brightness"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []DevicePropertyResult{{Name: "power", Value: true, Code: 0}, {Name: "brightness", Code: -704030013}}
|
||||||
|
if !reflect.DeepEqual(results, want) {
|
||||||
|
t.Fatalf("GetMany() = %#v, want %#v", results, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetManyClassifiesMissingAndDuplicateResults(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want []DevicePropertyResult
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing",
|
||||||
|
response: `[{"did":"a","siid":2,"piid":1,"value":true,"code":0}]`,
|
||||||
|
want: []DevicePropertyResult{{"power", true, 0}, {"brightness", nil, PropertyResultCodeMissing}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate",
|
||||||
|
response: `[{"did":"a","siid":2,"piid":1,"value":true,"code":0},{"did":"a","siid":2,"piid":1,"value":false,"code":0},{"did":"a","siid":2,"piid":2,"value":5,"code":0}]`,
|
||||||
|
want: []DevicePropertyResult{{"power", nil, PropertyResultCodeDuplicate}, {"brightness", json.Number("5"), 0}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
device := fixtureDeviceWithResults(t, []string{test.response}, 0)
|
||||||
|
results, err := device.GetMany(context.Background(), []string{"power", "brightness"})
|
||||||
|
if err != nil || !reflect.DeepEqual(results, test.want) {
|
||||||
|
t.Fatalf("GetMany() = %#v, %v, want %#v, nil", results, err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetManyRejectsExtraResult(t *testing.T) {
|
||||||
|
device := fixtureDeviceWithResults(t, []string{`[
|
||||||
|
{"did":"a","siid":2,"piid":1,"value":true,"code":0},
|
||||||
|
{"did":"a","siid":2,"piid":2,"value":5,"code":0},
|
||||||
|
{"did":"other","siid":9,"piid":9,"value":1,"code":0}
|
||||||
|
]`}, 0)
|
||||||
|
results, err := device.GetMany(context.Background(), []string{"power", "brightness"})
|
||||||
|
if err == nil || results != nil || !strings.Contains(err.Error(), "protocol") {
|
||||||
|
t.Fatalf("GetMany() = %#v, %v, want nil protocol error", results, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetManyReturnsTransportError(t *testing.T) {
|
||||||
|
device, testServer := fixtureDeviceWithServer(t, nil)
|
||||||
|
testServer.server.Close()
|
||||||
|
|
||||||
|
results, err := device.GetMany(context.Background(), []string{"power", "brightness"})
|
||||||
|
if err == nil || results != nil {
|
||||||
|
t.Fatalf("GetMany() = %#v, %v, want nil transport error", results, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestDeviceGetManyValidatesBeforeNetwork(t *testing.T) {
|
||||||
|
device, testServer := fixtureDeviceWithServer(t, nil)
|
||||||
|
tests := [][]string{{"power", "power"}, {"power", "missing"}, {"power", "write-only"}}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(testServer.requests) != 2 {
|
||||||
|
t.Fatalf("requests = %d, validation reached network", len(testServer.requests))
|
||||||
|
}
|
||||||
|
empty, err := device.GetMany(context.Background(), nil)
|
||||||
|
if err != nil || empty == nil || len(empty) != 0 {
|
||||||
|
t.Fatalf("GetMany(nil) = %#v, %v", empty, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetManyWaitsOnceAfterAllChunks(t *testing.T) {
|
||||||
|
properties, names := batchPropertyFixture(21)
|
||||||
|
responses := make([]string, 2)
|
||||||
|
for chunk := range responses {
|
||||||
|
start := chunk * 20
|
||||||
|
end := min(start+20, len(names))
|
||||||
|
items := make([]PropertyResult, 0, end-start)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
property := properties[names[index]]
|
||||||
|
items = append(items, PropertyResult{DID: "a", SIID: property.SIID, PIID: property.PIID, Value: index})
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
responses[chunk] = string(payload)
|
||||||
|
}
|
||||||
|
device := fixtureDeviceWithResults(t, responses, 40*time.Millisecond)
|
||||||
|
device.properties = properties
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
if _, err := device.GetMany(context.Background(), names); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(started)
|
||||||
|
if elapsed < 30*time.Millisecond || elapsed >= 75*time.Millisecond {
|
||||||
|
t.Fatalf("GetMany() delay = %v, want one approximately 40ms wait", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetManyWaitsOnceWithPartialProtocolErrors(t *testing.T) {
|
||||||
|
device := fixtureDeviceWithResults(t, []string{`[{"did":"a","siid":2,"piid":1,"value":true,"code":0}]`}, 40*time.Millisecond)
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
results, err := device.GetMany(context.Background(), []string{"power", "brightness"})
|
||||||
|
elapsed := time.Since(started)
|
||||||
|
if err != nil || len(results) != 2 || results[1].Code != PropertyResultCodeMissing {
|
||||||
|
t.Fatalf("GetMany() = %#v, %v", results, err)
|
||||||
|
}
|
||||||
|
if elapsed < 30*time.Millisecond || elapsed >= 75*time.Millisecond {
|
||||||
|
t.Fatalf("GetMany() delay = %v, want one approximately 40ms wait", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchPropertyFixture(count int) (map[string]PropertySpec, []string) {
|
||||||
|
properties := make(map[string]PropertySpec, count)
|
||||||
|
names := make([]string, count)
|
||||||
|
for index := range count {
|
||||||
|
name := fmt.Sprintf("property-%02d", index)
|
||||||
|
names[index] = name
|
||||||
|
properties[name] = PropertySpec{Name: name, Type: "int", RW: "r", SIID: 10 + index/10, PIID: index%10 + 1}
|
||||||
|
}
|
||||||
|
return properties, names
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeviceMetadataSnapshotsSupportConcurrentReads(t *testing.T) {
|
func TestDeviceMetadataSnapshotsSupportConcurrentReads(t *testing.T) {
|
||||||
device := fixtureDevice(t)
|
device := fixtureDevice(t)
|
||||||
var waitGroup sync.WaitGroup
|
var waitGroup sync.WaitGroup
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package mijia
|
package mijia
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrReauthenticationRequired = errors.New("reauthentication required")
|
||||||
|
|
||||||
var errorCodeMessages = map[int]string{
|
var errorCodeMessages = map[int]string{
|
||||||
-10000: "未知错误",
|
-10000: "未知错误",
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errAuthDataChanged = errors.New("auth data changed callback failed")
|
||||||
|
|
||||||
|
func TestNewClientWithAuthDataAcceptsCompleteAndZeroData(t *testing.T) {
|
||||||
|
complete := completeAuthData()
|
||||||
|
complete.Extra = map[string]string{"cookie": "original"}
|
||||||
|
|
||||||
|
client, err := NewClientWithAuthData(complete)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
complete.Extra["cookie"] = "caller changed"
|
||||||
|
if got := client.AuthData().Extra["cookie"]; got != "original" {
|
||||||
|
t.Fatalf("stored extra cookie = %q, want original", got)
|
||||||
|
}
|
||||||
|
if client.authPath != "" {
|
||||||
|
t.Fatalf("authPath = %q, want empty in memory mode", client.authPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyClient, err := NewClientWithAuthData(AuthData{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if authData := emptyClient.AuthData(); authData.UA != "" || authData.Extra != nil {
|
||||||
|
t.Fatalf("empty client auth data = %#v, want zero value", authData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientWithAuthDataRejectsPartialData(t *testing.T) {
|
||||||
|
_, err := NewClientWithAuthData(AuthData{UA: "agent"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "incomplete") {
|
||||||
|
t.Fatalf("error = %v, want incomplete auth data error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientWithAuthDataGeneratesMissingIdentityWithoutMutatingInput(t *testing.T) {
|
||||||
|
input := completeAuthData()
|
||||||
|
input.DeviceID = ""
|
||||||
|
input.PassO = ""
|
||||||
|
|
||||||
|
client, err := NewClientWithAuthData(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if input.DeviceID != "" || input.PassO != "" {
|
||||||
|
t.Fatalf("caller auth data mutated: %#v", input)
|
||||||
|
}
|
||||||
|
stored := client.AuthData()
|
||||||
|
if stored.DeviceID == "" || stored.PassO == "" {
|
||||||
|
t.Fatalf("stored identity not generated: %#v", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAuthDataChangedRejectsNil(t *testing.T) {
|
||||||
|
_, err := NewClientWithAuthData(AuthData{}, WithAuthDataChanged(nil))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "must not be nil") {
|
||||||
|
t.Fatalf("error = %v, want nil callback error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientWithAuthDataDoesNotAccessFilesystem(t *testing.T) {
|
||||||
|
home := filepath.Join(t.TempDir(), "must-not-exist")
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
if _, err := NewClientWithAuthData(AuthData{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(home); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("home stat error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryRefreshCallsChangedCallbackWithClone(t *testing.T) {
|
||||||
|
client, callbackAuth, server := newMemoryRefreshClient(t, func(authData AuthData) error {
|
||||||
|
authData.Extra["callback"] = "changed"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := client.refreshToken(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if callbackAuth.ServiceToken != "new-token" || callbackAuth.CUserID != "new-c-user" {
|
||||||
|
t.Fatalf("callback auth = %#v", callbackAuth)
|
||||||
|
}
|
||||||
|
stored := client.AuthData()
|
||||||
|
if stored.ServiceToken != "new-token" || stored.CUserID != "new-c-user" {
|
||||||
|
t.Fatalf("stored auth = %#v", stored)
|
||||||
|
}
|
||||||
|
if stored.Extra["callback"] != "original" {
|
||||||
|
t.Fatalf("stored callback extra = %q, want original", stored.Extra["callback"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryRefreshCallbackFailureRollsBack(t *testing.T) {
|
||||||
|
client, _, server := newMemoryRefreshClient(t, func(AuthData) error { return errAuthDataChanged })
|
||||||
|
defer server.Close()
|
||||||
|
before := client.AuthData()
|
||||||
|
|
||||||
|
err := client.refreshToken(context.Background())
|
||||||
|
if !errors.Is(err, errAuthDataChanged) {
|
||||||
|
t.Fatalf("refreshToken() error = %v, want %v", err, errAuthDataChanged)
|
||||||
|
}
|
||||||
|
if after := client.AuthData(); after.ServiceToken != before.ServiceToken || after.CUserID != before.CUserID || after.Ssecurity != before.Ssecurity {
|
||||||
|
t.Fatalf("auth changed after callback failure: before=%#v after=%#v", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryRefreshCallbackCanReadCurrentAuth(t *testing.T) {
|
||||||
|
var client *Client
|
||||||
|
client, _, server := newMemoryRefreshClient(t, func(AuthData) error {
|
||||||
|
if current := client.AuthData(); current.ServiceToken != "service-token" {
|
||||||
|
return errors.New("new auth data installed before callback completed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := client.refreshToken(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryRefreshCallbackCanAcquireApplicationLock(t *testing.T) {
|
||||||
|
var persistenceMu sync.Mutex
|
||||||
|
client, _, server := newMemoryRefreshClient(t, func(AuthData) error {
|
||||||
|
persistenceMu.Lock()
|
||||||
|
defer persistenceMu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := client.refreshToken(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryRefreshPersistsGeneratedIdentity(t *testing.T) {
|
||||||
|
initial := completeAuthData()
|
||||||
|
initial.DeviceID = ""
|
||||||
|
initial.PassO = ""
|
||||||
|
var changed AuthData
|
||||||
|
client, err := NewClientWithAuthData(initial, WithAuthDataChanged(func(authData AuthData) error {
|
||||||
|
changed = authData.clone()
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := configureRefreshServer(t, client)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := client.refreshToken(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if changed.DeviceID == "" || changed.PassO == "" {
|
||||||
|
t.Fatalf("persisted identity not generated: %#v", changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshMemoryClientQRLoginPersistsThroughCallback(t *testing.T) {
|
||||||
|
var changed AuthData
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/serviceLogin":
|
||||||
|
deviceID, deviceErr := request.Cookie("deviceId")
|
||||||
|
passO, passOErr := request.Cookie("pass_o")
|
||||||
|
if request.UserAgent() == "" || deviceErr != nil || deviceID.Value == "" || passOErr != nil || passO.Value == "" {
|
||||||
|
http.Error(writer, "missing generated login identity", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/prepare"}`)
|
||||||
|
case "/loginUrl":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","lp":"`+server.URL+`/lp"}`)
|
||||||
|
case "/lp":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"ssecurity":"`+testSsecurity+`","passToken":"pass","userId":"user","cUserId":"c-user","location":"`+server.URL+`/callback"}`)
|
||||||
|
case "/callback":
|
||||||
|
http.SetCookie(writer, &http.Cookie{Name: "serviceToken", Value: "service", Path: "/"})
|
||||||
|
_, _ = io.WriteString(writer, "ok")
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClientWithAuthData(AuthData{}, WithHTTPClient(server.Client()), WithQRWriter(io.Discard), WithAuthDataChanged(func(authData AuthData) error {
|
||||||
|
changed = authData.clone()
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.loginURL = server.URL + "/loginUrl"
|
||||||
|
|
||||||
|
authData, err := client.Login(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !authData.complete() || authData.DeviceID == "" || changed.ServiceToken != "service" {
|
||||||
|
t.Fatalf("login auth = %#v, callback auth = %#v", authData, changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshMemoryClientQRLoginCallbackFailureKeepsZeroAuthData(t *testing.T) {
|
||||||
|
var client *Client
|
||||||
|
var callbackCalls int
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/serviceLogin":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/prepare"}`)
|
||||||
|
case "/loginUrl":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","lp":"`+server.URL+`/lp"}`)
|
||||||
|
case "/lp":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"ssecurity":"`+testSsecurity+`","passToken":"pass","userId":"user","cUserId":"c-user","location":"`+server.URL+`/callback"}`)
|
||||||
|
case "/callback":
|
||||||
|
http.SetCookie(writer, &http.Cookie{Name: "serviceToken", Value: "service", Path: "/"})
|
||||||
|
_, _ = io.WriteString(writer, "ok")
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
client, err = NewClientWithAuthData(AuthData{}, WithHTTPClient(server.Client()), WithQRWriter(io.Discard), WithAuthDataChanged(func(AuthData) error {
|
||||||
|
callbackCalls++
|
||||||
|
if current := client.AuthData(); !reflect.DeepEqual(current, AuthData{}) {
|
||||||
|
t.Fatalf("auth data installed before callback completed: %#v", current)
|
||||||
|
}
|
||||||
|
return errAuthDataChanged
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.loginURL = server.URL + "/loginUrl"
|
||||||
|
|
||||||
|
_, err = client.Login(context.Background())
|
||||||
|
if !errors.Is(err, errAuthDataChanged) {
|
||||||
|
t.Fatalf("Login() error = %v, want %v", err, errAuthDataChanged)
|
||||||
|
}
|
||||||
|
if callbackCalls != 1 {
|
||||||
|
t.Fatalf("auth data changed callback calls = %d, want 1", callbackCalls)
|
||||||
|
}
|
||||||
|
if authData := client.AuthData(); !reflect.DeepEqual(authData, AuthData{}) {
|
||||||
|
t.Fatalf("auth data after callback failure = %#v, want exact zero value", authData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRefreshPersistenceFailureRollsBack(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
authPath := filepath.Join(directory, "auth.json")
|
||||||
|
initial := completeAuthData()
|
||||||
|
payload, err := initial.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(authPath, payload, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client, err := NewClient(authPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(authPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(authPath, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := configureRefreshServer(t, client)
|
||||||
|
defer server.Close()
|
||||||
|
before := client.AuthData()
|
||||||
|
|
||||||
|
if err := client.refreshToken(context.Background()); err == nil {
|
||||||
|
t.Fatal("refreshToken() error = nil, want persistence failure")
|
||||||
|
}
|
||||||
|
if after := client.AuthData(); after.ServiceToken != before.ServiceToken || after.CUserID != before.CUserID || after.Ssecurity != before.Ssecurity {
|
||||||
|
t.Fatalf("auth changed after file persistence failure: before=%#v after=%#v", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientFileModeCompatibility(t *testing.T) {
|
||||||
|
authPath := filepath.Join(t.TempDir(), "auth.json")
|
||||||
|
want := completeAuthData()
|
||||||
|
want.Extra = map[string]string{"custom": "preserved"}
|
||||||
|
payload, err := want.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(authPath, payload, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewClient(authPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := client.AuthData()
|
||||||
|
if got.ServiceToken != want.ServiceToken || got.Extra["custom"] != "preserved" {
|
||||||
|
t.Fatalf("loaded auth = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryRefreshClient(t *testing.T, callback func(AuthData) error) (*Client, *AuthData, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
captured := new(AuthData)
|
||||||
|
client, err := NewClientWithAuthData(completeAuthData(), WithAuthDataChanged(func(authData AuthData) error {
|
||||||
|
*captured = authData.clone()
|
||||||
|
return callback(authData)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := configureRefreshServer(t, client)
|
||||||
|
return client, captured, server
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureRefreshServer(t *testing.T, client *Client) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/v2/message/v2/check_new_msg":
|
||||||
|
_, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`)
|
||||||
|
case "/serviceLogin":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"location":"`+server.URL+`/refresh","ssecurity":"`+testSsecurity+`"}`)
|
||||||
|
case "/refresh":
|
||||||
|
http.SetCookie(writer, &http.Cookie{Name: "serviceToken", Value: "new-token", Path: "/"})
|
||||||
|
http.SetCookie(writer, &http.Cookie{Name: "cUserId", Value: "new-c-user", Path: "/"})
|
||||||
|
_, _ = io.WriteString(writer, "ok")
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
client.baseURL = server.URL
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.availabilityValid = false
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeAuthData() AuthData {
|
||||||
|
return AuthData{
|
||||||
|
UA: "test-agent",
|
||||||
|
DeviceID: "device-id",
|
||||||
|
PassO: "pass-o",
|
||||||
|
Ssecurity: testSsecurity,
|
||||||
|
PassToken: "pass-token",
|
||||||
|
UserID: "user",
|
||||||
|
CUserID: "c-user",
|
||||||
|
ServiceToken: "service-token",
|
||||||
|
Extra: map[string]string{"callback": "original"},
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -2,6 +2,6 @@
|
|||||||
<html>
|
<html>
|
||||||
<head><script>window.noise = "<script>not JSON</script>";</script></head>
|
<head><script>window.noise = "<script>not JSON</script>";</script></head>
|
||||||
<body>
|
<body>
|
||||||
<script data-page="app" type="application/json">{"props":{"product":{"name":"Test Lamp","model":"test.light.v1"},"i18n":{"zh_cn":{"service:002:property:001":"电源","service:002:property:002":"亮度","service:002:property:003":"模式","service:002:property:004":"温度","service:002:property:005":"序列号","service:002:property:006":"只写","service:002:property:007":"只读","service:002:action:001":"切换","service:003:property:001":"插座电源","service:003:action:001":"插座切换","mode.off":"关闭","mode.on":"开启"}},"tree":{"services":[{"iid":2,"type":"light","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write","notify"]},{"iid":2,"type":"brightness","description":"Brightness","format":"uint8","access":["read","write"],"valueRange":[1,100,1]},{"iid":3,"type":"mode","description":"Mode","format":"uint16","access":["read","write"],"valueList":[{"value":0,"description":"Off","i18nKey":"mode.off"},{"value":1,"description":"On","i18nKey":"mode.on"}]},{"iid":4,"type":"temperature","description":"Temperature","format":"float","access":["read","write"],"valueRange":[0,1,0.1]},{"iid":5,"type":"serial-number","description":"Serial number","format":"string","access":["read","write"]},{"iid":6,"type":"write-only","description":"Write only","format":"int32","access":["write"]},{"iid":7,"type":"read-only","description":"Read only","format":"int64","access":["read"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle"}]},{"iid":3,"type":"outlet","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle"}]}]}}}</script>
|
<script data-page="app" type="application/json">{"props":{"product":{"name":"Test Lamp","model":"test.light.v1"},"i18n":{"zh_cn":{"service:002:property:001":"电源","service:002:property:002":"亮度","service:002:property:003":"模式","service:002:property:004":"温度","service:002:property:005":"序列号","service:002:property:006":"只写","service:002:property:007":"只读","service:002:action:001":"切换","service:003:property:001":"插座电源","service:003:action:001":"插座切换","mode.off":"关闭","mode.on":"开启"}},"tree":{"services":[{"iid":2,"type":"light","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write","notify"]},{"iid":2,"type":"brightness","description":"Brightness","format":"uint8","access":["read","write"],"valueRange":[1,100,1]},{"iid":3,"type":"mode","description":"Mode","format":"uint16","access":["read","write"],"valueList":[{"value":0,"description":"Off","i18nKey":"mode.off"},{"value":1,"description":"On","i18nKey":"mode.on"}]},{"iid":4,"type":"temperature","description":"Temperature","format":"float","access":["read","write"],"valueRange":[0,1,0.1]},{"iid":5,"type":"serial-number","description":"Serial number","format":"string","access":["read","write"]},{"iid":6,"type":"write-only","description":"Write only","format":"int32","access":["write"]},{"iid":7,"type":"read-only","description":"Read only","format":"int64","access":["read"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle","in":[3,1]}]},{"iid":3,"type":"outlet","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle","in":[1]}]}]}}}</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PropertyResultCodeMissing classifies a missing GetMany result locally and is never returned by upstream.
|
||||||
|
PropertyResultCodeMissing int = math.MinInt32
|
||||||
|
// PropertyResultCodeDuplicate classifies duplicate GetMany results locally and is never returned by upstream.
|
||||||
|
PropertyResultCodeDuplicate int = math.MinInt32 + 1
|
||||||
|
)
|
||||||
|
|
||||||
type Home struct {
|
type Home struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -159,6 +167,12 @@ type PropertyResult struct {
|
|||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DevicePropertyResult struct {
|
||||||
|
Name string
|
||||||
|
Value any
|
||||||
|
Code int
|
||||||
|
}
|
||||||
|
|
||||||
type ActionRequest struct {
|
type ActionRequest struct {
|
||||||
DID string `json:"did"`
|
DID string `json:"did"`
|
||||||
SIID int `json:"siid"`
|
SIID int `json:"siid"`
|
||||||
|
|||||||
Reference in New Issue
Block a user