Files
xiaomihome/xiaomi/client.go
T
4566704 a0ef854726 feat: 完善 AC"set 后回读"机制 — 目标聚合 + 防抖重试验证
**回读目标聚合(recordReadback)**
- 所有 set 方法(TurnOn/TurnOff/SetMode/SetTargetTemp/SetFanSpeed/
  SetSwing/SetTargetHumidity/SetAuxHeat/SetEco)成功后记录目标值 rbTarget
- 同属性目标覆盖、不同属性追加,连续 set 自动聚合

**防抖 + 重试验证(readbackLoop)**
- 静默窗口 readbackIdle=3s:等待最后一次 set 无新操作才开始验证
- 未命中目标时按 readbackRetryInterval=3s 重试,上限 readbackMaxRetries=5
- 整体超时 readbackTimeout=30s,防云端卡住导致回读永久挂起
- 命中后逐字段回调 OnPropsChanged 注册的 handler(与 MQTT 推送同语义)
- rbValueEqual 数值比较用 epsilon=0.01,bool/string 直接相等

**白名单配置透传**
- xiaomi.Config 新增 ReadbackModels 白名单(留空 = 全部不回读,默认)
- Client 新增 NeedReadback(model) 判定 + Logger() 访问器
- bridge.Pool.NewPool(readbackModels...) 透传;newBridge 传入 Client 配置

**其他**
- bridge 设备创建失败(Warn)与不支持跳过(Debug)分别记录日志
2026-08-23 23:33:32 +08:00

191 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package xiaomi
import (
"context"
"fmt"
"sync"
"time"
"xiaomihome/logger"
"xiaomihome/miot"
)
// Client 高级封装客户端(包装 miot.MIoTClient + miot.MIoTHttpClient)
type Client struct {
cfg Config
inner *miot.MIoTClient
http *miot.MIoTHttpClient
mipsCloud *miot.MipsCloudClient
specParser *miot.MIoTSpecParser
uuid string
lgr logger.Logger
deviceMu sync.RWMutex
devices map[string]*miot.MIoTDevice
propSubs map[string]string
eventSubs map[string]string
mu sync.RWMutex
readbackModels map[string]struct{} // 需要 set 后回读的型号集合
}
// NewClient 创建并初始化客户端。
func NewClient(ctx context.Context, cfg Config) (*Client, error) {
if cfg.AuthInfo.AccessToken == "" {
return nil, fmt.Errorf("xiaomi: Config.AuthInfo.AccessToken is required")
}
if cfg.UUID == "" {
return nil, fmt.Errorf("xiaomi: Config.UUID is required")
}
if cfg.DataDir == "" {
cfg.DataDir = "./data"
}
// 计算 expires_ts(如果未提供,从 expires_in 推算)
expiresTS := float64(cfg.AuthInfo.ExpiresTS)
if expiresTS == 0 && cfg.AuthInfo.ExpiresIn > 0 {
expiresTS = float64(time.Now().Unix() + int64(cfg.AuthInfo.ExpiresIn))
}
uuid := cfg.UUID
entryData := map[string]interface{}{
"access_token": cfg.AuthInfo.AccessToken,
"refresh_token": cfg.AuthInfo.RefreshToken,
"expires_ts": expiresTS,
"uuid": uuid,
}
miotClient := miot.NewMIoTClient("xiaomi_sdk", entryData, cfg.UID, "cn", miot.CtrlModeAuto)
miotClient.SetStorage(miot.NewMIoTStorage(cfg.DataDir))
miotClient.SetSpecParser(miot.NewMIoTSpecParser("en", miot.NewMIoTStorage(cfg.DataDir), cfg.DataDir+"/specs"))
httpClient, err := miot.NewMIoTHttpClient("cn", miot.OAUTH2_CLIENT_ID, cfg.AuthInfo.AccessToken)
if err != nil {
return nil, fmt.Errorf("xiaomi: http: %w", err)
}
miotClient.SetHTTPClient(httpClient)
mipsCloud := miot.NewMipsCloudClient(
"ssl://cn-ha.mqtt.io.mi.com:8883",
"ha."+uuid,
miot.OAUTH2_CLIENT_ID,
cfg.AuthInfo.AccessToken,
)
miotClient.SetMipsCloud(mipsCloud)
var ids []string
if len(cfg.HomeIDs) > 0 {
ids = make([]string, len(cfg.HomeIDs))
for i, v := range cfg.HomeIDs {
ids[i] = fmt.Sprintf("%d", v)
}
miotClient.SetHomeIDs(ids)
}
if err := miotClient.Init(); err != nil {
return nil, fmt.Errorf("xiaomi: init: %w", err)
}
// Use the same homeIDs slice from above (nil if not set)
_ = miotClient.RefreshDevices(ids)
if err := mipsCloud.Connect(); err != nil {
return nil, fmt.Errorf("xiaomi: mips connect: %w", err)
}
c := &Client{
cfg: cfg,
inner: miotClient,
http: httpClient,
mipsCloud: mipsCloud,
uuid: uuid,
lgr: logger.Default(),
specParser: miot.NewMIoTSpecParser("en", miot.NewMIoTStorage(cfg.DataDir), cfg.DataDir+"/specs"),
devices: make(map[string]*miot.MIoTDevice),
propSubs: make(map[string]string),
eventSubs: make(map[string]string),
readbackModels: make(map[string]struct{}, len(cfg.ReadbackModels)),
}
for _, m := range cfg.ReadbackModels {
if m != "" {
c.readbackModels[m] = struct{}{}
}
}
return c, nil
}
// NeedReadback 判断指定型号是否需要"set 成功后回读状态"。
func (c *Client) NeedReadback(model string) bool {
if model == "" || len(c.readbackModels) == 0 {
return false
}
_, ok := c.readbackModels[model]
return ok
}
// SetLogger 设置日志记录器。
func (c *Client) SetLogger(l logger.Logger) { c.lgr = l }
// Logger 返回日志记录器。
func (c *Client) Logger() logger.Logger { return c.lgr }
// SetHomeIDs 动态设置 miot 层的家庭过滤,供后台定时 RefreshDevices 使用。
func (c *Client) SetHomeIDs(homeIDs []string) { c.inner.SetHomeIDs(homeIDs) }
// Close 关闭客户端。
func (c *Client) Close() error {
if c.mipsCloud != nil {
c.mipsCloud.Disconnect()
}
if c.inner != nil {
return c.inner.Deinit()
}
return nil
}
// RefreshToken 手动刷新 token,返回新值供调用方持久化。
func (c *Client) RefreshToken(ctx context.Context) (*TokenResult, error) {
result, err := c.inner.RefreshAuthInfo()
if err != nil {
return nil, fmt.Errorf("xiaomi: refresh token: %w", err)
}
return &TokenResult{
AccessToken: strVal(result, "access_token"),
RefreshToken: strVal(result, "refresh_token"),
ExpiresIn: int32(intVal(result, "expires_in")),
ExpiresTS: int64(intVal(result, "expires_ts")),
UID: strVal(result, "uid"),
}, nil
}
func (c *Client) getOrCreateDevice(did string, info map[string]interface{}) *miot.MIoTDevice {
c.deviceMu.Lock()
defer c.deviceMu.Unlock()
if dev, ok := c.devices[did]; ok {
return dev
}
urn, _ := info["urn"].(string)
model, _ := info["model"].(string)
var spec *miot.MIoTSpecInstance
if c.specParser != nil && urn != "" {
s, err := c.specParser.Parse(urn, model)
if err == nil {
spec = s
} else {
c.lgr.Warnf("[xiaomi] spec parse failed for %s (%s): %v", did, urn, err)
}
}
dev := miot.NewMIoTDevice(c.inner, info, spec)
c.devices[did] = dev
return dev
}
func (c *Client) getDevice(did string) *miot.MIoTDevice {
c.deviceMu.RLock()
defer c.deviceMu.RUnlock()
return c.devices[did]
}