feat: add Go API library
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.mimocode/
|
||||||
|
mijia-api/
|
||||||
|
auth.json
|
||||||
|
miot-cache/
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# mijia-go-api
|
||||||
|
|
||||||
|
`mijia-go-api` 是一个纯 Go 米家 API 库,支持二维码登录与 token 刷新、家庭/设备/场景/耗材查询、属性读写、action 执行、统计查询,以及基于 MIoT 设备描述的高级 `Device` 操作。
|
||||||
|
|
||||||
|
本项目只提供 Go 库,不提供 CLI、MCP、skills 或 HAR decrypt 功能。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.misaka.ren/m1saka/mijia-go-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 登录
|
||||||
|
|
||||||
|
`NewClient` 的认证路径传空字符串时,默认使用 `~/.config/mijia-api/auth.json`。`Login` 会优先尝试刷新已有 token;需要重新登录时,会在终端输出二维码供米家 App 扫描。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
mijia "git.misaka.ren/m1saka/mijia-go-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client, err := mijia.NewClient("")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.Login(context.Background()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := client.AuthData()
|
||||||
|
log.Printf("已登录 userId=%s", auth.UserID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`auth.json` 包含 `serviceToken`、`passToken`、`ssecurity` 等敏感认证数据。库写入该文件时使用 `0600` 权限;请保持此权限,并且不要将该文件提交到版本库。
|
||||||
|
|
||||||
|
## 底层 API
|
||||||
|
|
||||||
|
以下示例展示设备、属性和 action 的直接调用。`GetDevices` 的 `homeID` 传空字符串时查询所有家庭。
|
||||||
|
|
||||||
|
```go
|
||||||
|
devices, err := client.GetDevices(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
properties, err := client.GetProperties(ctx, []mijia.PropertyRequest{
|
||||||
|
{DID: devices[0].DID, SIID: 2, PIID: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.SetProperties(ctx, []mijia.PropertySetRequest{
|
||||||
|
{DID: devices[0].DID, SIID: 2, PIID: 1, Value: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.RunActions(ctx, []mijia.ActionRequest{
|
||||||
|
{DID: devices[0].DID, SIID: 2, AIID: 1},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
家庭、场景、耗材和统计分别使用 `GetHomes`、`GetScenes`、`RunScene`、`GetConsumables` 和 `GetStatistics`。
|
||||||
|
|
||||||
|
## 高级 Device
|
||||||
|
|
||||||
|
`NewDevice` 可通过 `DeviceSelector.DID` 或 `DeviceSelector.Name` 选择设备;名称匹配到多个设备时会返回 `MultipleDevicesFoundError`。设备描述默认缓存到认证文件所在目录,每次成功的 `Get`、`Set`、`RunAction` 后默认等待 `500ms`。
|
||||||
|
|
||||||
|
```go
|
||||||
|
device, err := mijia.NewDevice(ctx, client, mijia.DeviceSelector{DID: "设备 DID"})
|
||||||
|
// 也可以按名称选择:mijia.DeviceSelector{Name: "客厅灯"}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := device.Get(ctx, "on")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := device.Set(ctx, "on", true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = device.RunAction(ctx, "toggle", nil)
|
||||||
|
_ = value
|
||||||
|
```
|
||||||
|
|
||||||
|
可通过实际导出的 `DeviceOption` 调整行为:
|
||||||
|
|
||||||
|
```go
|
||||||
|
device, err := mijia.NewDevice(
|
||||||
|
ctx,
|
||||||
|
client,
|
||||||
|
mijia.DeviceSelector{Name: "客厅灯"},
|
||||||
|
mijia.WithDeviceDelay(0),
|
||||||
|
mijia.WithDeviceCacheDir("./miot-cache"),
|
||||||
|
mijia.WithDeviceHTTPClient(http.DefaultClient),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 超时与错误
|
||||||
|
|
||||||
|
所有网络和设备操作都接收 `context.Context`,建议设置超时:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
devices, err := client.GetDevices(ctx, "")
|
||||||
|
```
|
||||||
|
|
||||||
|
API 错误和高级设备错误可使用 `errors.As` 判断:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var apiErr *mijia.APIError
|
||||||
|
var deviceErr *mijia.DeviceGetError
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &apiErr):
|
||||||
|
log.Printf("API 错误: code=%d message=%s", apiErr.Code, apiErr.Message)
|
||||||
|
case errors.As(err, &deviceErr):
|
||||||
|
log.Printf("设备读取错误: device=%s property=%s code=%d", deviceErr.DeviceName, deviceErr.Name, deviceErr.Code)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
写属性和执行 action 时,对应错误类型为 `DeviceSetError` 和 `DeviceActionError`;设备选择还可能返回 `DeviceNotFoundError` 或 `MultipleDevicesFoundError`。
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
checkNewMessagesURI = "/v2/message/v2/check_new_msg"
|
||||||
|
getHomesURI = "/v2/homeroom/gethome_merged"
|
||||||
|
getDevicesURI = "/home/home_device_list"
|
||||||
|
getSharedDevicesURI = "/v2/home/device_list_page"
|
||||||
|
getScenesURI = "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList"
|
||||||
|
getConsumablesURI = "/v2/home/standard_consumable_items"
|
||||||
|
getPropertiesURI = "/miotspec/prop/get"
|
||||||
|
setPropertiesURI = "/miotspec/prop/set"
|
||||||
|
runActionURI = "/miotspec/action"
|
||||||
|
)
|
||||||
|
|
||||||
|
var getHomesData = map[string]any{
|
||||||
|
"fg": true, "fetch_share": true, "fetch_share_dev": true, "fetch_cariot": true,
|
||||||
|
"limit": 300, "app_ver": 7, "plat_form": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) CheckNewMessages(ctx context.Context, beginAt time.Time) (json.RawMessage, error) {
|
||||||
|
result, err := client.request(ctx, checkNewMessagesURI, map[string]int64{"begin_at": beginAt.Unix()}, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("check new messages: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetHomes(ctx context.Context) ([]Home, error) {
|
||||||
|
result, err := client.request(ctx, getHomesURI, getHomesData, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get homes: %w", err)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Homes []Home `json:"homelist"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(result, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s response: %w", getHomesURI, err)
|
||||||
|
}
|
||||||
|
return response.Homes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetDevices(ctx context.Context, homeID string) ([]Device, error) {
|
||||||
|
homes, err := client.GetHomes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if homeID != "" {
|
||||||
|
owner, err := homeOwner(homes, homeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.getDevices(ctx, homeID, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices []Device
|
||||||
|
for _, home := range homes {
|
||||||
|
homeDevices, err := client.getDevices(ctx, home.ID, home.UID)
|
||||||
|
devices = append(devices, homeDevices...)
|
||||||
|
if err != nil {
|
||||||
|
return devices, fmt.Errorf("get devices for home %s: %w", home.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) getDevices(ctx context.Context, homeID string, owner int64) ([]Device, error) {
|
||||||
|
numericHomeID, err := strconv.ParseInt(homeID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("home id %q must be numeric: %w", homeID, err)
|
||||||
|
}
|
||||||
|
var devices []Device
|
||||||
|
startDID := ""
|
||||||
|
seenCursors := map[string]struct{}{startDID: {}}
|
||||||
|
for {
|
||||||
|
data := map[string]any{
|
||||||
|
"home_owner": owner, "home_id": numericHomeID, "limit": 200, "start_did": startDID,
|
||||||
|
"get_split_device": true, "support_smart_home": true, "get_cariot_device": true, "get_third_device": true,
|
||||||
|
}
|
||||||
|
result, err := client.request(ctx, getDevicesURI, data, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request devices for home %s: %w", homeID, err)
|
||||||
|
}
|
||||||
|
var page struct {
|
||||||
|
Devices []Device `json:"device_info"`
|
||||||
|
MaxDID string `json:"max_did"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(result, &page); err != nil {
|
||||||
|
return devices, fmt.Errorf("decode %s response for home %s: %w", getDevicesURI, homeID, err)
|
||||||
|
}
|
||||||
|
for index := range page.Devices {
|
||||||
|
page.Devices[index].HomeID = homeID
|
||||||
|
}
|
||||||
|
devices = append(devices, page.Devices...)
|
||||||
|
if !page.HasMore {
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
if page.MaxDID == "" {
|
||||||
|
return devices, fmt.Errorf("paginate %s response for home %s: has_more is true but max_did is empty", getDevicesURI, homeID)
|
||||||
|
}
|
||||||
|
if _, seen := seenCursors[page.MaxDID]; seen {
|
||||||
|
return devices, fmt.Errorf("paginate %s response for home %s: max_did %q repeats or forms a cursor cycle", getDevicesURI, homeID, page.MaxDID)
|
||||||
|
}
|
||||||
|
seenCursors[page.MaxDID] = struct{}{}
|
||||||
|
startDID = page.MaxDID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetSharedDevices(ctx context.Context) ([]Device, error) {
|
||||||
|
data := map[string]any{
|
||||||
|
"ssid": "<unknown ssid>", "bssid": "02:00:00:00:00:00", "getVirtualModel": true, "getHuamiDevices": 1,
|
||||||
|
"get_split_device": true, "support_smart_home": true, "get_cariot_device": true, "get_third_device": true,
|
||||||
|
"get_phone_device": true, "get_miwear_device": true,
|
||||||
|
}
|
||||||
|
result, err := client.request(ctx, getSharedDevicesURI, data, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get shared devices: %w", err)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Devices []Device `json:"list"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(result, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s response: %w", getSharedDevicesURI, err)
|
||||||
|
}
|
||||||
|
devices := make([]Device, 0, len(response.Devices))
|
||||||
|
for _, device := range response.Devices {
|
||||||
|
if device.Owner {
|
||||||
|
device.HomeID = "shared"
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetScenes(ctx context.Context, homeID string) ([]Scene, error) {
|
||||||
|
homes, err := client.GetHomes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if homeID != "" {
|
||||||
|
owner, err := homeOwner(homes, homeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.getScenes(ctx, homeID, owner)
|
||||||
|
}
|
||||||
|
var scenes []Scene
|
||||||
|
for _, home := range homes {
|
||||||
|
homeScenes, err := client.getScenes(ctx, home.ID, home.UID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get scenes for home %s: %w", home.ID, err)
|
||||||
|
}
|
||||||
|
scenes = append(scenes, homeScenes...)
|
||||||
|
}
|
||||||
|
return scenes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) getScenes(ctx context.Context, homeID string, owner int64) ([]Scene, error) {
|
||||||
|
data := map[string]any{"app_version": 12, "get_type": 2, "home_id": homeID, "owner_uid": owner}
|
||||||
|
result, err := client.request(ctx, getScenesURI, data, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request scenes for home %s: %w", homeID, err)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Scenes []Scene `json:"manual_scene_info_list"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(result, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s response for home %s: %w", getScenesURI, homeID, err)
|
||||||
|
}
|
||||||
|
for index := range response.Scenes {
|
||||||
|
response.Scenes[index].HomeID = homeID
|
||||||
|
}
|
||||||
|
return response.Scenes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) RunScene(ctx context.Context, sceneID, homeID string) (json.RawMessage, error) {
|
||||||
|
homes, err := client.GetHomes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
owner, err := homeOwner(homes, homeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data := map[string]any{"scene_id": sceneID, "scene_type": 2, "phone_id": "null", "home_id": homeID, "owner_uid": owner}
|
||||||
|
result, err := client.request(ctx, "/appgateway/miot/appsceneservice/AppSceneService/NewRunScene", data, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("run scene %s in home %s: %w", sceneID, homeID, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetConsumables(ctx context.Context, homeID string) ([]Consumable, error) {
|
||||||
|
homes, err := client.GetHomes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if homeID != "" {
|
||||||
|
owner, err := homeOwner(homes, homeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.getConsumables(ctx, homeID, owner)
|
||||||
|
}
|
||||||
|
var consumables []Consumable
|
||||||
|
for _, home := range homes {
|
||||||
|
homeConsumables, err := client.getConsumables(ctx, home.ID, home.UID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get consumables for home %s: %w", home.ID, err)
|
||||||
|
}
|
||||||
|
consumables = append(consumables, homeConsumables...)
|
||||||
|
}
|
||||||
|
return consumables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) getConsumables(ctx context.Context, homeID string, owner int64) ([]Consumable, error) {
|
||||||
|
numericHomeID, err := strconv.ParseInt(homeID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("home id %q must be numeric: %w", homeID, err)
|
||||||
|
}
|
||||||
|
data := map[string]any{"home_id": numericHomeID, "owner_id": owner, "filter_ignore": true}
|
||||||
|
result, err := client.request(ctx, getConsumablesURI, data, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request consumables for home %s: %w", homeID, err)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Items []struct {
|
||||||
|
Consumables []Consumable `json:"consumes_data"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(result, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s response for home %s: %w", getConsumablesURI, homeID, err)
|
||||||
|
}
|
||||||
|
if len(response.Items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
consumables := response.Items[0].Consumables
|
||||||
|
for index := range consumables {
|
||||||
|
consumables[index].HomeID = homeID
|
||||||
|
var details []json.RawMessage
|
||||||
|
if json.Unmarshal(consumables[index].Details, &details) == nil && len(details) == 1 {
|
||||||
|
consumables[index].Details = details[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return consumables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetProperties(ctx context.Context, requests []PropertyRequest) ([]PropertyResult, error) {
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return []PropertyResult{}, nil
|
||||||
|
}
|
||||||
|
result, err := client.request(ctx, getPropertiesURI, map[string]any{"params": requests, "datasource": 1}, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get properties: %w", err)
|
||||||
|
}
|
||||||
|
var properties []PropertyResult
|
||||||
|
if err := decodeJSON(result, &properties); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s response: %w", getPropertiesURI, err)
|
||||||
|
}
|
||||||
|
return properties, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) SetProperties(ctx context.Context, requests []PropertySetRequest) ([]PropertyResult, error) {
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return []PropertyResult{}, nil
|
||||||
|
}
|
||||||
|
result, err := client.request(ctx, setPropertiesURI, map[string]any{"params": requests}, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("set properties: %w", err)
|
||||||
|
}
|
||||||
|
var properties []PropertyResult
|
||||||
|
if err := decodeJSON(result, &properties); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s response: %w", setPropertiesURI, err)
|
||||||
|
}
|
||||||
|
for index := range properties {
|
||||||
|
properties[index].Message = resultMessage(properties[index].Code, properties[index].Message)
|
||||||
|
}
|
||||||
|
return properties, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) RunActions(ctx context.Context, requests []ActionRequest) ([]ActionResult, error) {
|
||||||
|
results := make([]ActionResult, 0, len(requests))
|
||||||
|
for index, action := range requests {
|
||||||
|
result, err := client.request(ctx, runActionURI, map[string]any{"params": action}, true)
|
||||||
|
if err != nil {
|
||||||
|
return results, fmt.Errorf("run action %d: %w", index, err)
|
||||||
|
}
|
||||||
|
var actionResult ActionResult
|
||||||
|
if err := decodeJSON(result, &actionResult); err != nil {
|
||||||
|
return results, fmt.Errorf("decode %s response for action %d: %w", runActionURI, index, err)
|
||||||
|
}
|
||||||
|
actionResult.Message = resultMessage(actionResult.Code, actionResult.Message)
|
||||||
|
results = append(results, actionResult)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetStatistics(ctx context.Context, requests []StatisticsRequest) ([]json.RawMessage, error) {
|
||||||
|
results := make([]json.RawMessage, 0, len(requests))
|
||||||
|
for index, statisticsRequest := range requests {
|
||||||
|
result, err := client.request(ctx, "/v2/user/statistics", statisticsRequest, true)
|
||||||
|
if err != nil {
|
||||||
|
return results, fmt.Errorf("get statistics %d: %w", index, err)
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeOwner(homes []Home, homeID string) (int64, error) {
|
||||||
|
for _, home := range homes {
|
||||||
|
if home.ID == homeID {
|
||||||
|
return home.UID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, &APIError{Code: -1, Message: fmt.Sprintf("未找到 home_id=%s 的家庭信息", homeID)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultMessage(code int, message string) string {
|
||||||
|
if code == 0 || code == 1 {
|
||||||
|
return "成功"
|
||||||
|
}
|
||||||
|
if knownMessage, ok := errorCodeMessages[code]; ok {
|
||||||
|
return knownMessage
|
||||||
|
}
|
||||||
|
if message != "" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return ErrorMessage(code)
|
||||||
|
}
|
||||||
+407
@@ -0,0 +1,407 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiExpectation struct {
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
result string
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckNewMessagesAndGetHomes(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/v2/message/v2/check_new_msg", body: `{"begin_at":1700000000}`, result: `{"has_new":true}`},
|
||||||
|
{path: "/v2/homeroom/gethome_merged", body: `{"app_ver":7,"fetch_cariot":true,"fetch_share":true,"fetch_share_dev":true,"fg":true,"limit":300,"plat_form":0}`, result: `{"homelist":[{"id":"10","name":"Main","uid":100,"room_id":"living"},{"id":20,"name":"Other","uid":200}]}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
message, err := client.CheckNewMessages(context.Background(), time.Unix(1700000000, 999))
|
||||||
|
if err != nil || string(message) != `{"has_new":true}` {
|
||||||
|
t.Fatalf("CheckNewMessages() = %s, %v", message, err)
|
||||||
|
}
|
||||||
|
homes, err := client.GetHomes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(homes) != 2 || homes[0].ID != "10" || homes[1].ID != "20" || homes[1].UID != 200 {
|
||||||
|
t.Fatalf("GetHomes() = %#v", homes)
|
||||||
|
}
|
||||||
|
if string(homes[0].Extra["room_id"]) != `"living"` {
|
||||||
|
t.Fatalf("GetHomes()[0].Extra = %#v", homes[0].Extra)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDevicesReturnsPartialResultsForRepeatedCursor(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":20,"uid":200}]}`},
|
||||||
|
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a","name":"Lamp","model":"lamp.a"}],"max_did":"a","has_more":true}`},
|
||||||
|
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"a","support_smart_home":true}`, result: `{"device_info":[{"did":"b","name":"Fan","model":"fan.b"}],"max_did":"a","has_more":true}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
devices, err := client.GetDevices(context.Background(), "")
|
||||||
|
if err == nil || len(devices) != 2 || devices[0].HomeID != "10" || devices[1].DID != "b" || !strings.Contains(err.Error(), "max_did \"a\" repeats") {
|
||||||
|
t.Fatalf("GetDevices() = %#v, %v", devices, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDevicesStopsCursorCycle(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100}]}`},
|
||||||
|
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a"}],"max_did":"a","has_more":true}`},
|
||||||
|
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"a","support_smart_home":true}`, result: `{"device_info":[{"did":"b"}],"max_did":"b","has_more":true}`},
|
||||||
|
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"b","support_smart_home":true}`, result: `{"device_info":[{"did":"c"}],"max_did":"a","has_more":true}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
devices, err := client.GetDevices(context.Background(), "10")
|
||||||
|
if err == nil || len(devices) != 3 || !strings.Contains(err.Error(), "cursor cycle") {
|
||||||
|
t.Fatalf("GetDevices() = %#v, %v", devices, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDevicesReturnsPartialResultsForEmptyCursor(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100}]}`},
|
||||||
|
{path: "/home/home_device_list", body: `{"get_cariot_device":true,"get_split_device":true,"get_third_device":true,"home_id":10,"home_owner":100,"limit":200,"start_did":"","support_smart_home":true}`, result: `{"device_info":[{"did":"a"}],"has_more":true}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
devices, err := client.GetDevices(context.Background(), "10")
|
||||||
|
if err == nil || len(devices) != 1 || !strings.Contains(err.Error(), "max_did is empty") {
|
||||||
|
t.Fatalf("GetDevices() = %#v, %v", devices, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDevicesSingleHomeErrors(t *testing.T) {
|
||||||
|
t.Run("owner missing", func(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[]}`}})
|
||||||
|
_, err := client.GetDevices(context.Background(), "10")
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) || apiErr.Code != -1 {
|
||||||
|
t.Fatalf("error = %v, want APIError(-1)", err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid numeric id", func(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"home-a","uid":100}]}`}})
|
||||||
|
_, err := client.GetDevices(context.Background(), "home-a")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("GetDevices() error = nil")
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedDevicesScenesAndConsumables(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/v2/home/device_list_page", body: `{"bssid":"02:00:00:00:00:00","getHuamiDevices":1,"getVirtualModel":true,"get_cariot_device":true,"get_miwear_device":true,"get_phone_device":true,"get_split_device":true,"get_third_device":true,"ssid":"<unknown ssid>","support_smart_home":true}`, result: `{"list":[{"did":"owned","owner":true,"localip":"192.0.2.1"},{"did":"shared","owner":false},{"did":"missing"}]}`},
|
||||||
|
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
|
||||||
|
{path: "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList", body: `{"app_version":12,"get_type":2,"home_id":"10","owner_uid":100}`, result: `{"manual_scene_info_list":[{"scene_id":"s1","name":"Sleep","isOnline":true}]}`},
|
||||||
|
{path: "/appgateway/miot/appsceneservice/AppSceneService/GetSimpleSceneList", body: `{"app_version":12,"get_type":2,"home_id":"20","owner_uid":200}`, result: `{}`},
|
||||||
|
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
|
||||||
|
{path: "/appgateway/miot/appsceneservice/AppSceneService/NewRunScene", body: `{"home_id":"10","owner_uid":100,"phone_id":"null","scene_id":"s1","scene_type":2}`, result: `true`},
|
||||||
|
{path: "/v2/homeroom/gethome_merged", body: homesBody, result: `{"homelist":[{"id":"10","uid":100},{"id":"20","uid":200}]}`},
|
||||||
|
{path: "/v2/home/standard_consumable_items", body: `{"filter_ignore":true,"home_id":10,"owner_id":100}`, result: `{"items":[{"consumes_data":[{"did":"a","name":"Filter","details":[{"id":"filter-life"}],"room_id":"kitchen"}]}]}`},
|
||||||
|
{path: "/v2/home/standard_consumable_items", body: `{"filter_ignore":true,"home_id":20,"owner_id":200}`, result: `{"items":[]}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
shared, err := client.GetSharedDevices(context.Background())
|
||||||
|
if err != nil || len(shared) != 1 || shared[0].DID != "owned" || shared[0].HomeID != "shared" || string(shared[0].Extra["localip"]) != `"192.0.2.1"` {
|
||||||
|
t.Fatalf("GetSharedDevices() = %#v, %v", shared, err)
|
||||||
|
}
|
||||||
|
scenes, err := client.GetScenes(context.Background(), "")
|
||||||
|
if err != nil || len(scenes) != 1 || scenes[0].HomeID != "10" || string(scenes[0].Extra["isOnline"]) != "true" {
|
||||||
|
t.Fatalf("GetScenes() = %#v, %v", scenes, err)
|
||||||
|
}
|
||||||
|
runResult, err := client.RunScene(context.Background(), "s1", "10")
|
||||||
|
if err != nil || string(runResult) != "true" {
|
||||||
|
t.Fatalf("RunScene() = %s, %v", runResult, err)
|
||||||
|
}
|
||||||
|
consumables, err := client.GetConsumables(context.Background(), "")
|
||||||
|
if err != nil || len(consumables) != 1 || consumables[0].HomeID != "10" || string(consumables[0].Details) != `{"id":"filter-life"}` || string(consumables[0].Extra["room_id"]) != `"kitchen"` {
|
||||||
|
t.Fatalf("GetConsumables() = %#v, %v", consumables, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPropertiesActionsAndStatistics(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"a","siid":2,"piid":1},{"did":"b","siid":3,"piid":2}]}`, result: `[{"did":"a","siid":2,"piid":1,"value":true,"code":0},{"did":"b","siid":3,"piid":2,"code":-704030013}]`},
|
||||||
|
{path: "/miotspec/prop/set", body: `{"params":[{"did":"a","siid":2,"piid":1,"value":true},{"did":"b","siid":3,"piid":2,"value":5}]}`, result: `[{"did":"a","siid":2,"piid":1,"code":1},{"did":"b","siid":3,"piid":2,"code":-704030023}]`},
|
||||||
|
{path: "/miotspec/action", body: `{"params":{"did":"a","siid":2,"aiid":1}}`, result: `{"did":"a","siid":2,"aiid":1,"code":0,"out":{"enabled":true}}`},
|
||||||
|
{path: "/miotspec/action", body: `{"params":{"did":"b","siid":3,"aiid":2,"value":[5]}}`, result: `{"did":"b","siid":3,"aiid":2,"code":-704040005}`},
|
||||||
|
{path: "/v2/user/statistics", body: `{"data_type":"stat_day_v3","did":"a","key":"2.1","limit":2,"time_end":20,"time_start":10}`, result: `[{"time":10,"value":"[1]"}]`},
|
||||||
|
{path: "/v2/user/statistics", body: `{"data_type":"stat_hour_v3","did":"b","key":"3.2","limit":1,"time_end":40,"time_start":30}`, result: `[{"time":30,"value":"[2]"}]`},
|
||||||
|
})
|
||||||
|
|
||||||
|
properties, err := client.GetProperties(context.Background(), []PropertyRequest{{DID: "a", SIID: 2, PIID: 1}, {DID: "b", SIID: 3, PIID: 2}})
|
||||||
|
if err != nil || len(properties) != 2 || properties[0].Value != true {
|
||||||
|
t.Fatalf("GetProperties() = %#v, %v", properties, err)
|
||||||
|
}
|
||||||
|
setResults, err := client.SetProperties(context.Background(), []PropertySetRequest{{DID: "a", SIID: 2, PIID: 1, Value: true}, {DID: "b", SIID: 3, PIID: 2, Value: 5}})
|
||||||
|
if err != nil || setResults[0].Message != "成功" || setResults[1].Message != ErrorMessage(-704030023) {
|
||||||
|
t.Fatalf("SetProperties() = %#v, %v", setResults, err)
|
||||||
|
}
|
||||||
|
actions, err := client.RunActions(context.Background(), []ActionRequest{{DID: "a", SIID: 2, AIID: 1}, {DID: "b", SIID: 3, AIID: 2, Value: []any{5}}})
|
||||||
|
if err != nil || actions[0].Message != "成功" || string(actions[0].Out) != `{"enabled":true}` || actions[1].Message != ErrorMessage(-704040005) || actions[1].Out != nil {
|
||||||
|
t.Fatalf("RunActions() = %#v, %v", actions, err)
|
||||||
|
}
|
||||||
|
statistics, err := client.GetStatistics(context.Background(), []StatisticsRequest{
|
||||||
|
{DID: "a", Key: "2.1", DataType: "stat_day_v3", Limit: 2, TimeStart: 10, TimeEnd: 20},
|
||||||
|
{DID: "b", Key: "3.2", DataType: "stat_hour_v3", Limit: 1, TimeStart: 30, TimeEnd: 40},
|
||||||
|
})
|
||||||
|
if err != nil || len(statistics) != 2 || string(statistics[1]) != `[{"time":30,"value":"[2]"}]` {
|
||||||
|
t.Fatalf("GetStatistics() = %#v, %v", statistics, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPropertyResultsPreserveDynamicJSONNumbers(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"integer","siid":2,"piid":1},{"did":"float","siid":2,"piid":2},{"did":"bool","siid":2,"piid":3},{"did":"string","siid":2,"piid":4}]}`, result: `[{"did":"integer","value":9007199254740993},{"did":"float","value":1.25},{"did":"bool","value":true},{"did":"string","value":"on"}]`},
|
||||||
|
})
|
||||||
|
|
||||||
|
properties, err := client.GetProperties(context.Background(), []PropertyRequest{
|
||||||
|
{DID: "integer", SIID: 2, PIID: 1},
|
||||||
|
{DID: "float", SIID: 2, PIID: 2},
|
||||||
|
{DID: "bool", SIID: 2, PIID: 3},
|
||||||
|
{DID: "string", SIID: 2, PIID: 4},
|
||||||
|
})
|
||||||
|
integer, integerOK := properties[0].Value.(json.Number)
|
||||||
|
decimal, decimalOK := properties[1].Value.(json.Number)
|
||||||
|
if err != nil || !integerOK || integer.String() != "9007199254740993" || !decimalOK || decimal.String() != "1.25" || properties[2].Value != true || properties[3].Value != "on" {
|
||||||
|
t.Fatalf("GetProperties() = %#v, %v", properties, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeErrorsIncludeEndpoint(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/miotspec/prop/get", body: `{"datasource":1,"params":[{"did":"a","siid":2,"piid":1}]}`, result: `{`},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := client.GetProperties(context.Background(), []PropertyRequest{{DID: "a", SIID: 2, PIID: 1}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "/miotspec/prop/get") {
|
||||||
|
t.Fatalf("GetProperties() error = %v", err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseTypesMarshalExtraWithoutKnownFieldConflicts(t *testing.T) {
|
||||||
|
responses := []any{
|
||||||
|
Home{ID: "10", Extra: map[string]json.RawMessage{"id": json.RawMessage(`"wrong"`), "room_id": json.RawMessage(`"living"`)}},
|
||||||
|
Device{DID: "device", Extra: map[string]json.RawMessage{"did": json.RawMessage(`"wrong"`), "localip": json.RawMessage(`"192.0.2.1"`)}},
|
||||||
|
Scene{SceneID: "scene", Extra: map[string]json.RawMessage{"scene_id": json.RawMessage(`"wrong"`), "isOnline": json.RawMessage(`true`)}},
|
||||||
|
Consumable{DID: "filter", Extra: map[string]json.RawMessage{"did": json.RawMessage(`"wrong"`), "room_id": json.RawMessage(`"kitchen"`)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, response := range responses {
|
||||||
|
payload, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(%T) error = %v", response, err)
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(%T payload) error = %v", response, err)
|
||||||
|
}
|
||||||
|
if string(fields["id"]) == `"wrong"` || string(fields["did"]) == `"wrong"` || string(fields["scene_id"]) == `"wrong"` {
|
||||||
|
t.Fatalf("json.Marshal(%T) allowed Extra conflict: %s", response, payload)
|
||||||
|
}
|
||||||
|
if fields["room_id"] == nil && fields["localip"] == nil && fields["isOnline"] == nil {
|
||||||
|
t.Fatalf("json.Marshal(%T) dropped Extra: %s", response, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPropertiesEmptyRequestsDoNotUseNetwork(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, nil)
|
||||||
|
|
||||||
|
properties, err := client.GetProperties(context.Background(), nil)
|
||||||
|
if err != nil || properties == nil || len(properties) != 0 {
|
||||||
|
t.Fatalf("GetProperties(nil) = %#v, %v", properties, err)
|
||||||
|
}
|
||||||
|
setResults, err := client.SetProperties(context.Background(), []PropertySetRequest{})
|
||||||
|
if err != nil || setResults == nil || len(setResults) != 0 {
|
||||||
|
t.Fatalf("SetProperties(empty) = %#v, %v", setResults, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchRequestsReturnPartialResults(t *testing.T) {
|
||||||
|
t.Run("actions", func(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/miotspec/action", body: `{"params":{"did":"a","siid":2,"aiid":1}}`, result: `{"did":"a","siid":2,"aiid":1,"code":0}`},
|
||||||
|
{path: "/miotspec/action", body: `{"params":{"did":"b","siid":2,"aiid":1}}`, status: http.StatusInternalServerError, result: `failed`},
|
||||||
|
})
|
||||||
|
|
||||||
|
results, err := client.RunActions(context.Background(), []ActionRequest{{DID: "a", SIID: 2, AIID: 1}, {DID: "b", SIID: 2, AIID: 1}})
|
||||||
|
if err == nil || len(results) != 1 || results[0].DID != "a" {
|
||||||
|
t.Fatalf("RunActions() = %#v, %v", results, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("statistics", func(t *testing.T) {
|
||||||
|
requests := []StatisticsRequest{{DID: "a"}, {DID: "b"}}
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/v2/user/statistics", body: `{"data_type":"","did":"a","key":"","limit":0,"time_end":0,"time_start":0}`, result: `[1]`},
|
||||||
|
{path: "/v2/user/statistics", body: `{"data_type":"","did":"b","key":"","limit":0,"time_end":0,"time_start":0}`, status: http.StatusInternalServerError, result: `failed`},
|
||||||
|
})
|
||||||
|
|
||||||
|
results, err := client.GetStatistics(context.Background(), requests)
|
||||||
|
if err == nil || len(results) != 1 || string(results[0]) != `[1]` {
|
||||||
|
t.Fatalf("GetStatistics() = %#v, %v", results, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultMessagesPreserveUnknownDiagnostics(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/miotspec/prop/set", body: `{"params":[{"did":"a","siid":2,"piid":1,"value":true},{"did":"b","siid":2,"piid":1,"value":true},{"did":"c","siid":2,"piid":1,"value":true}]}`, result: `[{"did":"a","code":0,"message":"wrong"},{"did":"b","code":-704030023,"message":"server known"},{"did":"c","code":-99999,"message":"server detail"}]`},
|
||||||
|
{path: "/miotspec/action", body: `{"params":{"did":"d","siid":2,"aiid":1}}`, result: `{"did":"d","code":-99998,"message":"action detail"}`},
|
||||||
|
{path: "/miotspec/action", body: `{"params":{"did":"e","siid":2,"aiid":1}}`, result: `{"did":"e","code":-99997}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
properties, err := client.SetProperties(context.Background(), []PropertySetRequest{{DID: "a", SIID: 2, PIID: 1, Value: true}, {DID: "b", SIID: 2, PIID: 1, Value: true}, {DID: "c", SIID: 2, PIID: 1, Value: true}})
|
||||||
|
if err != nil || properties[0].Message != "成功" || properties[1].Message != ErrorMessage(-704030023) || properties[2].Message != "server detail" {
|
||||||
|
t.Fatalf("SetProperties() = %#v, %v", properties, err)
|
||||||
|
}
|
||||||
|
actions, err := client.RunActions(context.Background(), []ActionRequest{{DID: "d", SIID: 2, AIID: 1}, {DID: "e", SIID: 2, AIID: 1}})
|
||||||
|
if err != nil || actions[0].Message != "action detail" || actions[1].Message != "未知错误" {
|
||||||
|
t.Fatalf("RunActions() = %#v, %v", actions, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionRequestMarshalJSON(t *testing.T) {
|
||||||
|
t.Run("nil extra matches legacy encoding", func(t *testing.T) {
|
||||||
|
payload, err := json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2})
|
||||||
|
if err != nil || string(payload) != `{"did":"d","siid":1,"aiid":2}` {
|
||||||
|
t.Fatalf("json.Marshal() = %s, %v", payload, err)
|
||||||
|
}
|
||||||
|
payload, err = json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2, Value: []any{5}})
|
||||||
|
if err != nil || string(payload) != `{"did":"d","siid":1,"aiid":2,"value":[5]}` {
|
||||||
|
t.Fatalf("json.Marshal() = %s, %v", payload, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("extra keys are merged", func(t *testing.T) {
|
||||||
|
payload, err := json.Marshal(ActionRequest{DID: "d", SIID: 5, AIID: 4, Extra: map[string]any{"in": []any{"打开空调", 1}}})
|
||||||
|
if err != nil || !jsonEqual(payload, []byte(`{"did":"d","siid":5,"aiid":4,"in":["打开空调",1]}`)) {
|
||||||
|
t.Fatalf("json.Marshal() = %s, %v", payload, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reserved key conflicts fail", func(t *testing.T) {
|
||||||
|
for _, key := range []string{"did", "siid", "aiid", "value"} {
|
||||||
|
if _, err := json.Marshal(ActionRequest{DID: "d", SIID: 1, AIID: 2, Extra: map[string]any{key: "x"}}); err == nil {
|
||||||
|
t.Fatalf("json.Marshal() with extra key %q error = nil", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunActionsSendsExtraInsideParams(t *testing.T) {
|
||||||
|
client, verify := newAPIClient(t, []apiExpectation{
|
||||||
|
{path: "/miotspec/action", body: `{"params":{"did":"speaker","siid":5,"aiid":4,"in":["打开空调",1]}}`, result: `{"did":"speaker","siid":5,"aiid":4,"code":0}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
results, err := client.RunActions(context.Background(), []ActionRequest{{DID: "speaker", SIID: 5, AIID: 4, Extra: map[string]any{"in": []any{"打开空调", 1}}}})
|
||||||
|
if err != nil || len(results) != 1 || results[0].Code != 0 {
|
||||||
|
t.Fatalf("RunActions() = %#v, %v", results, err)
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
|
||||||
|
client, verify = newAPIClient(t, nil)
|
||||||
|
if _, err := client.RunActions(context.Background(), []ActionRequest{{DID: "speaker", SIID: 5, AIID: 4, Extra: map[string]any{"value": 1}}}); err == nil {
|
||||||
|
t.Fatal("RunActions() with conflicting extra key error = nil")
|
||||||
|
}
|
||||||
|
verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
const homesBody = `{"app_ver":7,"fetch_cariot":true,"fetch_share":true,"fetch_share_dev":true,"fg":true,"limit":300,"plat_form":0}`
|
||||||
|
|
||||||
|
func newAPIClient(t *testing.T, expectations []apiExpectation) (*Client, func()) {
|
||||||
|
t.Helper()
|
||||||
|
var mutex sync.Mutex
|
||||||
|
requestIndex := 0
|
||||||
|
var handlerErrors []error
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
if requestIndex >= len(expectations) {
|
||||||
|
handlerErrors = append(handlerErrors, fmt.Errorf("unexpected request %s", request.URL.Path))
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expectation := expectations[requestIndex]
|
||||||
|
requestIndex++
|
||||||
|
if request.URL.Path != expectation.path {
|
||||||
|
handlerErrors = append(handlerErrors, fmt.Errorf("request %d path = %s, want %s", requestIndex, request.URL.Path, expectation.path))
|
||||||
|
}
|
||||||
|
if err := request.ParseForm(); err != nil {
|
||||||
|
handlerErrors = append(handlerErrors, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nonce := request.PostForm.Get("_nonce")
|
||||||
|
signed, err := signedNonce(testSsecurity, nonce)
|
||||||
|
if err != nil {
|
||||||
|
handlerErrors = append(handlerErrors, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := decryptRC4(signed, request.PostForm.Get("data"))
|
||||||
|
if err != nil {
|
||||||
|
handlerErrors = append(handlerErrors, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !jsonEqual(payload, []byte(expectation.body)) {
|
||||||
|
handlerErrors = append(handlerErrors, fmt.Errorf("request %d body = %s, want %s", requestIndex, payload, expectation.body))
|
||||||
|
}
|
||||||
|
if expectation.status != 0 {
|
||||||
|
writer.WriteHeader(expectation.status)
|
||||||
|
_, _ = io.WriteString(writer, expectation.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":`+expectation.result+`}`)
|
||||||
|
}))
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
return client, func() {
|
||||||
|
t.Helper()
|
||||||
|
server.Close()
|
||||||
|
mutex.Lock()
|
||||||
|
count := requestIndex
|
||||||
|
errors := append([]error(nil), handlerErrors...)
|
||||||
|
mutex.Unlock()
|
||||||
|
for _, err := range errors {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if count != len(expectations) {
|
||||||
|
t.Errorf("requests = %d, want %d", count, len(expectations))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonEqual(left, right []byte) bool {
|
||||||
|
var leftValue any
|
||||||
|
var rightValue any
|
||||||
|
return json.Unmarshal(left, &leftValue) == nil && json.Unmarshal(right, &rightValue) == nil && reflect.DeepEqual(leftValue, rightValue)
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mdp/qrterminal/v3"
|
||||||
|
"rsc.io/qr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const qrLoginTimeout = 120 * time.Second
|
||||||
|
|
||||||
|
type AuthData struct {
|
||||||
|
UA string `json:"ua"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
PassO string `json:"pass_o"`
|
||||||
|
Psecurity string `json:"psecurity"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Ssecurity string `json:"ssecurity"`
|
||||||
|
PassToken string `json:"passToken"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
CUserID string `json:"cUserId"`
|
||||||
|
ServiceToken string `json:"serviceToken"`
|
||||||
|
YetAnotherServiceToken string `json:"yetAnotherServiceToken"`
|
||||||
|
ExpireTime int64 `json:"expireTime"`
|
||||||
|
SaveTime int64 `json:"saveTime"`
|
||||||
|
Extra map[string]string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data AuthData) MarshalJSON() ([]byte, error) {
|
||||||
|
fields := map[string]any{
|
||||||
|
"ua": data.UA, "deviceId": data.DeviceID, "pass_o": data.PassO,
|
||||||
|
"psecurity": data.Psecurity, "nonce": data.Nonce, "ssecurity": data.Ssecurity,
|
||||||
|
"passToken": data.PassToken, "userId": data.UserID, "cUserId": data.CUserID,
|
||||||
|
"serviceToken": data.ServiceToken, "yetAnotherServiceToken": data.YetAnotherServiceToken,
|
||||||
|
"expireTime": data.ExpireTime, "saveTime": data.SaveTime,
|
||||||
|
}
|
||||||
|
for key, value := range data.Extra {
|
||||||
|
if _, stable := fields[key]; !stable {
|
||||||
|
fields[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data *AuthData) UnmarshalJSON(payload []byte) error {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stable := map[string]any{
|
||||||
|
"ua": &data.UA, "deviceId": &data.DeviceID, "pass_o": &data.PassO,
|
||||||
|
"psecurity": &data.Psecurity, "nonce": &data.Nonce, "ssecurity": &data.Ssecurity,
|
||||||
|
"passToken": &data.PassToken, "userId": &data.UserID, "cUserId": &data.CUserID,
|
||||||
|
"serviceToken": &data.ServiceToken, "yetAnotherServiceToken": &data.YetAnotherServiceToken,
|
||||||
|
"expireTime": &data.ExpireTime, "saveTime": &data.SaveTime,
|
||||||
|
}
|
||||||
|
data.Extra = make(map[string]string)
|
||||||
|
for key, raw := range fields {
|
||||||
|
if target, ok := stable[key]; ok {
|
||||||
|
if err := json.Unmarshal(raw, target); err != nil {
|
||||||
|
return fmt.Errorf("decode auth field %s: %w", key, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
data.Extra[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data AuthData) complete() bool {
|
||||||
|
return data.UA != "" && data.Ssecurity != "" && data.UserID != "" && data.CUserID != "" && data.ServiceToken != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data AuthData) yetAnotherServiceToken() string {
|
||||||
|
if data.YetAnotherServiceToken != "" {
|
||||||
|
return data.YetAnotherServiceToken
|
||||||
|
}
|
||||||
|
if data.Extra != nil && data.Extra["yetAnotherServiceToken"] != "" {
|
||||||
|
return data.Extra["yetAnotherServiceToken"]
|
||||||
|
}
|
||||||
|
return data.ServiceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data AuthData) clone() AuthData {
|
||||||
|
clone := data
|
||||||
|
if data.Extra != nil {
|
||||||
|
clone.Extra = make(map[string]string, len(data.Extra))
|
||||||
|
for key, value := range data.Extra {
|
||||||
|
clone.Extra[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthData returns a snapshot of the client's current authentication data.
|
||||||
|
func (client *Client) AuthData() AuthData {
|
||||||
|
client.authMu.RLock()
|
||||||
|
defer client.authMu.RUnlock()
|
||||||
|
return client.authData.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) setAuthData(authData AuthData) {
|
||||||
|
client.authMu.Lock()
|
||||||
|
client.authData = authData.clone()
|
||||||
|
client.authMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) updateAuthData(update func(*AuthData)) AuthData {
|
||||||
|
client.authMu.Lock()
|
||||||
|
defer client.authMu.Unlock()
|
||||||
|
update(&client.authData)
|
||||||
|
return client.authData.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) loadAuthData() error {
|
||||||
|
payload, err := os.ReadFile(client.authPath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read auth data: %w", err)
|
||||||
|
}
|
||||||
|
var authData AuthData
|
||||||
|
if err := json.Unmarshal(payload, &authData); err != nil {
|
||||||
|
return fmt.Errorf("decode auth data: %w", err)
|
||||||
|
}
|
||||||
|
client.setAuthData(authData)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ensureIdentity() {
|
||||||
|
client.updateAuthData(func(authData *AuthData) {
|
||||||
|
if authData.PassO == "" {
|
||||||
|
authData.PassO = randomString(16, "0123456789abcdef")
|
||||||
|
}
|
||||||
|
if authData.DeviceID == "" {
|
||||||
|
authData.DeviceID = randomString(16, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-")
|
||||||
|
}
|
||||||
|
if authData.UA != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
countryCode := "CN"
|
||||||
|
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
|
||||||
|
countryCode = parts[1]
|
||||||
|
}
|
||||||
|
id1 := randomString(40, "0123456789ABCDEF")
|
||||||
|
id2 := randomString(32, "0123456789ABCDEF")
|
||||||
|
id3 := randomString(32, "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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomString(length int, alphabet string) string {
|
||||||
|
random := make([]byte, length)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
panic(fmt.Sprintf("generate random identity: %v", err))
|
||||||
|
}
|
||||||
|
for index := range random {
|
||||||
|
random[index] = alphabet[int(random[index])%len(alphabet)]
|
||||||
|
}
|
||||||
|
return string(random)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) saveAuthData() error {
|
||||||
|
authData := client.updateAuthData(func(authData *AuthData) {
|
||||||
|
authData.SaveTime = time.Now().UnixMilli()
|
||||||
|
})
|
||||||
|
payload, err := json.MarshalIndent(authData, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode auth data: %w", err)
|
||||||
|
}
|
||||||
|
directory := filepath.Dir(client.authPath)
|
||||||
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create auth directory: %w", err)
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp(directory, ".auth-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temporary auth file: %w", err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
defer os.Remove(temporaryPath)
|
||||||
|
if err := temporary.Chmod(0o600); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("secure temporary auth file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(payload); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("write auth data: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("sync auth data: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close auth data: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(temporaryPath, client.authPath); err != nil {
|
||||||
|
return fmt.Errorf("replace auth data: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseServiceResponse(payload []byte, target any) error {
|
||||||
|
payload = []byte(strings.TrimPrefix(string(payload), "&&&START&&&"))
|
||||||
|
if err := json.Unmarshal(payload, target); err != nil {
|
||||||
|
return fmt.Errorf("decode login response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceLoginData struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Desc string `json:"desc"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Ssecurity string `json:"ssecurity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type qrLoginData struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Desc string `json:"desc"`
|
||||||
|
LoginURL string `json:"loginUrl"`
|
||||||
|
QR string `json:"qr"`
|
||||||
|
LP string `json:"lp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type longPollData struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Desc string `json:"desc"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Psecurity string `json:"psecurity"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Ssecurity string `json:"ssecurity"`
|
||||||
|
PassToken string `json:"passToken"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
CUserID string `json:"cUserId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Login(ctx context.Context) (AuthData, error) {
|
||||||
|
client.loginMu.Lock()
|
||||||
|
defer client.loginMu.Unlock()
|
||||||
|
|
||||||
|
location, refreshed, err := client.getLocation(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return AuthData{}, err
|
||||||
|
}
|
||||||
|
if refreshed {
|
||||||
|
if err := client.saveAuthData(); err != nil {
|
||||||
|
return AuthData{}, err
|
||||||
|
}
|
||||||
|
return client.AuthData(), nil
|
||||||
|
}
|
||||||
|
loginData, err := client.getQRLoginData(ctx, location)
|
||||||
|
if err != nil {
|
||||||
|
return AuthData{}, err
|
||||||
|
}
|
||||||
|
if client.qrWriter != nil {
|
||||||
|
if _, err := qr.Encode(loginData.LoginURL, qr.L); err != nil {
|
||||||
|
return AuthData{}, fmt.Errorf("encode login QR code: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(client.qrWriter, "请使用米家APP扫描下方二维码\n%s\n", loginData.LoginURL)
|
||||||
|
qrterminal.GenerateHalfBlock(loginData.LoginURL, qrterminal.L, client.qrWriter)
|
||||||
|
if loginData.QR != "" {
|
||||||
|
fmt.Fprintf(client.qrWriter, "二维码图片: %s\n", loginData.QR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return client.completeQRLogin(ctx, loginData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) getLocation(ctx context.Context) (url.Values, bool, error) {
|
||||||
|
httpClient := client.newSession()
|
||||||
|
serviceURL, err := url.Parse(client.serviceLoginURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("parse service login URL: %w", err)
|
||||||
|
}
|
||||||
|
query := serviceURL.Query()
|
||||||
|
query.Set("_json", "true")
|
||||||
|
query.Set("sid", "mijia")
|
||||||
|
query.Set("_locale", client.locale)
|
||||||
|
serviceURL.RawQuery = query.Encode()
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
client.setLoginHeaders(request, true)
|
||||||
|
var data serviceLoginData
|
||||||
|
if err := client.doLoginRequestWithClient(httpClient, request, false, &data); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if data.Location == "" {
|
||||||
|
return nil, false, &LoginError{Code: data.Code, Message: "登录响应缺少 location"}
|
||||||
|
}
|
||||||
|
if data.Code == 0 {
|
||||||
|
refreshRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
client.setLoginHeaders(refreshRequest, false)
|
||||||
|
response, err := httpClient.Do(refreshRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("refresh login token: %w", err)
|
||||||
|
}
|
||||||
|
body, readErr := readHTTPResponse(response)
|
||||||
|
response.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, false, fmt.Errorf("read token refresh response: %w", readErr)
|
||||||
|
}
|
||||||
|
if response.StatusCode == http.StatusOK && string(body) == "ok" {
|
||||||
|
candidate := client.AuthData()
|
||||||
|
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
||||||
|
candidate.Ssecurity = data.Ssecurity
|
||||||
|
if !serviceTokenReceived || !candidate.complete() {
|
||||||
|
return nil, false, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"}
|
||||||
|
}
|
||||||
|
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||||
|
client.setAuthData(candidate)
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locationURL, err := url.Parse(data.Location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("parse login location: %w", err)
|
||||||
|
}
|
||||||
|
return locationURL.Query(), false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) getQRLoginData(ctx context.Context, location url.Values) (qrLoginData, error) {
|
||||||
|
location.Set("theme", "")
|
||||||
|
location.Set("bizDeviceType", "")
|
||||||
|
location.Set("_hasLogo", "false")
|
||||||
|
location.Set("_qrsize", "240")
|
||||||
|
location.Set("_dc", fmt.Sprintf("%d", time.Now().UnixMilli()))
|
||||||
|
loginURL, err := url.Parse(client.loginURL)
|
||||||
|
if err != nil {
|
||||||
|
return qrLoginData{}, err
|
||||||
|
}
|
||||||
|
loginURL.RawQuery = location.Encode()
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return qrLoginData{}, err
|
||||||
|
}
|
||||||
|
client.setLoginHeaders(request, false)
|
||||||
|
var data qrLoginData
|
||||||
|
if err := client.doLoginRequest(request, true, &data); err != nil {
|
||||||
|
return qrLoginData{}, err
|
||||||
|
}
|
||||||
|
if data.LoginURL == "" || data.LP == "" {
|
||||||
|
return qrLoginData{}, &LoginError{Code: data.Code, Message: "二维码登录响应不完整"}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData) (AuthData, error) {
|
||||||
|
pollContext, cancel := context.WithTimeout(ctx, qrLoginTimeout)
|
||||||
|
defer cancel()
|
||||||
|
httpClient := client.newSession()
|
||||||
|
request, err := http.NewRequestWithContext(pollContext, http.MethodGet, loginData.LP, nil)
|
||||||
|
if err != nil {
|
||||||
|
return AuthData{}, err
|
||||||
|
}
|
||||||
|
client.setLoginHeaders(request, false)
|
||||||
|
var data longPollData
|
||||||
|
if err := client.doLoginRequestWithClient(httpClient, request, true, &data); err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return AuthData{}, &LoginError{Code: -1, Message: "超时,请重试"}
|
||||||
|
}
|
||||||
|
return AuthData{}, err
|
||||||
|
}
|
||||||
|
callback, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
|
||||||
|
if err != nil {
|
||||||
|
return AuthData{}, err
|
||||||
|
}
|
||||||
|
client.setLoginHeaders(callback, false)
|
||||||
|
response, err := httpClient.Do(callback)
|
||||||
|
if err != nil {
|
||||||
|
return AuthData{}, fmt.Errorf("complete login callback: %w", err)
|
||||||
|
}
|
||||||
|
_, readErr := readHTTPResponse(response)
|
||||||
|
response.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return AuthData{}, fmt.Errorf("read login callback response: %w", readErr)
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return AuthData{}, &LoginError{Code: response.StatusCode, Message: "登录回调失败"}
|
||||||
|
}
|
||||||
|
candidate := client.AuthData()
|
||||||
|
candidate.Ssecurity = ""
|
||||||
|
candidate.UserID = ""
|
||||||
|
candidate.CUserID = ""
|
||||||
|
candidate.ServiceToken = ""
|
||||||
|
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
||||||
|
candidate.Psecurity = data.Psecurity
|
||||||
|
candidate.Nonce = data.Nonce
|
||||||
|
candidate.Ssecurity = data.Ssecurity
|
||||||
|
candidate.PassToken = data.PassToken
|
||||||
|
candidate.UserID = data.UserID
|
||||||
|
candidate.CUserID = data.CUserID
|
||||||
|
if !serviceTokenReceived || !candidate.complete() {
|
||||||
|
return AuthData{}, &LoginError{Code: -1, Message: "登录回调认证信息不完整"}
|
||||||
|
}
|
||||||
|
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||||
|
client.setAuthData(candidate)
|
||||||
|
if err := client.saveAuthData(); err != nil {
|
||||||
|
return AuthData{}, err
|
||||||
|
}
|
||||||
|
return client.AuthData(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) doLoginRequest(request *http.Request, verifyCode bool, target any) error {
|
||||||
|
return client.doLoginRequestWithClient(client.session(), request, verifyCode, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) doLoginRequestWithClient(httpClient *http.Client, request *http.Request, verifyCode bool, target any) error {
|
||||||
|
response, err := httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send login request: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := readHTTPResponse(response)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read login response: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return &LoginError{Code: response.StatusCode, Message: string(body)}
|
||||||
|
}
|
||||||
|
if err := parseServiceResponse(body, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if verifyCode {
|
||||||
|
encoded, _ := json.Marshal(target)
|
||||||
|
var status struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Desc string `json:"desc"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(encoded, &status)
|
||||||
|
if status.Code != 0 {
|
||||||
|
return &LoginError{Code: status.Code, Message: status.Desc}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) setLoginHeaders(request *http.Request, withCookies bool) {
|
||||||
|
authData := client.AuthData()
|
||||||
|
request.Header.Set("User-Agent", authData.UA)
|
||||||
|
request.Header.Set("Connection", "keep-alive")
|
||||||
|
request.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if withCookies {
|
||||||
|
request.Header.Set("Cookie", strings.Join([]string{
|
||||||
|
"deviceId=" + authData.DeviceID,
|
||||||
|
"pass_o=" + authData.PassO,
|
||||||
|
"passToken=" + authData.PassToken,
|
||||||
|
"userId=" + authData.UserID,
|
||||||
|
"cUserId=" + authData.CUserID,
|
||||||
|
"uLocale=" + client.locale,
|
||||||
|
}, ";"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAuthDataFromCookies(authData *AuthData, httpClient *http.Client, target *url.URL) bool {
|
||||||
|
if httpClient.Jar == nil || target == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cookies := httpClient.Jar.Cookies(target)
|
||||||
|
if authData.Extra == nil {
|
||||||
|
authData.Extra = make(map[string]string)
|
||||||
|
}
|
||||||
|
serviceTokenReceived := false
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
switch cookie.Name {
|
||||||
|
case "serviceToken":
|
||||||
|
if cookie.Value != "" {
|
||||||
|
authData.ServiceToken = cookie.Value
|
||||||
|
serviceTokenReceived = true
|
||||||
|
}
|
||||||
|
case "yetAnotherServiceToken":
|
||||||
|
authData.YetAnotherServiceToken = cookie.Value
|
||||||
|
case "cUserId":
|
||||||
|
authData.CUserID = cookie.Value
|
||||||
|
default:
|
||||||
|
authData.Extra[cookie.Name] = cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serviceTokenReceived
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) refreshToken(ctx context.Context) error {
|
||||||
|
client.loginMu.Lock()
|
||||||
|
defer client.loginMu.Unlock()
|
||||||
|
|
||||||
|
available, _ := client.Available(ctx)
|
||||||
|
if available {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, refreshed, err := client.getLocation(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !refreshed {
|
||||||
|
return &LoginError{Code: -1, Message: "刷新Token失败,请重新登录"}
|
||||||
|
}
|
||||||
|
if err := client.saveAuthData(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseServiceResponse(t *testing.T) {
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
if err := parseServiceResponse([]byte(`&&&START&&&{"code":0}`), &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Code != 0 {
|
||||||
|
t.Fatalf("code = %d", result.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAuthDataUses0600AndStableFields(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
client, err := NewClient(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.setAuthData(AuthData{UA: "agent", DeviceID: "device", Extra: map[string]string{"yetAnotherServiceToken": "extra"}})
|
||||||
|
if err := client.saveAuthData(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(directory, "auth.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(filepath.Join(directory, "auth.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(contents, []byte(`"deviceId"`)) || !bytes.Contains(contents, []byte(`"yetAnotherServiceToken"`)) {
|
||||||
|
t.Fatalf("saved JSON = %s", contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSilentlyRefreshesPassToken(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/serviceLogin":
|
||||||
|
writeGzipResponse(t, writer, `&&&START&&&{"code":0,"location":"`+serverURL(request)+`/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: "/"})
|
||||||
|
writeGzipResponse(t, writer, "ok")
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.updateAuthData(func(authData *AuthData) { authData.PassToken = "pass-token" })
|
||||||
|
|
||||||
|
auth, err := client.Login(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if auth.ServiceToken != "new-token" || auth.CUserID != "new-c-user" {
|
||||||
|
t.Fatalf("auth = %#v", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshRejectsMissingNewServiceTokenWithoutSaving(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":
|
||||||
|
http.SetCookie(writer, &http.Cookie{Name: "cUserId", Value: "new-c-user", Path: "/"})
|
||||||
|
_, _ = io.WriteString(writer, "ok")
|
||||||
|
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
|
||||||
|
client.updateAuthData(func(authData *AuthData) { authData.PassToken = "pass-token" })
|
||||||
|
if err := client.saveAuthData(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := os.ReadFile(client.authPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.refreshToken(context.Background())
|
||||||
|
var loginErr *LoginError
|
||||||
|
if !errors.As(err, &loginErr) {
|
||||||
|
t.Fatalf("refreshToken() error = %v, want LoginError", err)
|
||||||
|
}
|
||||||
|
after, readErr := os.ReadFile(client.authPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(after, before) {
|
||||||
|
t.Fatalf("auth file changed after failed refresh\nbefore: %s\nafter: %s", before, after)
|
||||||
|
}
|
||||||
|
if auth := client.AuthData(); auth.ServiceToken != "service-token" || auth.CUserID != "c-user" {
|
||||||
|
t.Fatalf("auth changed after failed refresh: %#v", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthDataReturnsDeepCopy(t *testing.T) {
|
||||||
|
client := testClient(t, http.DefaultClient)
|
||||||
|
client.updateAuthData(func(authData *AuthData) { authData.Extra = map[string]string{"cookie": "original"} })
|
||||||
|
|
||||||
|
snapshot := client.AuthData()
|
||||||
|
snapshot.Extra["cookie"] = "changed"
|
||||||
|
if got := client.AuthData().Extra["cookie"]; got != "original" {
|
||||||
|
t.Fatalf("stored extra cookie = %q, want original", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRejectsOversizedQRCode(t *testing.T) {
|
||||||
|
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":
|
||||||
|
loginURL := strings.Repeat("x", 10000)
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"`+loginURL+`","lp":"`+server.URL+`/lp"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.loginURL = server.URL + "/loginUrl"
|
||||||
|
client.qrWriter = io.Discard
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := client.Login(ctx); err == nil || !strings.Contains(err.Error(), "QR code") {
|
||||||
|
t.Fatalf("Login() error = %v, want QR encoding error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginQRCoreFlow(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/serviceLogin":
|
||||||
|
location := server.URL + `/prepare?sid=mijia&foo=bar`
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+location+`"}`)
|
||||||
|
case "/loginUrl":
|
||||||
|
query := request.URL.Query()
|
||||||
|
for _, key := range []string{"theme", "bizDeviceType", "_hasLogo", "_qrsize", "_dc", "sid", "foo"} {
|
||||||
|
if _, ok := query[key]; !ok {
|
||||||
|
t.Errorf("login query missing %q: %s", key, request.URL.RawQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","qr":"https://qr.example/image","lp":"`+server.URL+`/lp"}`)
|
||||||
|
case "/lp":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"psecurity":"p","nonce":"n","ssecurity":"`+testSsecurity+`","passToken":"pass","userId":"user","cUserId":"cuser","location":"`+server.URL+`/callback"}`)
|
||||||
|
case "/callback":
|
||||||
|
http.SetCookie(writer, &http.Cookie{Name: "serviceToken", Value: "service", Path: "/"})
|
||||||
|
http.SetCookie(writer, &http.Cookie{Name: "yetAnotherServiceToken", Value: "another", Path: "/"})
|
||||||
|
_, _ = io.WriteString(writer, "ok")
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||||
|
client.loginURL = server.URL + "/loginUrl"
|
||||||
|
client.qrWriter = &output
|
||||||
|
auth, err := client.Login(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if auth.ServiceToken != "service" || auth.Psecurity != "p" || auth.ExpireTime <= auth.SaveTime {
|
||||||
|
t.Fatalf("auth = %#v", auth)
|
||||||
|
}
|
||||||
|
if auth.YetAnotherServiceToken != "another" {
|
||||||
|
t.Fatalf("yetAnotherServiceToken = %q", auth.YetAnotherServiceToken)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), "qr.example/login") {
|
||||||
|
t.Fatalf("QR output does not contain login URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := os.ReadFile(client.authPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var saved AuthData
|
||||||
|
if err := json.Unmarshal(contents, &saved); err != nil || saved.ServiceToken != "service" {
|
||||||
|
t.Fatalf("saved auth = %#v, error = %v", saved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQRLoginRejectsIncompleteCallbackWithoutSaving(t *testing.T) {
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/lp":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"ssecurity":"`+testSsecurity+`","passToken":"pass","userId":"user","cUserId":"cuser","location":"`+server.URL+`/callback"}`)
|
||||||
|
case "/callback":
|
||||||
|
_, _ = io.WriteString(writer, "ok")
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
_, err := client.completeQRLogin(context.Background(), qrLoginData{LP: server.URL + "/lp"})
|
||||||
|
var loginErr *LoginError
|
||||||
|
if !errors.As(err, &loginErr) {
|
||||||
|
t.Fatalf("completeQRLogin() error = %v, want LoginError", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(client.authPath); !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
t.Fatalf("auth file stat error = %v, want not exist", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverURL(request *http.Request) string {
|
||||||
|
return "http://" + request.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGzipResponse(t *testing.T, writer http.ResponseWriter, body string) {
|
||||||
|
t.Helper()
|
||||||
|
writer.Header().Set("Content-Encoding", "gzip")
|
||||||
|
gzipWriter := gzip.NewWriter(writer)
|
||||||
|
if _, err := io.WriteString(gzipWriter, body); err != nil {
|
||||||
|
t.Errorf("write gzip response: %v", err)
|
||||||
|
}
|
||||||
|
if err := gzipWriter.Close(); err != nil {
|
||||||
|
t.Errorf("close gzip response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBaseURL = "https://api.mijia.tech/app"
|
||||||
|
defaultLoginURL = "https://account.xiaomi.com/longPolling/loginUrl"
|
||||||
|
defaultServiceLoginURL = "https://account.xiaomi.com/pass/serviceLogin"
|
||||||
|
availabilityTTL = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Option func(*Client) error
|
||||||
|
|
||||||
|
// WithHTTPClient configures the HTTP transport used by the client.
|
||||||
|
func WithHTTPClient(httpClient *http.Client) Option {
|
||||||
|
return func(client *Client) error {
|
||||||
|
if httpClient == nil {
|
||||||
|
return fmt.Errorf("HTTP client must not be nil")
|
||||||
|
}
|
||||||
|
client.httpClient = httpClient
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
authPath string
|
||||||
|
authMu sync.RWMutex
|
||||||
|
authData AuthData
|
||||||
|
loginMu sync.Mutex
|
||||||
|
httpClientMu sync.RWMutex
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
loginURL string
|
||||||
|
serviceLoginURL string
|
||||||
|
locale string
|
||||||
|
qrWriter io.Writer
|
||||||
|
|
||||||
|
availabilityMu sync.Mutex
|
||||||
|
availability bool
|
||||||
|
availabilityValid bool
|
||||||
|
availabilityAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(authPath string, options ...Option) (*Client, error) {
|
||||||
|
resolvedPath, err := resolveAuthPath(authPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := &Client{
|
||||||
|
authPath: resolvedPath,
|
||||||
|
httpClient: http.DefaultClient,
|
||||||
|
baseURL: defaultBaseURL,
|
||||||
|
loginURL: defaultLoginURL,
|
||||||
|
serviceLoginURL: defaultServiceLoginURL,
|
||||||
|
locale: systemLocale(),
|
||||||
|
qrWriter: os.Stdout,
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
if option == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := option(client); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.loadAuthData(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client.ensureIdentity()
|
||||||
|
client.initSession()
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAuthPath(authPath string) (string, error) {
|
||||||
|
if authPath == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "mijia-api", "auth.json"), nil
|
||||||
|
}
|
||||||
|
info, err := os.Stat(authPath)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return filepath.Join(authPath, "auth.json"), nil
|
||||||
|
}
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("inspect auth path: %w", err)
|
||||||
|
}
|
||||||
|
return authPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemLocale() string {
|
||||||
|
locale := strings.Split(os.Getenv("LC_ALL"), ".")[0]
|
||||||
|
if locale == "" {
|
||||||
|
locale = strings.Split(os.Getenv("LANG"), ".")[0]
|
||||||
|
}
|
||||||
|
if parts := strings.Split(locale, "_"); len(parts) == 2 && len(parts[1]) >= 2 {
|
||||||
|
return parts[0] + "_" + strings.ToUpper(parts[1][:2])
|
||||||
|
}
|
||||||
|
return "zh_CN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) initSession() {
|
||||||
|
client.httpClientMu.Lock()
|
||||||
|
defer client.httpClientMu.Unlock()
|
||||||
|
client.httpClient = cloneHTTPClient(client.httpClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneHTTPClient(httpClient *http.Client) *http.Client {
|
||||||
|
clone := *httpClient
|
||||||
|
clone.Jar, _ = cookiejar.New(nil)
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) session() *http.Client {
|
||||||
|
client.httpClientMu.RLock()
|
||||||
|
defer client.httpClientMu.RUnlock()
|
||||||
|
return client.httpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) newSession() *http.Client {
|
||||||
|
return cloneHTTPClient(client.session())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) request(ctx context.Context, uri string, data any, refresh bool) (json.RawMessage, error) {
|
||||||
|
if refresh {
|
||||||
|
if err := client.refreshToken(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request data: %w", err)
|
||||||
|
}
|
||||||
|
nonce, err := generateNonce()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
authData := client.AuthData()
|
||||||
|
signedNonceValue, err := signedNonce(authData.Ssecurity, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params, err := generateEncryptedParams(uri, http.MethodPost, signedNonceValue, nonce, string(payload), authData.Ssecurity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"data": {params.Data},
|
||||||
|
"rc4_hash__": {params.RC4Hash},
|
||||||
|
"signature": {params.Signature},
|
||||||
|
"ssecurity": {params.Ssecurity},
|
||||||
|
"_nonce": {params.Nonce},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+uri, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create API request: %w", err)
|
||||||
|
}
|
||||||
|
client.setAPIHeaders(req, authData)
|
||||||
|
response, err := client.session().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send API request: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := readHTTPResponse(response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read API response: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode API response from %s: %w", uri, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, error) {
|
||||||
|
var envelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Desc string `json:"desc"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||||
|
decrypted, decryptErr := decryptPayload(ssecurity, nonce, string(body))
|
||||||
|
if decryptErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode API response: %w", decryptErr)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(decrypted), &envelope); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode decrypted API response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if envelope.Code != 0 || envelope.Result == nil {
|
||||||
|
message := envelope.Message
|
||||||
|
if message == "" {
|
||||||
|
message = envelope.Desc
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = "未知错误"
|
||||||
|
}
|
||||||
|
return nil, &APIError{Code: envelope.Code, Message: message}
|
||||||
|
}
|
||||||
|
return envelope.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) setAPIHeaders(request *http.Request, authData AuthData) {
|
||||||
|
request.Header.Set("User-Agent", authData.UA)
|
||||||
|
request.Header.Set("Accept-Encoding", "identity")
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.Header.Set("miot-accept-encoding", "GZIP")
|
||||||
|
request.Header.Set("miot-encrypt-algorithm", "ENCRYPT-RC4")
|
||||||
|
request.Header.Set("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2")
|
||||||
|
request.Header.Set("Cookie", client.apiCookieHeader(authData))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) apiCookieHeader(authData AuthData) string {
|
||||||
|
now := time.Now()
|
||||||
|
_, offset := now.Zone()
|
||||||
|
zoneName := now.Location().String()
|
||||||
|
if zoneName == "Local" {
|
||||||
|
zoneName, _ = now.Zone()
|
||||||
|
}
|
||||||
|
isDaylight, dstOffset := daylightValues(now)
|
||||||
|
countryCode := "CN"
|
||||||
|
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
|
||||||
|
countryCode = parts[1]
|
||||||
|
}
|
||||||
|
timezone := fmt.Sprintf("GMT%+03d:%02d", offset/3600, abs(offset/60)%60)
|
||||||
|
values := []string{
|
||||||
|
"cUserId=" + authData.CUserID,
|
||||||
|
"yetAnotherServiceToken=" + authData.yetAnotherServiceToken(),
|
||||||
|
"serviceToken=" + authData.ServiceToken,
|
||||||
|
"timezone_id=" + zoneName,
|
||||||
|
"timezone=" + timezone,
|
||||||
|
"is_daylight=" + strconv.Itoa(isDaylight),
|
||||||
|
"dst_offset=" + strconv.Itoa(dstOffset),
|
||||||
|
"channel=MI_APP_STORE",
|
||||||
|
"countryCode=" + countryCode,
|
||||||
|
"PassportDeviceId=" + authData.DeviceID,
|
||||||
|
"locale=" + client.locale,
|
||||||
|
}
|
||||||
|
return strings.Join(values, ";")
|
||||||
|
}
|
||||||
|
|
||||||
|
func daylightValues(now time.Time) (int, int) {
|
||||||
|
location := now.Location()
|
||||||
|
dstOffset := 0
|
||||||
|
if now.In(location).IsDST() {
|
||||||
|
dstOffset = 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
for date := time.Date(now.Year(), time.January, 1, 12, 0, 0, 0, location); date.Year() == now.Year(); date = date.AddDate(0, 0, 1) {
|
||||||
|
if date.IsDST() {
|
||||||
|
return 1, dstOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, dstOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(value int) int {
|
||||||
|
if value < 0 {
|
||||||
|
return -value
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Available(ctx context.Context) (bool, error) {
|
||||||
|
if !client.AuthData().complete() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
client.availabilityMu.Lock()
|
||||||
|
defer client.availabilityMu.Unlock()
|
||||||
|
if client.availabilityValid && time.Since(client.availabilityAt) < availabilityTTL {
|
||||||
|
return client.availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := client.request(ctx, "/v2/message/v2/check_new_msg", map[string]int64{"begin_at": time.Now().Unix() - 3600}, false)
|
||||||
|
if err != nil {
|
||||||
|
client.availability = false
|
||||||
|
client.availabilityValid = false
|
||||||
|
client.availabilityAt = time.Time{}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
client.availability = true
|
||||||
|
client.availabilityValid = true
|
||||||
|
client.availabilityAt = time.Now()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
+456
@@ -0,0 +1,456 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestEncryptedPostAndPlainResponse(t *testing.T) {
|
||||||
|
var received url.Values
|
||||||
|
handlerErrors := make(chan error, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != "/test" {
|
||||||
|
handlerErrors <- fmt.Errorf("request = %s %s, want POST /test", request.Method, request.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := request.ParseForm(); err != nil {
|
||||||
|
handlerErrors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
received = request.PostForm
|
||||||
|
assertAPIHeaders(t, request)
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{"ok":true}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
result, err := client.request(context.Background(), "/test", map[string]any{"name": "lamp"}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request() error = %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-handlerErrors:
|
||||||
|
t.Fatal(err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if string(result) != `{"ok":true}` {
|
||||||
|
t.Fatalf("result = %s", result)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"data", "rc4_hash__", "signature", "ssecurity", "_nonce"} {
|
||||||
|
if received.Get(key) == "" {
|
||||||
|
t.Errorf("missing encrypted form field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if received.Get("ssecurity") != testSsecurity {
|
||||||
|
t.Errorf("ssecurity = %q", received.Get("ssecurity"))
|
||||||
|
}
|
||||||
|
assertAPICookies(t, receivedCookieHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
var receivedCookieHeader string
|
||||||
|
|
||||||
|
func assertAPIHeaders(t *testing.T, request *http.Request) {
|
||||||
|
t.Helper()
|
||||||
|
receivedCookieHeader = request.Header.Get("Cookie")
|
||||||
|
want := map[string]string{
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Miot-Accept-Encoding": "GZIP",
|
||||||
|
"Miot-Encrypt-Algorithm": "ENCRYPT-RC4",
|
||||||
|
"X-Xiaomi-Protocal-Flag-Cli": "PROTOCAL-HTTP2",
|
||||||
|
}
|
||||||
|
for key, value := range want {
|
||||||
|
if request.Header.Get(key) != value {
|
||||||
|
t.Errorf("header %s = %q, want %q", key, request.Header.Get(key), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAPICookies(t *testing.T, cookieHeader string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, value := range []string{
|
||||||
|
"cUserId=c-user", "yetAnotherServiceToken=service-token", "serviceToken=service-token",
|
||||||
|
"timezone_id=", "timezone=GMT", "is_daylight=", "dst_offset=", "channel=MI_APP_STORE",
|
||||||
|
"countryCode=CN", "PassportDeviceId=device-id", "locale=zh_CN",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(cookieHeader, value) {
|
||||||
|
t.Errorf("Cookie %q does not contain %q", cookieHeader, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaylightValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
locationName string
|
||||||
|
month time.Month
|
||||||
|
wantDaylight int
|
||||||
|
wantDSTOffset int
|
||||||
|
}{
|
||||||
|
{name: "northern winter", locationName: "America/New_York", month: time.January, wantDaylight: 1, wantDSTOffset: 0},
|
||||||
|
{name: "northern summer", locationName: "America/New_York", month: time.July, wantDaylight: 1, wantDSTOffset: 3600000},
|
||||||
|
{name: "southern summer", locationName: "Australia/Sydney", month: time.January, wantDaylight: 1, wantDSTOffset: 3600000},
|
||||||
|
{name: "southern winter", locationName: "Australia/Sydney", month: time.July, wantDaylight: 1, wantDSTOffset: 0},
|
||||||
|
{name: "Dublin negative DST winter", locationName: "Europe/Dublin", month: time.January, wantDaylight: 1, wantDSTOffset: 3600000},
|
||||||
|
{name: "Dublin standard summer", locationName: "Europe/Dublin", month: time.July, wantDaylight: 1, wantDSTOffset: 0},
|
||||||
|
{name: "no daylight saving", locationName: "Asia/Shanghai", month: time.July, wantDaylight: 0, wantDSTOffset: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation(test.locationName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2024, test.month, 15, 12, 0, 0, 0, location)
|
||||||
|
daylight, dstOffset := daylightValues(now)
|
||||||
|
if daylight != test.wantDaylight || dstOffset != test.wantDSTOffset {
|
||||||
|
t.Fatalf("daylightValues(%s) = (%d, %d), want (%d, %d)", now, daylight, dstOffset, test.wantDaylight, test.wantDSTOffset)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestDecryptsResponseAndReturnsAPIErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response func(*http.Request) string
|
||||||
|
wantCode int
|
||||||
|
wantResult string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "encrypted",
|
||||||
|
response: func(request *http.Request) string {
|
||||||
|
_ = request.ParseForm()
|
||||||
|
nonce := request.PostForm.Get("_nonce")
|
||||||
|
signed, _ := signedNonce(testSsecurity, nonce)
|
||||||
|
ciphertext, _ := encryptRC4(signed, `{"code":0,"result":[1,2]}`)
|
||||||
|
return ciphertext
|
||||||
|
},
|
||||||
|
wantResult: `[1,2]`,
|
||||||
|
},
|
||||||
|
{name: "api error", response: func(*http.Request) string { return `{"code":-10002,"message":"bad"}` }, wantCode: -10002},
|
||||||
|
{name: "missing result", response: func(*http.Request) string { return `{"code":0}` }, wantCode: 0},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
_, _ = io.WriteString(writer, test.response(request))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
result, err := client.request(context.Background(), "/test", map[string]any{}, false)
|
||||||
|
if test.wantCode != 0 || test.name == "missing result" {
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) || apiErr.Code != test.wantCode {
|
||||||
|
t.Fatalf("error = %v, want APIError code %d", err, test.wantCode)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || string(result) != test.wantResult {
|
||||||
|
t.Fatalf("result = %s, error = %v", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRejectsHTTPStatus(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
_, err := client.request(context.Background(), "/test", nil, false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "503") {
|
||||||
|
t.Fatalf("error = %v, want HTTP status error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRejectsOversizedRawResponse(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
|
||||||
|
_, err := client.request(context.Background(), "/test", nil, false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "raw HTTP response") || !strings.Contains(err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("error = %v, want oversized raw response error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRejectsOversizedGzipResponse(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Content-Encoding", "gzip")
|
||||||
|
gzipWriter := gzip.NewWriter(writer)
|
||||||
|
_, _ = gzipWriter.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
|
||||||
|
_ = gzipWriter.Close()
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
|
||||||
|
_, err := client.request(context.Background(), "/test", nil, false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "decompress gzip HTTP response") || !strings.Contains(err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("error = %v, want oversized gzip response error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentRequestsUseConsistentAuthSnapshot(t *testing.T) {
|
||||||
|
handlerErrors := make(chan error, 100)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := request.ParseForm(); err != nil {
|
||||||
|
handlerErrors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
security := request.PostForm.Get("ssecurity")
|
||||||
|
cookie := request.Header.Get("Cookie")
|
||||||
|
if security == testSsecurity && strings.Contains(cookie, "cUserId=c-user-a") && strings.Contains(cookie, "serviceToken=token-a") {
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if security == "QUJDREVGR0hJSktMTU5PUA==" && strings.Contains(cookie, "cUserId=c-user-b") && strings.Contains(cookie, "serviceToken=token-b") {
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlerErrors <- fmt.Errorf("mixed credentials: ssecurity=%q cookie=%q", security, cookie)
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
first := client.AuthData()
|
||||||
|
first.CUserID = "c-user-a"
|
||||||
|
first.ServiceToken = "token-a"
|
||||||
|
second := first.clone()
|
||||||
|
second.Ssecurity = "QUJDREVGR0hJSktMTU5PUA=="
|
||||||
|
second.CUserID = "c-user-b"
|
||||||
|
second.ServiceToken = "token-b"
|
||||||
|
client.setAuthData(first)
|
||||||
|
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
for index := range 100 {
|
||||||
|
client.setAuthData(first)
|
||||||
|
if index%2 == 1 {
|
||||||
|
client.setAuthData(second)
|
||||||
|
}
|
||||||
|
waitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
if _, err := client.request(context.Background(), "/test", nil, false); err != nil {
|
||||||
|
handlerErrors <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
waitGroup.Wait()
|
||||||
|
close(handlerErrors)
|
||||||
|
for err := range handlerErrors {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailableCachesSuccessfulProbe(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
handlerErrors := make(chan error, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
requests++
|
||||||
|
if request.URL.Path != "/v2/message/v2/check_new_msg" {
|
||||||
|
handlerErrors <- fmt.Errorf("path = %s", request.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
client.availabilityValid = false
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
available, err := client.Available(context.Background())
|
||||||
|
if err != nil || !available {
|
||||||
|
t.Fatalf("Available() = %v, %v", available, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requests != 1 {
|
||||||
|
t.Fatalf("requests = %d, want 1", requests)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-handlerErrors:
|
||||||
|
t.Fatal(err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRefreshesAfterFailedProbeBeforeBusinessRequest(t *testing.T) {
|
||||||
|
var server *httptest.Server
|
||||||
|
probeRequests := 0
|
||||||
|
businessRequests := 0
|
||||||
|
refreshRequests := 0
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/v2/message/v2/check_new_msg":
|
||||||
|
probeRequests++
|
||||||
|
_, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`)
|
||||||
|
case "/serviceLogin":
|
||||||
|
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"location":"`+server.URL+`/refresh","ssecurity":"`+testSsecurity+`"}`)
|
||||||
|
case "/refresh":
|
||||||
|
refreshRequests++
|
||||||
|
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")
|
||||||
|
case "/business":
|
||||||
|
businessRequests++
|
||||||
|
if !strings.Contains(request.Header.Get("Cookie"), "serviceToken=new-token") {
|
||||||
|
t.Errorf("business Cookie = %q", request.Header.Get("Cookie"))
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{"ok":true}}`)
|
||||||
|
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
|
||||||
|
client.updateAuthData(func(authData *AuthData) { authData.PassToken = "pass-token" })
|
||||||
|
result, err := client.request(context.Background(), "/business", nil, true)
|
||||||
|
if err != nil || string(result) != `{"ok":true}` {
|
||||||
|
t.Fatalf("request() = %s, %v", result, err)
|
||||||
|
}
|
||||||
|
if probeRequests != 1 || refreshRequests != 1 || businessRequests != 1 {
|
||||||
|
t.Fatalf("requests: probe=%d refresh=%d business=%d", probeRequests, refreshRequests, businessRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenUsesAvailableCache(t *testing.T) {
|
||||||
|
probeRequests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/v2/message/v2/check_new_msg" {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
probeRequests++
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
client.availabilityValid = false
|
||||||
|
for range 2 {
|
||||||
|
if err := client.refreshToken(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if probeRequests != 1 {
|
||||||
|
t.Fatalf("probe requests = %d, want 1", probeRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestUsesDistinctYetAnotherServiceToken(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
cookie := request.Header.Get("Cookie")
|
||||||
|
if !strings.Contains(cookie, "yetAnotherServiceToken=another-token") || !strings.Contains(cookie, "serviceToken=service-token") {
|
||||||
|
t.Errorf("Cookie = %q", cookie)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `{"code":0,"result":{}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
client.updateAuthData(func(authData *AuthData) { authData.YetAnotherServiceToken = "another-token" })
|
||||||
|
if _, err := client.request(context.Background(), "/test", nil, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailableRequiresAuthFields(t *testing.T) {
|
||||||
|
client, err := NewClient(t.TempDir(), WithHTTPClient(http.DefaultClient))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
available, err := client.Available(context.Background())
|
||||||
|
if err != nil || available {
|
||||||
|
t.Fatalf("Available() = %v, %v, want false, nil", available, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClient(t *testing.T, httpClient *http.Client) *Client {
|
||||||
|
t.Helper()
|
||||||
|
client, err := NewClient(t.TempDir(), WithHTTPClient(httpClient))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.locale = "zh_CN"
|
||||||
|
client.setAuthData(AuthData{
|
||||||
|
UA: "test-agent", DeviceID: "device-id", PassO: "pass-o", Ssecurity: testSsecurity,
|
||||||
|
UserID: "user", CUserID: "c-user", ServiceToken: "service-token",
|
||||||
|
ExpireTime: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||||
|
})
|
||||||
|
client.availability = true
|
||||||
|
client.availabilityValid = true
|
||||||
|
client.availabilityAt = time.Now()
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientsDoNotShareInjectedCookieJar(t *testing.T) {
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := &http.Client{
|
||||||
|
Transport: http.DefaultTransport,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
Jar: jar,
|
||||||
|
Timeout: time.Second,
|
||||||
|
}
|
||||||
|
first, err := NewClient(t.TempDir(), WithHTTPClient(source))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := NewClient(t.TempDir(), WithHTTPClient(source))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target, _ := url.Parse("https://example.com/")
|
||||||
|
first.session().Jar.SetCookies(target, []*http.Cookie{{Name: "client", Value: "first"}})
|
||||||
|
if cookies := second.session().Jar.Cookies(target); len(cookies) != 0 {
|
||||||
|
t.Fatalf("second client cookies = %v, want none", cookies)
|
||||||
|
}
|
||||||
|
if first.session().Transport != source.Transport || first.session().Timeout != source.Timeout || first.session().CheckRedirect == nil {
|
||||||
|
t.Fatal("HTTP client configuration was not preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthDataJSONFieldNames(t *testing.T) {
|
||||||
|
data, err := json.Marshal(AuthData{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"ua", "deviceId", "pass_o", "psecurity", "nonce", "ssecurity", "passToken", "userId", "cUserId", "serviceToken", "yetAnotherServiceToken", "expireTime", "saveTime"} {
|
||||||
|
if !strings.Contains(string(data), `"`+field+`"`) {
|
||||||
|
t.Errorf("JSON %s missing field %q", data, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rc4"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rc4DropBytes = 1024
|
||||||
|
maxDecompressedResponseBytes = 16 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type orderedParam struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type encryptedParams struct {
|
||||||
|
Data string
|
||||||
|
RC4Hash string
|
||||||
|
Signature string
|
||||||
|
Ssecurity string
|
||||||
|
Nonce string
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateNonce() (string, error) {
|
||||||
|
randomBytes := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(randomBytes); err != nil {
|
||||||
|
return "", fmt.Errorf("generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
minute := uint64(time.Now().Unix() / 60)
|
||||||
|
minuteBytes := make([]byte, 0, 8)
|
||||||
|
for value := minute; value > 0; value >>= 8 {
|
||||||
|
minuteBytes = append(minuteBytes, byte(value))
|
||||||
|
}
|
||||||
|
if len(minuteBytes) == 0 {
|
||||||
|
minuteBytes = append(minuteBytes, 0)
|
||||||
|
}
|
||||||
|
for left, right := 0, len(minuteBytes)-1; left < right; left, right = left+1, right-1 {
|
||||||
|
minuteBytes[left], minuteBytes[right] = minuteBytes[right], minuteBytes[left]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := append(randomBytes, minuteBytes...)
|
||||||
|
return base64.StdEncoding.EncodeToString(nonce), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedNonce(ssecurity, nonce string) (string, error) {
|
||||||
|
securityBytes, err := base64.StdEncoding.DecodeString(ssecurity)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decode ssecurity: %w", err)
|
||||||
|
}
|
||||||
|
nonceBytes, err := base64.StdEncoding.DecodeString(nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decode nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := sha256.New()
|
||||||
|
_, _ = digest.Write(securityBytes)
|
||||||
|
_, _ = digest.Write(nonceBytes)
|
||||||
|
return base64.StdEncoding.EncodeToString(digest.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptedSignature(uri, method string, params []orderedParam, nonce string) string {
|
||||||
|
parts := make([]string, 0, len(params)+3)
|
||||||
|
parts = append(parts, strings.ToUpper(method), uri)
|
||||||
|
for _, param := range params {
|
||||||
|
parts = append(parts, param.Key+"="+param.Value)
|
||||||
|
}
|
||||||
|
parts = append(parts, nonce)
|
||||||
|
digest := sha1.Sum([]byte(strings.Join(parts, "&")))
|
||||||
|
return base64.StdEncoding.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateEncryptedParams(uri, method, signedNonceValue, nonce, data, ssecurity string) (encryptedParams, error) {
|
||||||
|
plainParams := []orderedParam{{Key: "data", Value: data}}
|
||||||
|
rc4Hash := encryptedSignature(uri, method, plainParams, signedNonceValue)
|
||||||
|
|
||||||
|
encryptedData, err := encryptRC4(signedNonceValue, data)
|
||||||
|
if err != nil {
|
||||||
|
return encryptedParams{}, fmt.Errorf("encrypt data: %w", err)
|
||||||
|
}
|
||||||
|
encryptedRC4Hash, err := encryptRC4(signedNonceValue, rc4Hash)
|
||||||
|
if err != nil {
|
||||||
|
return encryptedParams{}, fmt.Errorf("encrypt rc4_hash__: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted := []orderedParam{
|
||||||
|
{Key: "data", Value: encryptedData},
|
||||||
|
{Key: "rc4_hash__", Value: encryptedRC4Hash},
|
||||||
|
}
|
||||||
|
return encryptedParams{
|
||||||
|
Data: encryptedData,
|
||||||
|
RC4Hash: encryptedRC4Hash,
|
||||||
|
Signature: encryptedSignature(uri, method, encrypted, signedNonceValue),
|
||||||
|
Ssecurity: ssecurity,
|
||||||
|
Nonce: nonce,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptRC4(password, payload string) (string, error) {
|
||||||
|
return encryptRC4Bytes(password, []byte(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptRC4Bytes(password string, payload []byte) (string, error) {
|
||||||
|
result, err := cryptRC4(password, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptRC4(password, payload string) ([]byte, error) {
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode RC4 payload: %w", err)
|
||||||
|
}
|
||||||
|
return cryptRC4(password, ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cryptRC4(password string, payload []byte) ([]byte, error) {
|
||||||
|
key, err := base64.StdEncoding.DecodeString(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode RC4 key: %w", err)
|
||||||
|
}
|
||||||
|
cipher, err := rc4.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create RC4 cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
discard := make([]byte, rc4DropBytes)
|
||||||
|
cipher.XORKeyStream(discard, discard)
|
||||||
|
result := make([]byte, len(payload))
|
||||||
|
cipher.XORKeyStream(result, payload)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptPayload(ssecurity, nonce, payload string) (string, error) {
|
||||||
|
signedNonceValue, err := signedNonce(ssecurity, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
decrypted, err := decryptRC4(signedNonceValue, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if utf8.Valid(decrypted) {
|
||||||
|
return string(decrypted), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := gzip.NewReader(bytes.NewReader(decrypted))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open gzip response: %w", err)
|
||||||
|
}
|
||||||
|
decompressed, readErr := io.ReadAll(io.LimitReader(reader, maxDecompressedResponseBytes+1))
|
||||||
|
closeErr := reader.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return "", fmt.Errorf("decompress response: %w", readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return "", fmt.Errorf("close gzip response: %w", closeErr)
|
||||||
|
}
|
||||||
|
if len(decompressed) > maxDecompressedResponseBytes {
|
||||||
|
return "", fmt.Errorf("decompressed response exceeds %d bytes", maxDecompressedResponseBytes)
|
||||||
|
}
|
||||||
|
if !utf8.Valid(decompressed) {
|
||||||
|
return "", fmt.Errorf("decrypted response is not valid UTF-8")
|
||||||
|
}
|
||||||
|
return string(decompressed), nil
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testSsecurity = "MDEyMzQ1Njc4OWFiY2RlZg=="
|
||||||
|
testNonce = "AAECAwQFBgcICQoL"
|
||||||
|
testSignedNonce = "16/CeTzC9IqVVbiZ01Hy/Qd8rtVo5ybLo+ph/Vvh52k="
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateNonce(t *testing.T) {
|
||||||
|
before := time.Now().Unix() / 60
|
||||||
|
nonce, err := generateNonce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateNonce() error = %v", err)
|
||||||
|
}
|
||||||
|
after := time.Now().Unix() / 60
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generated nonce is not base64: %v", err)
|
||||||
|
}
|
||||||
|
if len(decoded) <= 8 {
|
||||||
|
t.Fatalf("decoded nonce length = %d, want more than 8", len(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
minute := decodeBigEndian(decoded[8:])
|
||||||
|
if minute < before || minute > after {
|
||||||
|
t.Fatalf("nonce minute = %d, want between %d and %d", minute, before, after)
|
||||||
|
}
|
||||||
|
if len(decoded[8:]) > 1 && decoded[8] == 0 {
|
||||||
|
t.Fatal("nonce minute is not minimally encoded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignedNonce(t *testing.T) {
|
||||||
|
got, err := signedNonce(testSsecurity, testNonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signedNonce() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != testSignedNonce {
|
||||||
|
t.Fatalf("signedNonce() = %q, want %q", got, testSignedNonce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRC4Drop1024(t *testing.T) {
|
||||||
|
ciphertext, err := encryptRC4(testSignedNonce, "hello world")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encryptRC4() error = %v", err)
|
||||||
|
}
|
||||||
|
if ciphertext != "9ve6riTrkW1oJUE=" {
|
||||||
|
t.Fatalf("encryptRC4() = %q, want fixed ciphertext", ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := decryptRC4(testSignedNonce, ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decryptRC4() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(plaintext) != "hello world" {
|
||||||
|
t.Fatalf("decryptRC4() = %q, want %q", plaintext, "hello world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateEncryptedParams(t *testing.T) {
|
||||||
|
params, err := generateEncryptedParams("/miotspec/prop/get", "post", testSignedNonce, testNonce, "{\"k\":\"v\"}", testSsecurity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateEncryptedParams() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := encryptedParams{
|
||||||
|
Data: "5bC94HHpkCBn",
|
||||||
|
RC4Hash: "19WFsROvqTVVE0wemsEqz8GI7Ib8ZRiiLJS5hQ==",
|
||||||
|
Signature: "uRPqk6IHkFCAg+Skfo+5l/KbTpM=",
|
||||||
|
Ssecurity: testSsecurity,
|
||||||
|
Nonce: testNonce,
|
||||||
|
}
|
||||||
|
if params != want {
|
||||||
|
t.Fatalf("generateEncryptedParams() = %#v, want %#v", params, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstSignature := encryptedSignature("/miotspec/prop/get", "post", []orderedParam{{Key: "data", Value: "{\"k\":\"v\"}"}}, testSignedNonce)
|
||||||
|
if firstSignature != "IGSsXdO7OZiyN58pywgUYELur6g=" {
|
||||||
|
t.Fatalf("first signature = %q, want fixed signature", firstSignature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptPayload(t *testing.T) {
|
||||||
|
plaintext, err := decryptPayload(testSsecurity, testNonce, "9ve6riTrkW1oJUE=")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decryptPayload() plain error = %v", err)
|
||||||
|
}
|
||||||
|
if plaintext != "hello world" {
|
||||||
|
t.Fatalf("decryptPayload() plain = %q, want %q", plaintext, "hello world")
|
||||||
|
}
|
||||||
|
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
writer := gzip.NewWriter(&compressed)
|
||||||
|
if _, err := writer.Write([]byte("gzip response")); err != nil {
|
||||||
|
t.Fatalf("gzip write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("gzip close: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext, err := encryptRC4Bytes(testSignedNonce, compressed.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt compressed response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := decryptPayload(testSsecurity, testNonce, ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decryptPayload() gzip error = %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != "gzip response" {
|
||||||
|
t.Fatalf("decryptPayload() gzip = %q, want %q", decrypted, "gzip response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptPayloadInvalidBase64(t *testing.T) {
|
||||||
|
_, err := decryptPayload(testSsecurity, testNonce, "not-base64")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "decode RC4 payload") {
|
||||||
|
t.Fatalf("decryptPayload() error = %v, want invalid base64 error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptPayloadDamagedGzip(t *testing.T) {
|
||||||
|
ciphertext, err := encryptRC4Bytes(testSignedNonce, []byte{0x1f, 0x8b, 0xff})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt damaged gzip response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = decryptPayload(testSsecurity, testNonce, ciphertext)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "open gzip response") {
|
||||||
|
t.Fatalf("decryptPayload() error = %v, want damaged gzip error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptPayloadDecompressedLimit(t *testing.T) {
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
writer := gzip.NewWriter(&compressed)
|
||||||
|
oversized := bytes.Repeat([]byte{0xff}, maxDecompressedResponseBytes+1)
|
||||||
|
if _, err := writer.Write(oversized); err != nil {
|
||||||
|
t.Fatalf("gzip write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("gzip close: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext, err := encryptRC4Bytes(testSignedNonce, compressed.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt oversized gzip response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = decryptPayload(testSsecurity, testNonce, ciphertext)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "decompressed response exceeds") {
|
||||||
|
t.Fatalf("decryptPayload() error = %v, want decompressed limit error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBigEndian(value []byte) int64 {
|
||||||
|
var result int64
|
||||||
|
for _, current := range value {
|
||||||
|
result = result<<8 | int64(current)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
+707
@@ -0,0 +1,707 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceTestServer struct {
|
||||||
|
t *testing.T
|
||||||
|
fixture []byte
|
||||||
|
mu sync.Mutex
|
||||||
|
specCalls int
|
||||||
|
apiResults []string
|
||||||
|
requests []map[string]any
|
||||||
|
status int
|
||||||
|
server *httptest.Server
|
||||||
|
specPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDeviceTestServer(t *testing.T, fixture []byte, apiResults []string) *deviceTestServer {
|
||||||
|
t.Helper()
|
||||||
|
testServer := &deviceTestServer{t: t, fixture: fixture, apiResults: apiResults}
|
||||||
|
testServer.server = httptest.NewServer(http.HandlerFunc(testServer.handle))
|
||||||
|
oldSpecURL := deviceSpecURL
|
||||||
|
deviceSpecURL = testServer.server.URL + "/spec/"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
deviceSpecURL = oldSpecURL
|
||||||
|
testServer.server.Close()
|
||||||
|
})
|
||||||
|
return testServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testServer *deviceTestServer) handle(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
testServer.mu.Lock()
|
||||||
|
defer testServer.mu.Unlock()
|
||||||
|
if strings.HasPrefix(request.URL.Path, "/spec/") {
|
||||||
|
testServer.specCalls++
|
||||||
|
testServer.specPaths = append(testServer.specPaths, request.URL.Path)
|
||||||
|
if request.Method != http.MethodGet {
|
||||||
|
testServer.t.Errorf("spec method = %q", request.Method)
|
||||||
|
}
|
||||||
|
if request.Header.Get("User-Agent") != "mijiaAPI/4.1.2" {
|
||||||
|
testServer.t.Errorf("spec User-Agent = %q", request.Header.Get("User-Agent"))
|
||||||
|
}
|
||||||
|
status := testServer.status
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
writer.WriteHeader(status)
|
||||||
|
_, _ = writer.Write(testServer.fixture)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := request.ParseForm(); err != nil {
|
||||||
|
testServer.t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := testAuthData()
|
||||||
|
decoded, err := decryptPayload(auth.Ssecurity, request.PostForm.Get("_nonce"), request.PostForm.Get("data"))
|
||||||
|
if err != nil {
|
||||||
|
testServer.t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(decoded), &payload); err != nil {
|
||||||
|
testServer.t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testServer.requests = append(testServer.requests, payload)
|
||||||
|
if len(testServer.apiResults) == 0 {
|
||||||
|
testServer.t.Errorf("unexpected API request %s", request.URL.Path)
|
||||||
|
http.Error(writer, "unexpected request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := testServer.apiResults[0]
|
||||||
|
testServer.apiResults = testServer.apiResults[1:]
|
||||||
|
_, _ = fmt.Fprintf(writer, `{"code":0,"result":%s}`, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAuthData() AuthData {
|
||||||
|
return AuthData{UA: "test", DeviceID: "device", PassO: "pass", Ssecurity: testSsecurity, UserID: "user", CUserID: "cuser", ServiceToken: "token", ExpireTime: time.Now().Add(time.Hour).UnixMilli()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSpecFixture(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
fixture, err := os.ReadFile("testdata/miot-spec.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
||||||
|
httpClient := testServer.server.Client()
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
info, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Name != "Test Lamp" || info.Model != "test.light.v1" || len(info.Properties) != 8 || len(info.Actions) != 2 {
|
||||||
|
t.Fatalf("info = %#v", info)
|
||||||
|
}
|
||||||
|
if got := info.Properties[0]; got.Name != "power" || got.Description != "Power / 电源" || got.Type != "bool" || got.RW != "rw" || got.SIID != 2 || got.PIID != 1 {
|
||||||
|
t.Fatalf("power = %#v", got)
|
||||||
|
}
|
||||||
|
if got := info.Properties[1]; got.Type != "uint" || len(got.Range) != 3 || got.Range[0].String() != "1" {
|
||||||
|
t.Fatalf("brightness = %#v", got)
|
||||||
|
}
|
||||||
|
if got := info.Properties[2].ValueList[1]; got.Value.String() != "1" || got.Description != "On" || got.DescZhCN != "开启" {
|
||||||
|
t.Fatalf("value list = %#v", got)
|
||||||
|
}
|
||||||
|
if info.Properties[7].Name != "outlet-power" || info.Actions[1].Name != "outlet-toggle" {
|
||||||
|
t.Fatalf("duplicates = %#v / %#v", info.Properties[7], info.Actions[1])
|
||||||
|
}
|
||||||
|
if testServer.specCalls != 1 || testServer.specPaths[0] != "/spec/test.light.v1" {
|
||||||
|
t.Fatalf("spec requests = %v, want GET /spec/test.light.v1", testServer.specPaths)
|
||||||
|
}
|
||||||
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
||||||
|
stat, err := os.Stat(cachePath)
|
||||||
|
if err != nil || stat.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("cache stat = %v, %v", stat, err)
|
||||||
|
}
|
||||||
|
cacheData, err := os.ReadFile(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var pythonCache struct {
|
||||||
|
Properties []struct {
|
||||||
|
Method cacheMethod `json:"method"`
|
||||||
|
} `json:"properties"`
|
||||||
|
Actions []struct {
|
||||||
|
Method cacheMethod `json:"method"`
|
||||||
|
} `json:"actions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(cacheData, &pythonCache); err != nil || pythonCache.Properties[0].Method.SIID != 2 || pythonCache.Properties[0].Method.PIID != 1 || pythonCache.Actions[0].Method.SIID != 2 || pythonCache.Actions[0].Method.AIID != 1 {
|
||||||
|
t.Fatalf("Python-compatible cache = %#v, %v", pythonCache, err)
|
||||||
|
}
|
||||||
|
testServer.fixture = nil
|
||||||
|
cached, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir)
|
||||||
|
if err != nil || cached.Name != info.Name || testServer.specCalls != 1 {
|
||||||
|
t.Fatalf("cached = %#v, %v, calls=%d", cached, err, testServer.specCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
||||||
|
const pythonCache = `{
|
||||||
|
"name": "Python Lamp",
|
||||||
|
"model": "test.light.v1",
|
||||||
|
"properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}],
|
||||||
|
"actions": [{"name":"toggle","description":"Toggle","method":{"siid":9,"aiid":10}}]
|
||||||
|
}`
|
||||||
|
testServer := newDeviceTestServer(t, nil, []string{
|
||||||
|
`{"homelist":[{"id":"10","uid":1}]}`,
|
||||||
|
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
||||||
|
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
|
||||||
|
`[{"did":"a","siid":7,"piid":8,"code":0}]`,
|
||||||
|
`{"did":"a","siid":9,"aiid":10,"code":0}`,
|
||||||
|
})
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(pythonCache), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := testClient(t, testServer.server.Client())
|
||||||
|
client.baseURL = testServer.server.URL
|
||||||
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0), WithDeviceCacheDir(cacheDir))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := device.Get(context.Background(), "power"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := device.Set(context.Background(), "power", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := device.RunAction(context.Background(), "toggle", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index, want := range []map[string]any{
|
||||||
|
{"siid": float64(7), "piid": float64(8)},
|
||||||
|
{"siid": float64(7), "piid": float64(8)},
|
||||||
|
{"siid": float64(9), "aiid": float64(10)},
|
||||||
|
} {
|
||||||
|
params := testServer.requests[index+2]["params"]
|
||||||
|
var request map[string]any
|
||||||
|
if list, ok := params.([]any); ok {
|
||||||
|
request = list[0].(map[string]any)
|
||||||
|
} else {
|
||||||
|
request = params.(map[string]any)
|
||||||
|
}
|
||||||
|
for key, value := range want {
|
||||||
|
if request[key] != value {
|
||||||
|
t.Fatalf("request %d %s = %#v, want %#v", index, key, request[key], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if testServer.specCalls != 0 {
|
||||||
|
t.Fatalf("spec calls = %d, want 0", testServer.specCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cache string
|
||||||
|
}{
|
||||||
|
{name: "empty object", cache: `{}`},
|
||||||
|
{name: "wrong model", cache: `{"model":"other.model","properties":[],"actions":[]}`},
|
||||||
|
{name: "zero ID", cache: `{"model":"test.light.v1","properties":[{"name":"power","type":"bool","rw":"rw","siid":0,"piid":1}],"actions":[]}`},
|
||||||
|
{name: "trailing JSON", cache: `{"model":"test.light.v1","properties":[],"actions":[]} {}`},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
||||||
|
if err := os.WriteFile(cachePath, []byte(test.cache), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
||||||
|
if err != nil || info.Model != "test.light.v1" || testServer.specCalls != 1 {
|
||||||
|
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := decodeDeviceInfo(data, "test.light.v1"); err != nil {
|
||||||
|
t.Fatalf("refreshed cache is invalid: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
|
||||||
|
testServer.status = http.StatusServiceUnavailable
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
||||||
|
if err := os.WriteFile(cachePath, []byte(`{}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid device info cache") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(cachePath)
|
||||||
|
if readErr != nil || string(data) != `{}` {
|
||||||
|
t.Fatalf("cache changed after failed refresh: %q, %v", data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDeviceInfoErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
fixture string
|
||||||
|
}{
|
||||||
|
{name: "http", status: http.StatusNotFound, fixture: "not found"},
|
||||||
|
{name: "missing script", fixture: "<html></html>"},
|
||||||
|
{name: "invalid json", fixture: `<script data-page="app" type="application/json">{</script>`},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, []byte(test.fixture), nil)
|
||||||
|
testServer.status = test.status
|
||||||
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "bad.model", "")
|
||||||
|
var infoErr *GetDeviceInfoError
|
||||||
|
if !errors.As(err, &infoErr) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
if test.status != 0 && !strings.Contains(err.Error(), fmt.Sprint(test.status)) {
|
||||||
|
t.Fatalf("error = %v, want status %d", err, test.status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDeviceInfoRejectsUnsafeModelsBeforeFileAccess(t *testing.T) {
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
parent := filepath.Dir(cacheDir)
|
||||||
|
for _, model := range []string{"", "../escaped", "/tmp/absolute", `vendor\\model`, "vendor/model"} {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
_, err := GetDeviceInfo(context.Background(), http.DefaultClient, model, cacheDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("GetDeviceInfo() error = nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(cacheDir)
|
||||||
|
if err != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("cache entries = %v, %v", entries, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(parent, "escaped.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("escaped cache file exists: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDeviceInfoRejectsOversizedContent(t *testing.T) {
|
||||||
|
t.Run("cache", func(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
cachePath := filepath.Join(cacheDir, "test.model.json")
|
||||||
|
if err := os.WriteFile(cachePath, make([]byte, deviceSpecMaxSize+1), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", cacheDir)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "model") || testServer.specCalls != 1 {
|
||||||
|
t.Fatalf("error = %v, calls=%d", err, testServer.specCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HTML", func(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, make([]byte, deviceSpecMaxSize+1), nil)
|
||||||
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", "")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("error = %v, want HTML size error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDeviceInfoHTMLScriptTagVariants(t *testing.T) {
|
||||||
|
fixture := string(loadSpecFixture(t))
|
||||||
|
const opening = `<script data-page="app" type="application/json">`
|
||||||
|
const closing = `</script>`
|
||||||
|
variants := []struct {
|
||||||
|
name string
|
||||||
|
opening string
|
||||||
|
closing string
|
||||||
|
}{
|
||||||
|
{name: "reordered", opening: `<script type="application/json" data-page="app">`, closing: closing},
|
||||||
|
{name: "extra attributes", opening: `<script defer data-extra='value' data-page='app' type='application/json'>`, closing: closing},
|
||||||
|
{name: "whitespace", opening: "<script\n type = 'application/json'\n async\n data-page = \"app\" >", closing: closing},
|
||||||
|
{name: "uppercase tags and attributes", opening: `<SCRIPT DATA-PAGE="app" TYPE="application/json">`, closing: `</SCRIPT>`},
|
||||||
|
{name: "unquoted attributes", opening: `<script TYPE=application/json DATA-PAGE=app>`, closing: closing},
|
||||||
|
}
|
||||||
|
for _, variant := range variants {
|
||||||
|
t.Run(variant.name, func(t *testing.T) {
|
||||||
|
body := strings.Replace(fixture, opening, variant.opening, 1)
|
||||||
|
openingIndex := strings.Index(body, variant.opening)
|
||||||
|
body = body[:openingIndex] + strings.Replace(body[openingIndex:], closing, variant.closing, 1)
|
||||||
|
info, err := parseDeviceInfoHTML([]byte(body))
|
||||||
|
if err != nil || info.Model != "test.light.v1" {
|
||||||
|
t.Fatalf("parseDeviceInfoHTML() = %#v, %v", info, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDeviceSelectorsAndAliases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
selector DeviceSelector
|
||||||
|
devices string
|
||||||
|
wantDID string
|
||||||
|
wantErrAs any
|
||||||
|
}{
|
||||||
|
{name: "did wins", selector: DeviceSelector{DID: "a", Name: "duplicate"}, devices: `[{"did":"a","name":"Lamp","model":"test.light.v1"}]`, wantDID: "a"},
|
||||||
|
{name: "unique name", selector: DeviceSelector{Name: "Lamp"}, devices: `[{"did":"a","name":"Lamp","model":"test.light.v1"}]`, wantDID: "a"},
|
||||||
|
{name: "missing selector", selector: DeviceSelector{}, devices: `[]`, wantErrAs: &DeviceNotFoundError{}},
|
||||||
|
{name: "not found", selector: DeviceSelector{DID: "x"}, devices: `[]`, wantErrAs: &DeviceNotFoundError{}},
|
||||||
|
{name: "duplicate name", selector: DeviceSelector{Name: "Lamp"}, devices: `[{"did":"a","name":"Lamp"},{"did":"b","name":"Lamp"}]`, wantErrAs: &MultipleDevicesFoundError{}},
|
||||||
|
{name: "duplicate did", selector: DeviceSelector{DID: "a"}, devices: `[{"did":"a","name":"Lamp"},{"did":"a","name":"Clone"}]`, wantErrAs: &MultipleDevicesFoundError{}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":` + test.devices + `,"has_more":false}`})
|
||||||
|
client := testClient(t, testServer.server.Client())
|
||||||
|
client.baseURL = testServer.server.URL
|
||||||
|
device, err := NewDevice(context.Background(), client, test.selector, WithDeviceDelay(0), WithDeviceCacheDir(t.TempDir()))
|
||||||
|
if test.wantErrAs != nil {
|
||||||
|
if err == nil || !errors.As(err, &test.wantErrAs) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || device.DID != test.wantDID || device.Properties()["serial_number"].Name != "serial-number" {
|
||||||
|
t.Fatalf("device = %#v, %v", device, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDeviceDefaults(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","model":"test.light.v1"}],"has_more":false}`})
|
||||||
|
client := testClient(t, testServer.server.Client())
|
||||||
|
client.baseURL = testServer.server.URL
|
||||||
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if device.delay != 500*time.Millisecond {
|
||||||
|
t.Fatalf("delay = %v, want 500ms", device.delay)
|
||||||
|
}
|
||||||
|
cachePath := filepath.Join(filepath.Dir(client.authPath), "test.light.v1.json")
|
||||||
|
if _, err := os.Stat(cachePath); err != nil {
|
||||||
|
t.Fatalf("default cache path %s: %v", cachePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceGetSetAndAction(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{
|
||||||
|
`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
||||||
|
`[{"did":"a","siid":2,"piid":1,"value":true,"code":0}]`,
|
||||||
|
`[{"did":"a","siid":2,"piid":1,"code":1}]`,
|
||||||
|
`{"did":"a","siid":2,"aiid":1,"code":1,"out":{"ok":true}}`,
|
||||||
|
})
|
||||||
|
client := testClient(t, testServer.server.Client())
|
||||||
|
client.baseURL = testServer.server.URL
|
||||||
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0), WithDeviceCacheDir(t.TempDir()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
properties := device.Properties()
|
||||||
|
properties["power"] = PropertySpec{}
|
||||||
|
properties["brightness"].Range[0] = "999"
|
||||||
|
actions := device.Actions()
|
||||||
|
actions["toggle"] = ActionSpec{}
|
||||||
|
delete(properties, "serial-number")
|
||||||
|
delete(actions, "outlet-toggle")
|
||||||
|
if device.Properties()["power"].SIID != 2 || device.Properties()["brightness"].Range[0].String() != "1" || device.Actions()["toggle"].SIID != 2 {
|
||||||
|
t.Fatal("mutating snapshots changed internal device metadata")
|
||||||
|
}
|
||||||
|
value, err := device.Get(context.Background(), "power")
|
||||||
|
if err != nil || value != true {
|
||||||
|
t.Fatalf("Get() = %v, %v", value, err)
|
||||||
|
}
|
||||||
|
if err := device.Set(context.Background(), "power", "1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := device.RunAction(context.Background(), "toggle", nil)
|
||||||
|
if err != nil || string(result.Out) != `{"ok":true}` {
|
||||||
|
t.Fatalf("RunAction() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if testServer.requests[3]["params"].([]any)[0].(map[string]any)["value"] != true {
|
||||||
|
t.Fatalf("set request = %#v", testServer.requests[3])
|
||||||
|
}
|
||||||
|
if _, exists := testServer.requests[4]["params"].(map[string]any)["value"]; exists {
|
||||||
|
t.Fatalf("nil action value was sent: %#v", testServer.requests[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceMetadataSnapshotsSupportConcurrentReads(t *testing.T) {
|
||||||
|
device := fixtureDevice(t)
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
for range 100 {
|
||||||
|
waitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
if device.Properties()["power"].Name != "power" || device.Actions()["toggle"].Name != "toggle" {
|
||||||
|
t.Error("metadata snapshot changed during concurrent read")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
waitGroup.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceValidation(t *testing.T) {
|
||||||
|
device := fixtureDevice(t)
|
||||||
|
successes := []struct {
|
||||||
|
property string
|
||||||
|
value any
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{property: "power", value: int8(0), want: false},
|
||||||
|
{property: "brightness", value: "20", want: uint64(20)},
|
||||||
|
{property: "mode", value: json.Number("1.0"), want: uint64(1)},
|
||||||
|
{property: "temperature", value: 0.3, want: 0.3},
|
||||||
|
{property: "serial-number", value: "abc", want: "abc"},
|
||||||
|
}
|
||||||
|
for _, test := range successes {
|
||||||
|
property := device.Properties()[test.property]
|
||||||
|
got, err := convertPropertyValue(property, test.value)
|
||||||
|
if err != nil || got != test.want {
|
||||||
|
t.Errorf("convertPropertyValue(%s, %v) = %#v, %v, want %#v", test.property, test.value, got, err, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prop string
|
||||||
|
value any
|
||||||
|
}{
|
||||||
|
{name: "unknown", prop: "missing", value: 1},
|
||||||
|
{name: "read only", prop: "read-only", value: 1},
|
||||||
|
{name: "bad bool integer", prop: "power", value: 2},
|
||||||
|
{name: "bad bool string", prop: "power", value: "yes"},
|
||||||
|
{name: "fractional integer", prop: "brightness", value: 1.5},
|
||||||
|
{name: "integer range", prop: "brightness", value: 101},
|
||||||
|
{name: "integer step", prop: "brightness", value: 2.5},
|
||||||
|
{name: "float step", prop: "temperature", value: 0.35},
|
||||||
|
{name: "value list", prop: "mode", value: json.Number("2")},
|
||||||
|
{name: "string", prop: "serial_number", value: 3},
|
||||||
|
{name: "non finite", prop: "temperature", value: math.Inf(1)},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if err := device.Set(context.Background(), test.prop, test.value); err == nil {
|
||||||
|
t.Fatal("Set() error = nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := device.Get(context.Background(), "write-only"); err == nil {
|
||||||
|
t.Fatal("Get(write-only) error = nil")
|
||||||
|
}
|
||||||
|
if _, err := device.RunAction(context.Background(), "missing", nil); err == nil {
|
||||||
|
t.Fatal("RunAction(missing) error = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegerValidationIsExact(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
property PropertySpec
|
||||||
|
value any
|
||||||
|
want any
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "int above 2^53", property: PropertySpec{Type: "int"}, value: "9007199254740993", want: int64(9007199254740993)},
|
||||||
|
{name: "max int64", property: PropertySpec{Type: "int", Range: []json.Number{"-9223372036854775808", "9223372036854775807"}}, value: json.Number("9223372036854775807"), want: int64(math.MaxInt64)},
|
||||||
|
{name: "max uint64", property: PropertySpec{Type: "uint", Range: []json.Number{"0", "18446744073709551615"}}, value: "18446744073709551615", want: uint64(math.MaxUint64)},
|
||||||
|
{name: "int overflow string", property: PropertySpec{Type: "int"}, value: "9223372036854775808", wantErr: true},
|
||||||
|
{name: "uint overflow string", property: PropertySpec{Type: "uint"}, value: "18446744073709551616", wantErr: true},
|
||||||
|
{name: "fractional number", property: PropertySpec{Type: "int"}, value: json.Number("9007199254740993.5"), wantErr: true},
|
||||||
|
{name: "imprecise float", property: PropertySpec{Type: "uint"}, value: float64(9007199254740994), wantErr: true},
|
||||||
|
{name: "exact value list", property: PropertySpec{Type: "uint", ValueList: []ValueListItem{{Value: "9007199254740993"}}}, value: "9007199254740993", want: uint64(9007199254740993)},
|
||||||
|
{name: "adjacent value list rejected", property: PropertySpec{Type: "uint", ValueList: []ValueListItem{{Value: "9007199254740993"}}}, value: "9007199254740992", wantErr: true},
|
||||||
|
{name: "exact range and step", property: PropertySpec{Type: "uint", Range: []json.Number{"9007199254740993", "9007199254740999", "2"}}, value: "9007199254740997", want: uint64(9007199254740997)},
|
||||||
|
{name: "exact step rejected", property: PropertySpec{Type: "uint", Range: []json.Number{"9007199254740993", "9007199254740999", "2"}}, value: "9007199254740996", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := convertPropertyValue(test.property, test.value)
|
||||||
|
if test.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("convertPropertyValue() = %#v, want error", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || got != test.want {
|
||||||
|
t.Fatalf("convertPropertyValue() = %#v, %v, want %#v", got, err, test.want)
|
||||||
|
}
|
||||||
|
marshaled, err := json.Marshal(got)
|
||||||
|
if err != nil || string(marshaled) != fmt.Sprint(test.want) {
|
||||||
|
t.Fatalf("json.Marshal() = %s, %v", marshaled, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFloatIntegerInputsMustBeExactlyRepresentable(t *testing.T) {
|
||||||
|
property := PropertySpec{Type: "float"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "positive boundary", value: int64(1 << 53)},
|
||||||
|
{name: "negative boundary", value: int64(-(1 << 53))},
|
||||||
|
{name: "int above boundary", value: int64(1<<53 + 1), wantErr: true},
|
||||||
|
{name: "uint above boundary", value: uint64(1<<53 + 1), wantErr: true},
|
||||||
|
{name: "JSON number boundary", value: json.Number("9007199254740992")},
|
||||||
|
{name: "JSON number above boundary", value: json.Number("9007199254740993"), wantErr: true},
|
||||||
|
{name: "string positive boundary", value: "9007199254740992"},
|
||||||
|
{name: "string negative boundary", value: "-9007199254740992"},
|
||||||
|
{name: "string above boundary", value: "9007199254740993", wantErr: true},
|
||||||
|
{name: "string below boundary", value: "-9007199254740993", wantErr: true},
|
||||||
|
{name: "fractional string", value: "9007199254740993.5"},
|
||||||
|
{name: "max uint64", value: uint64(math.MaxUint64), wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := convertPropertyValue(property, test.value)
|
||||||
|
if (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("convertPropertyValue(%v) error = %v, wantErr %v", test.value, err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceOperationErrorsAndCancellation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result string
|
||||||
|
operation func(*Device) error
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{name: "get", result: `[{"code":-704030013}]`, operation: func(device *Device) error { _, err := device.Get(context.Background(), "power"); return err }, want: &DeviceGetError{}},
|
||||||
|
{name: "set", result: `[{"code":-704030023}]`, operation: func(device *Device) error { return device.Set(context.Background(), "power", true) }, want: &DeviceSetError{}},
|
||||||
|
{name: "action", result: `{"code":-704040005}`, operation: func(device *Device) error {
|
||||||
|
_, err := device.RunAction(context.Background(), "toggle", nil)
|
||||||
|
return err
|
||||||
|
}, want: &DeviceActionError{}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
device := fixtureDeviceWithResults(t, []string{test.result}, 0)
|
||||||
|
err := test.operation(device)
|
||||||
|
if !errors.As(err, &test.want) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
device := fixtureDeviceWithResults(t, []string{`[{"value":true,"code":0}]`}, time.Hour)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
time.AfterFunc(10*time.Millisecond, cancel)
|
||||||
|
started := time.Now()
|
||||||
|
_, err := device.Get(ctx, "power")
|
||||||
|
if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second {
|
||||||
|
t.Fatalf("Get() cancellation = %v after %v", err, time.Since(started))
|
||||||
|
}
|
||||||
|
|
||||||
|
device = fixtureDeviceWithResults(t, []string{`{"code":0}`}, time.Hour)
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
started = time.Now()
|
||||||
|
_, err = device.RunAction(ctx, "toggle", nil)
|
||||||
|
if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second {
|
||||||
|
t.Fatalf("RunAction() cancellation = %v after %v", err, time.Since(started))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceRunActionWith(t *testing.T) {
|
||||||
|
t.Run("sends extra in field like xiaoai speaker", func(t *testing.T) {
|
||||||
|
device, testServer := fixtureDeviceWithServer(t, []string{`{"did":"a","siid":2,"aiid":1,"code":0}`})
|
||||||
|
result, err := device.RunActionWith(context.Background(), "toggle", nil, map[string]any{"in": []any{"打开空调", 1}})
|
||||||
|
if err != nil || result.Code != 0 {
|
||||||
|
t.Fatalf("RunActionWith() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
params, ok := testServer.requests[2]["params"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("params = %#v", testServer.requests[2]["params"])
|
||||||
|
}
|
||||||
|
if len(params) != 4 || params["did"] != "a" || params["siid"] != float64(2) || params["aiid"] != float64(1) {
|
||||||
|
t.Fatalf("params = %#v", params)
|
||||||
|
}
|
||||||
|
in, ok := params["in"].([]any)
|
||||||
|
if !ok || len(in) != 2 || in[0] != "打开空调" || in[1] != float64(1) {
|
||||||
|
t.Fatalf("in = %#v", params["in"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("conflicting extra key fails before sending", func(t *testing.T) {
|
||||||
|
device, testServer := fixtureDeviceWithServer(t, nil)
|
||||||
|
for _, key := range []string{"did", "siid", "aiid", "value"} {
|
||||||
|
if _, err := device.RunActionWith(context.Background(), "toggle", nil, map[string]any{key: "x"}); err == nil {
|
||||||
|
t.Fatalf("RunActionWith() with extra key %q error = nil", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(testServer.requests) != 2 {
|
||||||
|
t.Fatalf("requests = %d, conflicting extra keys must not reach the API", len(testServer.requests))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil extra matches RunAction", func(t *testing.T) {
|
||||||
|
device, testServer := fixtureDeviceWithServer(t, []string{`{"did":"a","siid":2,"aiid":1,"code":0}`, `{"did":"a","siid":2,"aiid":1,"code":0}`})
|
||||||
|
if _, err := device.RunAction(context.Background(), "toggle", []any{5}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := device.RunActionWith(context.Background(), "toggle", []any{5}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacy, _ := json.Marshal(testServer.requests[2]["params"])
|
||||||
|
with, _ := json.Marshal(testServer.requests[3]["params"])
|
||||||
|
if string(legacy) != string(with) {
|
||||||
|
t.Fatalf("RunActionWith(nil) body %s != RunAction body %s", with, legacy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureDevice(t *testing.T) *Device {
|
||||||
|
return fixtureDeviceWithResults(t, nil, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureDeviceWithResults(t *testing.T, operationResults []string, delay time.Duration) *Device {
|
||||||
|
t.Helper()
|
||||||
|
device, _ := fixtureDeviceWithOptions(t, operationResults, delay)
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureDeviceWithServer(t *testing.T, operationResults []string) (*Device, *deviceTestServer) {
|
||||||
|
t.Helper()
|
||||||
|
return fixtureDeviceWithOptions(t, operationResults, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureDeviceWithOptions(t *testing.T, operationResults []string, delay time.Duration) (*Device, *deviceTestServer) {
|
||||||
|
t.Helper()
|
||||||
|
results := append([]string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`}, operationResults...)
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), results)
|
||||||
|
client := testClient(t, testServer.server.Client())
|
||||||
|
client.baseURL = testServer.server.URL
|
||||||
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(delay), WithDeviceCacheDir(t.TempDir()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return device, testServer
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# mijia-api Go Refactor Implementation Plan
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This document may not reflect the current implementation.
|
||||||
|
> See the final report for up-to-date state:
|
||||||
|
> [Final Report](../reports/mijia-go-api.md)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a pure Go library equivalent to the Python mijia-api core and high-level device API, excluding CLI, skills, MCP, and packet-decryption tools.
|
||||||
|
|
||||||
|
**Architecture:** A single `mijia` package separates deterministic cryptography, authenticated HTTP transport, public API methods, and MIoT device metadata. Public methods accept `context.Context`; flexible Xiaomi payloads use typed request structures plus `json.RawMessage` results where schemas vary.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.22+, standard library, `github.com/mdp/qrterminal/v3` only for terminal QR rendering.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Module path is `git.misaka.ren/m1saka/mijia-go-api` and package name is `mijia`.
|
||||||
|
- Preserve Python v4.1.2 endpoints, compact JSON encoding, ordered signing parameters, and RC4-drop-1024 behavior.
|
||||||
|
- Include login/token refresh, homes, devices, shared devices, scenes, consumables, properties, actions, statistics, and high-level device access.
|
||||||
|
- Exclude CLI, MCP, skills, documentation site, and HAR/decrypt utilities.
|
||||||
|
- Keep network dependencies injectable through `*http.Client`; tests must not require Xiaomi services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Module, errors, and encryption
|
||||||
|
|
||||||
|
**Covers:** [S2, S3, S4, S7, S8]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `go.mod`
|
||||||
|
- Create: `errors.go`
|
||||||
|
- Create: `crypto.go`
|
||||||
|
- Test: `crypto_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `APIError`, device error types, `generateNonce`, `signedNonce`, `encryptRC4`, `decryptPayload`, `generateEncryptedParams`.
|
||||||
|
|
||||||
|
- [ ] Write table-driven tests using the fixed vector `ssecurity=MDEyMzQ1Njc4OWFiY2RlZg==`, `nonce=AAECAwQFBgcICQoL`, signed nonce `16/CeTzC9IqVVbiZ01Hy/Qd8rtVo5ybLo+ph/Vvh52k=`, and RC4 ciphertext `9ve6riTrkW1oJUE=`.
|
||||||
|
- [ ] Run `go test ./...`; expect failure because encryption functions are undefined.
|
||||||
|
- [ ] Implement SHA-256 signed nonce, RC4-drop-1024, SHA-1 ordered signatures, encrypted form parameters, plain/gzip response decryption, and the Xiaomi error-code map.
|
||||||
|
- [ ] Run `gofmt -w errors.go crypto.go crypto_test.go && go test ./...`; expect PASS.
|
||||||
|
|
||||||
|
### Task 2: Client transport and QR authentication
|
||||||
|
|
||||||
|
**Covers:** [S3, S4, S7]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client.go`
|
||||||
|
- Create: `auth.go`
|
||||||
|
- Test: `client_test.go`
|
||||||
|
- Test: `auth_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `generateEncryptedParams`, `decryptPayload`, `APIError`.
|
||||||
|
- Produces: `Client`, `AuthData`, `NewClient(authPath string, options ...Option)`, `WithHTTPClient`, `Login(context.Context)`, `Available(context.Context)`, and internal `request`.
|
||||||
|
|
||||||
|
- [ ] Add `httptest.Server` tests for encrypted POST form fields, decrypted JSON responses, API errors, `&&&START&&&` parsing, auth persistence, and cookie/header construction.
|
||||||
|
- [ ] Run `go test ./...`; expect failures for missing client/auth symbols.
|
||||||
|
- [ ] Implement auth-file loading, generated UA/device ID/pass_o, API session headers, compact request JSON, response decoding, and 60-second availability caching.
|
||||||
|
- [ ] Implement service-login discovery, passToken refresh, QR login data retrieval, terminal QR rendering, 120-second long polling, callback cookies, and atomic auth-file persistence.
|
||||||
|
- [ ] Run `gofmt -w client.go auth.go client_test.go auth_test.go && go test ./...`; expect PASS.
|
||||||
|
|
||||||
|
### Task 3: Public Xiaomi API methods
|
||||||
|
|
||||||
|
**Covers:** [S3, S5, S7]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `types.go`
|
||||||
|
- Create: `api.go`
|
||||||
|
- Test: `api_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Client.request`.
|
||||||
|
- Produces: `GetHomes`, `GetDevices`, `GetSharedDevices`, `GetScenes`, `RunScene`, `GetConsumables`, `GetProperties`, `SetProperties`, `RunActions`, `GetStatistics`, and `CheckNewMessages` with typed parameters/results.
|
||||||
|
|
||||||
|
- [ ] Add HTTP fixture tests asserting every URI and exact decoded request body, including device pagination and one-request-per-action/statistic behavior.
|
||||||
|
- [ ] Run `go test ./...`; expect failures for missing API methods.
|
||||||
|
- [ ] Define stable public structs for homes/devices/property/action/statistic calls while preserving unknown response fields in `json.RawMessage` where needed.
|
||||||
|
- [ ] Implement all endpoint methods, owner lookup, all-home aggregation, home ID annotation, pagination, and Xiaomi result-code messages.
|
||||||
|
- [ ] Run `gofmt -w types.go api.go api_test.go && go test ./...`; expect PASS.
|
||||||
|
|
||||||
|
### Task 4: High-level MIoT device API
|
||||||
|
|
||||||
|
**Covers:** [S3, S6, S7]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `device.go`
|
||||||
|
- Test: `device_test.go`
|
||||||
|
- Create: `testdata/miot-spec.html`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Client.GetDevices`, `Client.GetProperties`, `Client.SetProperties`, `Client.RunActions`.
|
||||||
|
- Produces: `Device`, `DeviceInfo`, `PropertySpec`, `ActionSpec`, `NewDevice`, `GetDeviceInfo`, `Device.Get`, `Device.Set`, and `Device.RunAction`.
|
||||||
|
|
||||||
|
- [ ] Add local HTML fixture tests for MIoT embedded JSON parsing, duplicate-name qualification, underscore aliases, cache read/write, device selection, value conversion, range/step checks, and API error propagation.
|
||||||
|
- [ ] Run `go test ./...`; expect failures for missing device symbols.
|
||||||
|
- [ ] Implement spec download/parsing/cache and high-level device construction by DID or unique name.
|
||||||
|
- [ ] Implement readable/writable checks, bool/number/string conversion, range/value-list validation, property calls, action calls, and configurable post-command delay.
|
||||||
|
- [ ] Run `gofmt -w device.go device_test.go && go test ./...`; expect PASS.
|
||||||
|
|
||||||
|
### Task 5: Documentation and final verification
|
||||||
|
|
||||||
|
**Covers:** [S1, S2, S7, S8]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `README.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all exported package APIs.
|
||||||
|
- Produces: install, login, low-level property/action, and high-level device usage examples.
|
||||||
|
|
||||||
|
- [ ] Write concise Go examples and explicitly document unsupported CLI/MCP/skills features and auth-file security.
|
||||||
|
- [ ] Run `gofmt -w *.go && go vet ./... && go test -race ./...`; expect all commands to pass.
|
||||||
|
- [ ] Review exported API names with `go doc ./...` and remove any unused or speculative surface.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Initialize Git Repository Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Initialize the Go library as a Git repository and publish its first commit to git.misaka.ren.
|
||||||
|
|
||||||
|
**Architecture:** Track the root Go module as the repository. Exclude local MiMoCode state, authentication data, generated MIoT cache data, and the nested Python reference repository.
|
||||||
|
|
||||||
|
**Tech Stack:** Git, Go 1.22+
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Use `https://git.misaka.ren/m1saka/mijia-go-api.git` as `origin`.
|
||||||
|
- Keep `mijia-api/` untracked.
|
||||||
|
- Use `feat: add Go API library` as the initial commit message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Initialize And Publish Repository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify the Go library**
|
||||||
|
|
||||||
|
Run: `go test ./...`
|
||||||
|
Expected: all packages pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Initialize the repository**
|
||||||
|
|
||||||
|
Run: `git init -b main`
|
||||||
|
Expected: an empty Git repository on branch `main`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Configure the remote**
|
||||||
|
|
||||||
|
Run: `git remote add origin https://git.misaka.ren/m1saka/mijia-go-api.git`
|
||||||
|
Expected: `origin` points to the target repository.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create the initial commit**
|
||||||
|
|
||||||
|
Run: `git add . && git commit -m "feat: add Go API library"`
|
||||||
|
Expected: one root commit containing the Go library and documentation, excluding ignored local data.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Publish and verify**
|
||||||
|
|
||||||
|
Run: `git push -u origin main`
|
||||||
|
Expected: `main` tracks `origin/main` and the worktree is clean.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
feature: mijia-go-api
|
||||||
|
status: delivered
|
||||||
|
specs:
|
||||||
|
- docs/compose/specs/2026-07-16-mijia-go-api-design.md
|
||||||
|
plans:
|
||||||
|
- docs/compose/plans/2026-07-16-mijia-go-api.md
|
||||||
|
branch: none
|
||||||
|
commits: none
|
||||||
|
---
|
||||||
|
|
||||||
|
# mijia-api Go 重构 — Final Report
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
项目提供纯 Go `mijia` 库,等价实现 Python mijia-api v4.1.2 的核心能力:小米账号二维码登录、passToken 静默刷新、RC4 加密 API 请求、家庭和设备查询、场景、耗材、属性读写、动作执行、统计数据,以及基于 MIoT spec 的高级设备封装。项目不包含 CLI、MCP、skills 或抓包解密工具。
|
||||||
|
|
||||||
|
客户端支持认证文件安全持久化、请求前在线 token 有效性缓存、每实例 CookieJar 隔离、并发认证快照和有界 gzip 响应读取。动态 API 字段通过 `Extra` 保留,属性大整数通过 `json.Number` 保持精度。
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `crypto.go` 实现 signed nonce、RC4-drop-1024、有序双重签名和响应解密。
|
||||||
|
- `auth.go` 与 `client.go` 实现认证文件、二维码流程、token 刷新、Cookie 和加密 HTTP 传输;`response.go` 统一限制原始与解压响应大小。
|
||||||
|
- `api.go` 与 `types.go` 提供家庭、设备、共享设备、场景、耗材、property、action 和 statistics API。
|
||||||
|
- `device.go` 获取、解析并缓存 MIoT spec,提供 `NewDevice`、`Get`、`Set` 和 `RunAction`。
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
- 保持 Python v4.1.2 的实际网络契约,因为目标是等价重构;包括共享设备的 `owner=true` 筛选、action 的 `value` 字段和耗材首分组行为。
|
||||||
|
- 单次请求使用同一认证快照,因为签名、Cookie 和响应解密不能混用不同代 token。
|
||||||
|
- 设备 spec 缓存同时兼容 Go 顶层标识和 Python `method` 格式,因为两个实现默认共享认证目录和缓存文件名。
|
||||||
|
- 整数规格校验使用精确十进制/有理数比较,因为 `float64` 无法安全表达 `2^53` 以上整数。
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.misaka.ren/m1saka/mijia-go-api
|
||||||
|
```
|
||||||
|
|
||||||
|
创建客户端并登录:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := mijia.NewClient("")
|
||||||
|
if err != nil { /* handle */ }
|
||||||
|
auth, err := client.Login(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
底层控制使用 `GetDevices`、`GetProperties`、`SetProperties` 和 `RunActions`。高级控制使用 `NewDevice` 按 DID 或唯一名称选择设备,再调用 `device.Get`、`device.Set` 和 `device.RunAction`。完整示例和 option 名称见根目录 `README.md`。
|
||||||
|
|
||||||
|
认证文件默认位于 `~/.config/mijia-api/auth.json`,包含敏感 token,库以 `0600` 权限原子写入,不应提交到版本控制。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `gofmt -w *.go`
|
||||||
|
- `go test -count=1 ./...`:通过
|
||||||
|
- `go vet ./...`:通过
|
||||||
|
- 独立规格审查和逐阶段代码质量审查均通过。
|
||||||
|
- 测试覆盖固定加密向量、gzip/大小限制、QR 与静默刷新、本地加密 HTTP 端点、分页异常、精确大整数、MIoT HTML 变体、Python 缓存迁移和 context 取消。
|
||||||
|
|
||||||
|
真实小米服务仍存在账号区域、Cookie 策略、限流和设备型号页面变化等集成风险;本地测试不使用真实账号或外网。
|
||||||
|
|
||||||
|
## Journey Log
|
||||||
|
|
||||||
|
> Brief notes on what informed the final design. Not required reading.
|
||||||
|
|
||||||
|
- [lesson] 手动设置 `Accept-Encoding: gzip` 会关闭 Go Transport 自动解压,因此登录和 API 响应必须显式、有界解压。
|
||||||
|
- [pivot] 认证状态从公开可变字段改为深拷贝快照,避免并发请求混用 token 和外部 map 竞态。
|
||||||
|
- [lesson] IANA 时区存在负 DST(例如 Dublin),必须使用 `time.Time.IsDST()`,不能从 UTC offset 大小推断。
|
||||||
|
- [pivot] MIoT 数值校验改为精确有理数,避免大整数通过 `float64` 静默失真。
|
||||||
|
- [lesson] Python 与 Go 共用 spec 缓存路径时,格式迁移和语义校验属于实际兼容需求。
|
||||||
|
|
||||||
|
## Source Materials
|
||||||
|
|
||||||
|
| File | Role | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `docs/compose/specs/2026-07-16-mijia-go-api-design.md` | Initial design | 功能范围与协议约束 |
|
||||||
|
| `docs/compose/plans/2026-07-16-mijia-go-api.md` | Implementation plan | 五阶段实施与验证计划 |
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# mijia-api Go 重构设计(2026-07-16)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This document may not reflect the current implementation.
|
||||||
|
> See the final report for up-to-date state:
|
||||||
|
> [Final Report](../reports/mijia-go-api.md)
|
||||||
|
|
||||||
|
## [S1] 问题
|
||||||
|
|
||||||
|
将 Python 版 mijia-api(v4.1.2)重构为纯 Go 库,只保留 API 核心功能,排除 skills、MCP server、CLI、decrypt 调试脚本、文档站。
|
||||||
|
|
||||||
|
## [S2] 方案概览
|
||||||
|
|
||||||
|
- 项目形态:纯 Go 库,module 路径 `git.misaka.ren/m1saka/mijia-go-api`,包名 `mijia`,代码放仓库根目录。
|
||||||
|
- 功能范围:完整底层 API + 高级设备封装(mijiaDevice 等价物)。
|
||||||
|
- Go 版本:1.22+,尽量只用标准库;二维码终端输出用 `github.com/mdp/qrterminal/v3`。
|
||||||
|
|
||||||
|
## [S3] 包结构
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/ (package mijia)
|
||||||
|
crypto.go ← miutils.py: GenNonce / SignedNonce / RC4-drop1024 加解密 / 签名 / GenerateEncParams(有序 KV)
|
||||||
|
auth.go ← 登录: QRLogin、passToken 静默刷新、auth.json 读写、UA/deviceId 生成
|
||||||
|
client.go ← Client 结构、session headers/cookies、统一加密请求 request()
|
||||||
|
api.go ← GetHomesList / GetDevicesList(分页) / GetSharedDevicesList / GetScenesList / RunScene /
|
||||||
|
GetConsumableItems / GetDevicesProp / SetDevicesProp / RunAction / GetStatistics / CheckNewMsg
|
||||||
|
device.go ← Device 高级封装: miot-spec 抓取解析+缓存、Get/Set(类型与 range 校验)/RunAction
|
||||||
|
errors.go ← ERROR_CODE 表 + 错误类型(LoginError/APIError/DeviceGetError/DeviceSetError 等)
|
||||||
|
types.go ← 请求/响应结构体
|
||||||
|
```
|
||||||
|
|
||||||
|
## [S4] 认证与加密关键点
|
||||||
|
|
||||||
|
1. 二维码扫码登录:`account.xiaomi.com/pass/serviceLogin` → `longPolling/loginUrl` → 终端二维码 → 长轮询(120s)→ 回调取 serviceToken;响应需去 `&&&START&&&` 前缀。
|
||||||
|
2. passToken 有效时静默刷新 token;auth.json 保存 ua/deviceId/pass_o/ssecurity/serviceToken/passToken/userId/cUserId/expireTime(30 天)等。
|
||||||
|
3. 加密请求(base `https://api.mijia.tech/app`,全部 POST form):
|
||||||
|
- nonce = base64(8 字节随机 + 分钟时间戳字节);signedNonce = base64(SHA256(ssecurity||nonce))。
|
||||||
|
- RC4 必须先丢弃 1024 字节 keystream(标准库 crypto/rc4 手动 drop)。
|
||||||
|
- 双重签名:先对明文 params 签 `rc4_hash__`,RC4 加密后再签 `signature`;签名串顺序固定 `POST&uri&data=..&rc4_hash__=..&signedNonce`,Go 用有序 KV 切片而非 map。
|
||||||
|
- data JSON 必须紧凑无空格。
|
||||||
|
- 响应可能是 gzip 压缩后的 RC4 密文:先直接 JSON 解析,失败则解密,解密后 UTF-8 无效再 gzip 解压。
|
||||||
|
4. `Available()`:检查 auth 字段完备 + 调 check_new_msg 验证,结果缓存 60 秒。
|
||||||
|
|
||||||
|
## [S5] API 方法映射
|
||||||
|
|
||||||
|
与 Python 版一一对应(URI、请求体 JSON 完全一致),注意:
|
||||||
|
- GetDevicesList 分页(start_did/max_did,直到 has_more=false),home_id 为空时遍历所有家庭。
|
||||||
|
- RunAction 逐条请求,prop get/set 批量。
|
||||||
|
- 多数接口需 home 的 owner uid(从 GetHomesList 查)。
|
||||||
|
- SetDevicesProp code==1 表示网关已接收但结果未知;非 0/1 查 ERROR_CODE 附中文消息。
|
||||||
|
|
||||||
|
## [S6] 高级设备封装
|
||||||
|
|
||||||
|
`NewDevice(api, did 或 name)`:
|
||||||
|
- 从设备列表解析 model → GET `https://home.miot-spec.com/spec/{model}` 正则提取内嵌 JSON → 解析 services/properties/actions(siid/piid/aiid、类型、rw、range、value-list)→ 本地 JSON 缓存(auth.json 同目录)。
|
||||||
|
- `Get(name)` / `Set(name, value)`(bool/int/float 类型转换、range 与枚举校验)/ `RunAction(name, args...)`;属性名 `-` 与 `_` 互为别名。不做 Python 的动态属性语法糖。
|
||||||
|
|
||||||
|
## [S7] 错误处理与测试
|
||||||
|
|
||||||
|
- 错误:哨兵/自定义错误类型 + ERROR_CODE map(码→中文)。
|
||||||
|
- 测试:crypto.go 的纯函数(nonce 格式、signedNonce、RC4-drop1024、签名串)用与 Python 实现对照生成的固定向量做单测;spec HTML 解析用本地样本。网络调用不做集成测试。
|
||||||
|
|
||||||
|
## [S8] 排除项
|
||||||
|
|
||||||
|
mcp_server.py、skills/、docs/、decrypt/、`__main__.py` CLI、qrcode 图片生成(仅终端二维码)。
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
var errorCodeMessages = map[int]string{
|
||||||
|
-10000: "未知错误",
|
||||||
|
-10001: "服务不可用",
|
||||||
|
-10002: "参数无效",
|
||||||
|
-10003: "资源不足",
|
||||||
|
-10004: "内部错误",
|
||||||
|
-10005: "权限不足",
|
||||||
|
-10006: "执行超时",
|
||||||
|
-10007: "设备离线或者不存在",
|
||||||
|
-10020: "未授权OAuth2",
|
||||||
|
-10030: "无效的token(HTTP)",
|
||||||
|
-10040: "无效的消息格式",
|
||||||
|
-10050: "无效的证书",
|
||||||
|
-704000000: "未知错误",
|
||||||
|
-704010000: "未授权(设备可能被删除)",
|
||||||
|
-704014006: "没找到设备描述",
|
||||||
|
-704030013: "Property不可读",
|
||||||
|
-704030023: "Property不可写",
|
||||||
|
-704030033: "Property不可订阅",
|
||||||
|
-704040002: "Service不存在",
|
||||||
|
-704040003: "Property不存在",
|
||||||
|
-704040004: "Event不存在",
|
||||||
|
-704040005: "Action不存在",
|
||||||
|
-704040999: "功能未上线",
|
||||||
|
-704042001: "Device不存在",
|
||||||
|
-704042011: "设备离线",
|
||||||
|
-704053036: "设备操作超时",
|
||||||
|
-704053100: "设备在当前状态下无法执行此操作",
|
||||||
|
-704083036: "设备操作超时",
|
||||||
|
-704090001: "Device不存在",
|
||||||
|
-704220008: "无效的ID",
|
||||||
|
-704220025: "Action参数个数不匹配",
|
||||||
|
-704220035: "Action参数错误",
|
||||||
|
-704220043: "Property值错误",
|
||||||
|
-704222034: "Action返回值错误",
|
||||||
|
-705004000: "未知错误",
|
||||||
|
-705004501: "未知错误",
|
||||||
|
-705201013: "Property不可读",
|
||||||
|
-705201015: "Action执行错误",
|
||||||
|
-705201023: "Property不可写",
|
||||||
|
-705201033: "Property不可订阅",
|
||||||
|
-706012000: "未知错误",
|
||||||
|
-706012013: "Property不可读",
|
||||||
|
-706012015: "Action执行错误",
|
||||||
|
-706012023: "Property不可写",
|
||||||
|
-706012033: "Property不可订阅",
|
||||||
|
-706012043: "Property值错误",
|
||||||
|
-706014006: "没找到设备描述",
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginError struct {
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *LoginError) Error() string {
|
||||||
|
return fmt.Sprintf("code: %d, message: %s", err.Code, err.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIError struct {
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("code: %d, message: %s", err.Code, err.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceNotFoundError struct {
|
||||||
|
DID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *DeviceNotFoundError) Error() string {
|
||||||
|
return fmt.Sprintf("未找到 did 为 '%s' 的设备,请检查 did 是否正确", err.DID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultipleDevicesFoundError struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *MultipleDevicesFoundError) Error() string {
|
||||||
|
return err.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceGetError struct {
|
||||||
|
DeviceName string
|
||||||
|
Name string
|
||||||
|
Code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *DeviceGetError) Error() string {
|
||||||
|
return fmt.Sprintf("获取设备 '%s' 的属性 '%s' 时失败, code: %d, message: %s", err.DeviceName, err.Name, err.Code, ErrorMessage(err.Code))
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceSetError struct {
|
||||||
|
DeviceName string
|
||||||
|
Name string
|
||||||
|
Code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *DeviceSetError) Error() string {
|
||||||
|
return fmt.Sprintf("设置设备 '%s' 的属性 '%s' 时失败, code: %d, message: %s", err.DeviceName, err.Name, err.Code, ErrorMessage(err.Code))
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceActionError struct {
|
||||||
|
DeviceName string
|
||||||
|
Name string
|
||||||
|
Code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *DeviceActionError) Error() string {
|
||||||
|
return fmt.Sprintf("执行设备 '%s' 的动作 '%s' 时失败, code: %d, message: %s", err.DeviceName, err.Name, err.Code, ErrorMessage(err.Code))
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDeviceInfoError struct {
|
||||||
|
DeviceModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *GetDeviceInfoError) Error() string {
|
||||||
|
return fmt.Sprintf("获取设备型号 '%s' 的设备信息失败", err.DeviceModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorMessage returns the description for a Xiaomi error code.
|
||||||
|
func ErrorMessage(code int) string {
|
||||||
|
if message, ok := errorCodeMessages[code]; ok {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return "未知错误"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestErrorMessage(t *testing.T) {
|
||||||
|
if message := ErrorMessage(-10001); message != "服务不可用" {
|
||||||
|
t.Fatalf("ErrorMessage(-10001) = %q, want %q", message, "服务不可用")
|
||||||
|
}
|
||||||
|
if message := ErrorMessage(1); message != "未知错误" {
|
||||||
|
t.Fatalf("ErrorMessage(1) = %q, want %q", message, "未知错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module git.misaka.ren/m1saka/mijia-go-api
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
|
rsc.io/qr v0.2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
golang.org/x/term v0.13.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
|
||||||
|
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxHTTPResponseBytes = 16 << 20
|
||||||
|
|
||||||
|
func readHTTPResponse(response *http.Response) ([]byte, error) {
|
||||||
|
rawBody, err := readBounded(response.Body, maxHTTPResponseBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read raw HTTP response: %w", err)
|
||||||
|
}
|
||||||
|
if !headerContainsToken(response.Header.Get("Content-Encoding"), "gzip") {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := gzip.NewReader(bytes.NewReader(rawBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open gzip HTTP response: %w", err)
|
||||||
|
}
|
||||||
|
body, readErr := readBounded(reader, maxHTTPResponseBytes)
|
||||||
|
closeErr := reader.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("decompress gzip HTTP response: %w", readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, fmt.Errorf("close gzip HTTP response: %w", closeErr)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBounded(reader io.Reader, maximum int64) ([]byte, error) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(reader, maximum+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(body)) > maximum {
|
||||||
|
return nil, fmt.Errorf("response exceeds %d bytes", maximum)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerContainsToken(value, token string) bool {
|
||||||
|
for _, encoding := range strings.Split(value, ",") {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(encoding), token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><script>window.noise = "<script>not JSON</script>";</script></head>
|
||||||
|
<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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package mijia
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Home struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UID int64 `json:"uid"`
|
||||||
|
RoomList json.RawMessage `json:"roomlist,omitempty"`
|
||||||
|
Extra map[string]json.RawMessage `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (home *Home) UnmarshalJSON(payload []byte) error {
|
||||||
|
type homeFields struct {
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UID int64 `json:"uid"`
|
||||||
|
RoomList json.RawMessage `json:"roomlist"`
|
||||||
|
}
|
||||||
|
var fields homeFields
|
||||||
|
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := decodeStringOrNumber(fields.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode home id: %w", err)
|
||||||
|
}
|
||||||
|
home.ID = id
|
||||||
|
home.Name = fields.Name
|
||||||
|
home.UID = fields.UID
|
||||||
|
home.RoomList = fields.RoomList
|
||||||
|
home.Extra, err = decodeExtraFields(payload, "id", "name", "uid", "roomlist")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (home Home) MarshalJSON() ([]byte, error) {
|
||||||
|
type homeFields Home
|
||||||
|
return encodeJSONWithExtra(homeFields(home), home.Extra, "id", "name", "uid", "roomlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
UID int64 `json:"uid"`
|
||||||
|
Owner bool `json:"owner"`
|
||||||
|
HomeID string `json:"home_id,omitempty"`
|
||||||
|
Extra map[string]json.RawMessage `json:"-"`
|
||||||
|
properties map[string]PropertySpec
|
||||||
|
actions map[string]ActionSpec
|
||||||
|
client *Client
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (device *Device) UnmarshalJSON(payload []byte) error {
|
||||||
|
type deviceFields Device
|
||||||
|
var fields deviceFields
|
||||||
|
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*device = Device(fields)
|
||||||
|
extra, err := decodeExtraFields(payload, "did", "name", "model", "uid", "owner", "home_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
device.Extra = extra
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (device Device) MarshalJSON() ([]byte, error) {
|
||||||
|
type deviceFields Device
|
||||||
|
return encodeJSONWithExtra(deviceFields(device), device.Extra, "did", "name", "model", "uid", "owner", "home_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scene struct {
|
||||||
|
SceneID string `json:"scene_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HomeID string `json:"home_id,omitempty"`
|
||||||
|
Extra map[string]json.RawMessage `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scene *Scene) UnmarshalJSON(payload []byte) error {
|
||||||
|
type sceneFields Scene
|
||||||
|
var fields sceneFields
|
||||||
|
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*scene = Scene(fields)
|
||||||
|
extra, err := decodeExtraFields(payload, "scene_id", "name", "home_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scene.Extra = extra
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scene Scene) MarshalJSON() ([]byte, error) {
|
||||||
|
type sceneFields Scene
|
||||||
|
return encodeJSONWithExtra(sceneFields(scene), scene.Extra, "scene_id", "name", "home_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
type Consumable struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Details json.RawMessage `json:"details"`
|
||||||
|
HomeID string `json:"home_id,omitempty"`
|
||||||
|
Extra map[string]json.RawMessage `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (consumable *Consumable) UnmarshalJSON(payload []byte) error {
|
||||||
|
type consumableFields Consumable
|
||||||
|
var fields consumableFields
|
||||||
|
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*consumable = Consumable(fields)
|
||||||
|
extra, err := decodeExtraFields(payload, "did", "name", "details", "home_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
consumable.Extra = extra
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (consumable Consumable) MarshalJSON() ([]byte, error) {
|
||||||
|
type consumableFields Consumable
|
||||||
|
return encodeJSONWithExtra(consumableFields(consumable), consumable.Extra, "did", "name", "details", "home_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
type PropertyRequest struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
SIID int `json:"siid"`
|
||||||
|
PIID int `json:"piid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PropertySetRequest struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
SIID int `json:"siid"`
|
||||||
|
PIID int `json:"piid"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PropertyResult struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
SIID int `json:"siid"`
|
||||||
|
PIID int `json:"piid"`
|
||||||
|
Value any `json:"value,omitempty"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
UpdateTime int64 `json:"updateTime,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionRequest struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
SIID int `json:"siid"`
|
||||||
|
AIID int `json:"aiid"`
|
||||||
|
Value any `json:"value,omitempty"`
|
||||||
|
Extra map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON 先序列化固定字段,再合并 Extra;Extra 与保留键
|
||||||
|
// (did, siid, aiid, value) 冲突时返回错误。
|
||||||
|
func (request ActionRequest) MarshalJSON() ([]byte, error) {
|
||||||
|
type actionRequestFields ActionRequest
|
||||||
|
payload, err := json.Marshal(actionRequestFields(request))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(request.Extra) == 0 {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
var combined map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &combined); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for key, value := range request.Extra {
|
||||||
|
if actionRequestReservedKey(key) {
|
||||||
|
return nil, fmt.Errorf("无效的参数: %s. 请勿使用保留键 (did, siid, aiid, value)", key)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("编码扩展参数 %s: %w", key, err)
|
||||||
|
}
|
||||||
|
combined[key] = encoded
|
||||||
|
}
|
||||||
|
return json.Marshal(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionRequestReservedKey(key string) bool {
|
||||||
|
switch key {
|
||||||
|
case "did", "siid", "aiid", "value":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionResult struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
SIID int `json:"siid"`
|
||||||
|
AIID int `json:"aiid"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Out json.RawMessage `json:"out,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatisticsRequest struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
DataType string `json:"data_type"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
TimeStart int64 `json:"time_start"`
|
||||||
|
TimeEnd int64 `json:"time_end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStringOrNumber(raw json.RawMessage) (string, error) {
|
||||||
|
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
if err := json.Unmarshal(raw, &value); err == nil {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var number json.Number
|
||||||
|
if err := decoder.Decode(&number); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return number.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(payload []byte, target any) error {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Errorf("unexpected trailing JSON value")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeExtraFields(payload []byte, knownFields ...string) (map[string]json.RawMessage, error) {
|
||||||
|
var extra map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &extra); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, field := range knownFields {
|
||||||
|
delete(extra, field)
|
||||||
|
}
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return extra, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJSONWithExtra(fields any, extra map[string]json.RawMessage, knownFields ...string) ([]byte, error) {
|
||||||
|
payload, err := json.Marshal(fields)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
var combined map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &combined); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reserved := make(map[string]struct{}, len(knownFields))
|
||||||
|
for _, field := range knownFields {
|
||||||
|
reserved[field] = struct{}{}
|
||||||
|
}
|
||||||
|
for field, value := range extra {
|
||||||
|
if _, known := reserved[field]; !known {
|
||||||
|
combined[field] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(combined)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user