chore: 提交高层 xiaomi 封装库及示例

- 新增 xiaomi/examples/ 下 8 个示例程序(用户/家庭查询、设备列表、开关/灯光/空调控制、属性订阅、高级过滤分类、SPEC 解析)
- miot_client_sub.go: 重构 SubProp/SubEvent,使用 buildPropTopic/buildEventTopic 支持通配符订阅(siid/piid=0 → +),并修复锁顺序问题(将 RequestRefreshProp 移到 Lock 外)
- spec_parser.go: 新增 downloadSpecFile 方法,本地 SPEC 文件缺失时自动从 miot-spec.org 下载
- ARCH_PLAN.md: 架构设计从 Proposed 更新为 Accepted(v1.0→v1.1),补充设备分类/工厂/SPEC 映射等模块设计
- xiaomi/: 新增 miot 上层强类型封装模块,包含 Client 主入口、用户/家庭/设备 API、属性读写、动作调用、订阅通知,以及 devices/ 设备控制抽象(Switch/Light/AirConditioner/Fan/Cover/Humidifier/Vacuum/WaterHeater/Thermostat)和 specs/ SPEC 查询辅助
- 更多 xiaomi 示例(风扇/窗帘/传感器控制)
This commit is contained in:
2026-06-29 08:51:21 +08:00
parent 35efed7d15
commit a3d94c4b9f
93 changed files with 7582 additions and 157 deletions
+288
View File
@@ -0,0 +1,288 @@
# xiaomi 客户端重构方案
> 状态:Proposed | 版本:v3.0 | 日期:2026-06-29 | 参照:miot/examples/full_client
---
## 一、Config 字段说明
| 字段 | 含义 | 来源 |
|------|------|------|
| `ClientID` | OAuth Client ID,默认 `"2882303761520251711"`(小米官方 HA 客户端) | 小米开放平台,可留空走默认值 |
| `ClientSecret` | OAuth Client Secret,用于 token 交换 | 小米开放平台申请 |
| `AccessToken` | OAuth 授权后获得的 token,云端 API 鉴权 | OAuth 流程获取(AuthURL → ExchangeCode) |
---
## 二、目标形态
```go
// 零 miot import,1 个对象
client, err := xiaomi.NewClient(ctx, xiaomi.Config{
ClientID: "", // 空则默认 "2882303761520251711"
ClientSecret: "your-secret",
AccessToken: "your-access-token",
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
user, _ := client.GetUserInfo(ctx)
```
---
## 三、参照源码
`miot/examples/full_client/main.go`(已验证可运行)的初始化流程:
```go
// 1. entryData 只放 access_token
entryData := cfg.EntryData() // → {"access_token": "xxx"}
// 2. 创建 MIoTClient
client := miot.NewMIoTClient("demo", entryData, cfg.UID, cfg.CloudServer, cfg.GetCtrlMode())
// 3. 设置组件(必须在 Init 之前)
client.SetStorage(miot.NewMIoTStorage("./data"))
client.SetHTTPClient(miot.NewMIoTHttpClient(cfg.CloudServer, miot.OAUTH2_CLIENT_ID, cfg.AuthInfo.AccessToken))
client.SetMipsCloud(miot.NewMipsCloudClient("ssl://cn-ha.mqtt.io.mi.com:8883", "ha."+cfg.UUID, ...))
// 4. Init(一次性完成:OAuth 客户端、证书、HTTP、MIPS 云、mDNS、LAN)
client.Init()
// 5. 业务操作
client.RefreshDevices()
client.DeviceList()
client.SubDeviceState(...)
client.SubProp(...)
// 6. 清理
client.Deinit()
```
**关键要点**:
- entryData 只需 `access_token` 一个字段
- `SetStorage` / `SetHTTPClient` / `SetMipsCloud` 必须在 Init 之前调用
- Init 内部会跳过已设置的组件(`if c.http == nil { ... }`)
- 不需要两次 Init,一次足够
---
## 四、逐文件修改
### 4.1 新建 `xiaomi/config.go`
```go
package xiaomi
// Config 客户端初始化配置
type Config struct {
// ClientID OAuth Client ID,空则默认 "2882303761520251711"(小米 HA 客户端)
ClientID string
// ClientSecret OAuth Client Secret
ClientSecret string
// AccessToken OAuth 授权后获取的 access token
AccessToken string
}
```
### 4.2 重写 `xiaomi/client.go`
**删除**:
```go
func NewClient(inner *miot.MIoTClient, httpClient *miot.MIoTHttpClient) *Client
func (c *Client) Inner() *miot.MIoTClient
func (c *Client) HTTPClient() *miot.MIoTHttpClient
```
**新增**(完全对齐 full_client 示例的初始化流程):
```go
import "context"
// NewClient 创建并初始化客户端。
//
// 内部流程(对齐 miot/examples/full_client):
// 1. 构建 entryData(仅 access_token)
// 2. 创建 MIoTClient
// 3. SetStorage → SetHTTPClient → SetMipsCloud(必须在 Init 前)
// 4. Init(一次性完成所有子组件初始化)
func NewClient(ctx context.Context, cfg Config) (*Client, error) {
clientID := cfg.ClientID
if clientID == "" {
clientID = miot.OAUTH2_CLIENT_ID // 默认小米 HA 客户端
}
// 构造 entryData(仅 access_token,对齐 full_client 的 EntryData())
entryData := map[string]interface{}{
"access_token": cfg.AccessToken,
}
// 创建 MIoTClient(uid 空、CloudServer=cn、CtrlMode=auto)
miotClient := miot.NewMIoTClient("xiaomi_sdk", entryData, "", "cn", miot.CtrlModeAuto)
// 设置存储(对齐 full_client line 36-37)
miotClient.SetStorage(miot.NewMIoTStorage("./data"))
// 设置 HTTP 客户端(对齐 full_client line 41-46)
httpClient, err := miot.NewMIoTHttpClient("cn", clientID, cfg.AccessToken)
if err != nil {
return nil, fmt.Errorf("xiaomi: http: %w", err)
}
miotClient.SetHTTPClient(httpClient)
// 设置 MIPS 云端客户端(对齐 full_client line 50-57)
mipsCloud := miot.NewMipsCloudClient(
"ssl://cn-ha.mqtt.io.mi.com:8883",
"ha."+uuid.New().String(),
clientID,
cfg.AccessToken,
)
miotClient.SetMipsCloud(mipsCloud)
// 一次性 Init(对齐 full_client line 60)
if err := miotClient.Init(); err != nil {
return nil, fmt.Errorf("xiaomi: init: %w", err)
}
return &Client{
cfg: cfg,
inner: miotClient,
http: httpClient,
devices: make(map[string]*miot.MIoTDevice),
propSubs: make(map[string]string),
eventSubs: make(map[string]string),
}, nil
}
// Close 关闭客户端(对齐 full_client line 125: client.Deinit())
func (c *Client) Close() error {
if c.inner != nil {
return c.inner.Deinit()
}
return nil
}
```
**Client struct 新增 cfg 字段**:
```go
type Client struct {
cfg Config // ← 新增
inner *miot.MIoTClient
http *miot.MIoTHttpClient
deviceMu sync.RWMutex
devices map[string]*miot.MIoTDevice
propSubs map[string]string
eventSubs map[string]string
mu sync.RWMutex
}
```
### 4.3 新建 `xiaomi/oauth.go`
```go
package xiaomi
import "context"
// AuthURL 生成小米 OAuth 授权页面 URL
func AuthURL(cfg Config) string
// ExchangeCode 用 authorization code 交换 access token
func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, error)
// TokenResult OAuth token 交换结果
type TokenResult struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
UID string `json:"uid"`
}
```
### 4.4 重写 `xiaomi/examples/main.go`
```go
package main
import (
"context"
"log"
"time"
"xiaomihome/xiaomi"
"xiaomihome/xiaomi/examples/config"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg, _ := config.LoadFromEnv()
client, err := xiaomi.NewClient(ctx, xiaomi.Config{
ClientSecret: cfg.ClientSecret,
AccessToken: cfg.AccessToken,
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
user, _ := client.GetUserInfo(ctx)
log.Printf("用户: %s (UID: %s)", user.NickName, user.UID)
}
```
---
## 五、不动的文件(15 个)
`properties.go` `actions.go` `subscribe.go` `user.go` `homes.go` `devices.go` `types.go` `errors.go` `convert.go` + `devices/` 下全部 9 个文件
---
## 六、附带清理
### 6.1 `xiaomi/examples/config/config.go` — 去掉无用的 CountryCode / CloudServer
```go
// 修改前
type Config struct {
CountryCode string // ← 无用
CloudServer string // ← 硬编码 "cn",无需暴露
ClientID string
ClientSecret string
AccessToken string
}
func (c *Config) LoadFromEnv() {
cfg.CountryCode = getEnv("XIAOMI_COUNTRY_CODE", "86") // ← 删
cfg.CloudServer = getEnv("XIAOMI_CLOUD_SERVER", "cn") // ← 删
}
// 修改后
type Config struct {
ClientID string
ClientSecret string
AccessToken string
}
```
### 6.2 `miot/miot_client.go` — `NewMIoTClient` 中去掉无用的 `country_code`
已验证 Go 代码和 Python 代码中均无任何地方读取 entryData 的 `country_code`,一并移除。
---
## 七、实施步骤
1. 新建 `xiaomi/config.go`
2. 重写 `xiaomi/client.go`(旧 NewClient 改名内部保留)
3. 新建 `xiaomi/oauth.go`
4. 清理 `xiaomi/examples/config/config.go`(去 CountryCode / CloudServer)
5. 重写 `xiaomi/examples/main.go`
6. 清理 `miot/miot_client.go` 中无用的 `country_code`
7. 更新 `xiaomi/migration/ARCH_PLAN.md` 4.2 节
8. `go vet ./...` 编译验证