diff --git a/.gitignore b/.gitignore index e43fbd3..8436a13 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ ip.xdb /cmd/test/downFile/temp /*.exe /cmd/cloud/admin/go_build_ant_scream_cmd_cloud_admin.exe +/xiaomi/examples/05_ac_control/data/miot_config/*.dict +*.dict diff --git a/miot/const.go b/miot/const.go index 9e8a1cf..4e99a93 100644 --- a/miot/const.go +++ b/miot/const.go @@ -33,7 +33,9 @@ const ( MIHOME_HTTP_API_TIMEOUT = 30 MIHOME_MQTT_KEEPALIVE = 60 // seconds, 3 days - MIHOME_CERT_EXPIRE_MARGIN = 3600 * 24 * 3 + MIHOME_CERT_EXPIRE_MARGIN = 3600 * 24 * 3 + // seconds, 1 hour — token 过期前刷新(比 Python 60s 更保守) + MIHOME_REFRESH_THRESHOLD = 3600 * 6 NETWORK_REFRESH_INTERVAL = 30 // seconds, 14 days SPEC_STD_LIB_EFFECTIVE_TIME = 3600 * 24 * 14 diff --git a/miot/miot_client.go b/miot/miot_client.go index 6f7eec8..0b8144a 100644 --- a/miot/miot_client.go +++ b/miot/miot_client.go @@ -97,6 +97,10 @@ type MIoTClient struct { ctx context.Context cancel context.CancelFunc mu sync.RWMutex + refreshMu sync.Mutex // token 刷新互斥锁 + + // === Callbacks === + tokenRefreshCB func(accessToken, refreshToken string) } // NewMIoTClient creates a new MIoTClient. @@ -129,15 +133,41 @@ func NewMIoTClient(entryID string, entryData map[string]interface{}, uid string, // Init initializes the MIoTClient. // Aligns with Python MIoTClient.init_async. func (c *MIoTClient) Init() error { - // 1. Load user config + // 1. Load user config from storage, fallback to entryData + accessToken, _ := c.entryData["access_token"].(string) + refreshToken, _ := c.entryData["refresh_token"].(string) + if c.storage != nil { - config := c.storage.LoadUserConfig(c.uid, c.cloudServer, nil) - if len(config) > 0 { - fmt.Printf("[MIoTClient] Loaded user config\n") + authInfo := c.storage.LoadUserConfig(c.uid, c.cloudServer, nil) + if t, ok := authInfo["access_token"].(string); ok && t != "" { + accessToken = t + // Also restore refresh_token and expires_ts from storage + if rt, ok := authInfo["refresh_token"].(string); ok && rt != "" { + refreshToken = rt + c.entryData["refresh_token"] = rt + } + if et, ok := authInfo["expires_ts"].(float64); ok { + c.entryData["expires_ts"] = et + } + fmt.Printf("[MIoTClient] Loaded token from storage\n") + } else if accessToken != "" { + // First run: write entryData token to storage + expiresTS := float64(time.Now().Unix()) + 3600*24*30 // default 30 days + if et, ok := c.entryData["expires_ts"].(float64); ok && et > 0 { + expiresTS = et + } + c.storage.UpdateUserConfig(c.uid, c.cloudServer, map[string]interface{}{ + "access_token": accessToken, + "refresh_token": refreshToken, + "expires_ts": expiresTS, + }, true) + fmt.Printf("[MIoTClient] Saved initial token to storage\n") } - _ = config } + // Keep access_token in entryData for downstream use + c.entryData["access_token"] = accessToken + // 2. Load device cache if err := c.loadCacheDevice(); err != nil { fmt.Printf("[MIoTClient] Warning: failed to load device cache: %v\n", err) @@ -161,25 +191,21 @@ func (c *MIoTClient) Init() error { fmt.Printf("[MIoTClient] Cert client initialized\n") } - // 4. Initialize HTTP client (from existing code path) - if c.http == nil && c.entryData != nil { - accessToken, _ := c.entryData["access_token"].(string) - if accessToken != "" { - httpClient, err := NewMIoTHttpClient(c.cloudServer, OAUTH2_CLIENT_ID, accessToken) - if err != nil { - fmt.Printf("[MIoTClient] Warning: failed to create HTTP client: %v\n", err) - } else { - c.http = httpClient - fmt.Printf("[MIoTClient] HTTP client created\n") - } + // 4. Initialize HTTP client (use token from storage if available) + if c.http == nil && accessToken != "" { + httpClient, err := NewMIoTHttpClient(c.cloudServer, OAUTH2_CLIENT_ID, accessToken) + if err != nil { + fmt.Printf("[MIoTClient] Warning: failed to create HTTP client: %v\n", err) + } else { + c.http = httpClient + fmt.Printf("[MIoTClient] HTTP client created\n") } } - // 3.3 Initialize MipsCloudClient - if c.mipsCloud == nil && c.entryData != nil { - accessToken, _ := c.entryData["access_token"].(string) + // 3.3 Initialize MipsCloudClient (use token from storage if available) + if c.mipsCloud == nil && accessToken != "" { uuid, _ := c.entryData["uuid"].(string) - if accessToken != "" && uuid != "" { + if uuid != "" { broker := buildCloudBroker(c.cloudServer) mips := NewMipsCloudClient(broker, "ha."+uuid, OAUTH2_CLIENT_ID, accessToken) key := c.uid + "-" + c.cloudServer @@ -553,6 +579,11 @@ func (c *MIoTClient) UnsubDeviceState(did string) error { } // ============================================================================ +// SetTokenRefreshCallback sets a callback invoked when the background timer refreshes the token. +func (c *MIoTClient) SetTokenRefreshCallback(cb func(accessToken, refreshToken string)) { + c.tokenRefreshCB = cb +} + // M2: RequestRefreshAuthInfo / RequestRefreshUserCert // ============================================================================ @@ -577,6 +608,9 @@ func (c *MIoTClient) RequestRefreshAuthInfo(delaySec int) { // refreshAuthInfo performs the actual token refresh logic. // Aligns with Python: refresh_oauth_info_async. func (c *MIoTClient) refreshAuthInfo() { + c.refreshMu.Lock() + defer c.refreshMu.Unlock() + if c.oauth == nil || c.storage == nil { return } @@ -587,13 +621,13 @@ func (c *MIoTClient) refreshAuthInfo() { refreshToken, _ := authInfo["refresh_token"].(string) expiresTS, _ := authInfo["expires_ts"].(float64) - // Check if refresh is needed: expires_ts - now <= MIHOME_CERT_EXPIRE_MARGIN + // Check if refresh is needed: expires_ts - now <= MIHOME_REFRESH_THRESHOLD now := float64(time.Now().UnixMilli()) / 1000 - needRefresh := (expiresTS - now) <= float64(MIHOME_CERT_EXPIRE_MARGIN) + needRefresh := (expiresTS - now) <= float64(MIHOME_REFRESH_THRESHOLD) if !needRefresh && accessToken != "" { // Schedule next check - nextDelay := int(expiresTS - now - float64(MIHOME_CERT_EXPIRE_MARGIN)) + nextDelay := int(expiresTS - now - float64(MIHOME_REFRESH_THRESHOLD)) if nextDelay < 60 { nextDelay = 60 } @@ -618,7 +652,11 @@ func (c *MIoTClient) refreshAuthInfo() { newAccessToken, _ := result["access_token"].(string) newRefreshToken, _ := result["refresh_token"].(string) - newExpiresTS, _ := result["expires_ts"].(float64) + var newExpiresTS float64 + switch v := result["expires_ts"].(type) { + case float64: newExpiresTS = v + case int64: newExpiresTS = float64(v) + } if newAccessToken == "" { fmt.Printf("[MIoTClient] Token refresh returned empty access_token\n") @@ -645,14 +683,82 @@ func (c *MIoTClient) refreshAuthInfo() { fmt.Printf("[MIoTClient] Token refreshed successfully\n") + // Notify callback (xiaomi layer uses this to persist to DB) + if c.tokenRefreshCB != nil { + c.tokenRefreshCB(newAccessToken, newRefreshToken) + } + // Schedule next refresh - nextDelay := int(newExpiresTS - now - float64(MIHOME_CERT_EXPIRE_MARGIN)) + nextDelay := int(newExpiresTS - now - float64(MIHOME_REFRESH_THRESHOLD)) if nextDelay < 60 { nextDelay = 60 } c.RequestRefreshAuthInfo(nextDelay) } +// RefreshAuthInfo 手动触发 token 刷新并同步返回结果(对齐 Python refresh_oauth_info_async)。 +// 返回 map 包含 access_token / refresh_token / expires_ts。内部有互斥锁,可安全并发调用。 +func (c *MIoTClient) RefreshAuthInfo() (map[string]interface{}, error) { + c.refreshMu.Lock() + defer c.refreshMu.Unlock() + if c.oauth == nil || c.storage == nil { + return nil, fmt.Errorf("miot: oauth or storage not initialized") + } + + authInfo := c.storage.LoadUserConfig(c.uid, c.cloudServer, nil) + refreshToken, _ := authInfo["refresh_token"].(string) + accessToken, _ := authInfo["access_token"].(string) + expiresTS, _ := authInfo["expires_ts"].(float64) + + // 未临近过期且 token 有效 → 直接返回,避免重复刷新浪费 refresh_token + now := float64(time.Now().UnixMilli()) / 1000 + if accessToken != "" && (expiresTS-now) > float64(MIHOME_REFRESH_THRESHOLD) { + return map[string]interface{}{ + "access_token": accessToken, + "refresh_token": refreshToken, + "expires_ts": expiresTS, + }, nil + } + + if refreshToken == "" { + refreshToken, _ = c.entryData["refresh_token"].(string) + } + if refreshToken == "" { + return nil, fmt.Errorf("miot: no refresh token available") + } + + result, err := c.oauth.RefreshAccessToken(refreshToken) + if err != nil { + return nil, fmt.Errorf("miot: refresh access token: %w", err) + } + + // Save new auth info + c.entryData["access_token"] = result["access_token"] + c.entryData["refresh_token"] = result["refresh_token"] + if et, ok := result["expires_ts"]; ok { + c.entryData["expires_ts"] = et + } + c.storage.UpdateUserConfig(c.uid, c.cloudServer, map[string]interface{}{ + "access_token": result["access_token"], + "refresh_token": result["refresh_token"], + "expires_ts": result["expires_ts"], + }, true) + + // Update live clients + if c.http != nil { + if ac, ok := result["access_token"].(string); ok { + c.http.UpdateHTTPHeader(c.cloudServer, OAUTH2_CLIENT_ID, ac) + } + } + if c.mipsCloud != nil { + ac, _ := result["access_token"].(string) + c.mipsCloud.UpdateAccessToken(ac) + } + + fmt.Printf("[MIoTClient] RefreshAuthInfo: token refreshed\n") + return result, nil +} + // RequestRefreshUserCert schedules a user certificate refresh after delaySec seconds. // Uses MIHOME_CERT_EXPIRE_MARGIN to determine when refresh is needed. // Aligns with Python: __request_refresh_user_cert(delay_sec). diff --git a/xiaomi/README.md b/xiaomi/README.md index 8ece9ca..d7f70b9 100644 --- a/xiaomi/README.md +++ b/xiaomi/README.md @@ -256,7 +256,7 @@ v, _ := devices.NewVacuum(client, info) v.Start() / Stop() v.ReturnToDock() v.SetFanLevel(3) // 吸力档位 -status, _ := v.GetStatus() // → VacuumStatus{Power, Mode, FanLevel, Battery} +status, _ := v.GetStatus() // → VacuumStatus{On, Mode, FanLevel, Battery} ``` #### 热水器 (`WaterHeater`) diff --git a/xiaomi/client.go b/xiaomi/client.go index 6af30a9..ccdde4a 100644 --- a/xiaomi/client.go +++ b/xiaomi/client.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sync" + "time" "xiaomihome/miot" ) @@ -15,6 +16,7 @@ type Client struct { http *miot.MIoTHttpClient mipsCloud *miot.MipsCloudClient specParser *miot.MIoTSpecParser + uuid string deviceMu sync.RWMutex devices map[string]*miot.MIoTDevice @@ -23,32 +25,48 @@ type Client struct { mu sync.RWMutex } -// NewClient 创建并初始化客户端(零 miot import,一个 Config 搞定)。 +// 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.AccessToken, + "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("./data")) - miotClient.SetSpecParser(miot.NewMIoTSpecParser("en", miot.NewMIoTStorage("./data"), "./data/specs")) + 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.AccessToken) + 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) - // MIPS client ID 使用 config 中的 UUID,空则自动生成 - mipsUUID := cfg.UUID - if mipsUUID == "" { - mipsUUID = generateUUID() - } mipsCloud := miot.NewMipsCloudClient( "ssl://cn-ha.mqtt.io.mi.com:8883", - "ha."+mipsUUID, + "ha."+uuid, miot.OAUTH2_CLIENT_ID, - cfg.AccessToken, + cfg.AuthInfo.AccessToken, ) miotClient.SetMipsCloud(mipsCloud) @@ -56,20 +74,25 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { return nil, fmt.Errorf("xiaomi: init: %w", err) } - // 初始刷新设备列表(对齐 full_client 示例) _ = miotClient.RefreshDevices() - // 连接 MIPS(对齐 full_client 示例 line 83) if err := mipsCloud.Connect(); err != nil { return nil, fmt.Errorf("xiaomi: mips connect: %w", err) } + // 启动后台 token 自动刷新 + if cfg.OnTokenRefreshed != nil { + miotClient.SetTokenRefreshCallback(cfg.OnTokenRefreshed) + } + miotClient.RequestRefreshAuthInfo(10) + return &Client{ cfg: cfg, inner: miotClient, http: httpClient, mipsCloud: mipsCloud, - specParser: miot.NewMIoTSpecParser("en", miot.NewMIoTStorage("./data"), "./data/specs"), + uuid: uuid, + 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), @@ -87,6 +110,21 @@ func (c *Client) Close() error { 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: 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() @@ -94,7 +132,6 @@ func (c *Client) getOrCreateDevice(did string, info map[string]interface{}) *mio return dev } - // 从 device info 获取 URN 和 model 用于 SPEC 加载 urn, _ := info["urn"].(string) model, _ := info["model"].(string) var spec *miot.MIoTSpecInstance diff --git a/xiaomi/config.go b/xiaomi/config.go index e331c5f..f528a16 100644 --- a/xiaomi/config.go +++ b/xiaomi/config.go @@ -1,10 +1,20 @@ package xiaomi -// Config 客户端初始化配置。 -// AccessToken 必填,UUID/UID 对 MIPS 客户端和 cert 初始化有帮助。 -type Config struct { - ClientSecret string // OAuth Client Secret - AccessToken string // OAuth Access Token - UID string // 小米账号 UID(选填,用于 cert 初始化) - UUID string // MQTT 客户端标识(选填,空则自动生成) +// AuthInfo OAuth token 信息。 +type AuthInfo struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` // 有效期(秒) + ExpiresTS int64 `json:"expires_ts"` // 过期时间戳(unix),0 则内部按 expires_in 计算 +} + +// Config 客户端初始化配置。 +type Config struct { + AuthInfo AuthInfo // OAuth token 信息 + UUID string // MIPS 设备标识,OAuth 前生成,永久不可变 + UID string // 小米帐号 UID,OAuth 后从 API 获取 + DataDir string // 存储根路径(token/设备/SPEC 缓存),多用户场景每个用户用不同目录,如 ./data/{uid} + + // OnTokenRefreshed 后台自动刷新成功时回调,供调用方将新 token 持久化到数据库。 + OnTokenRefreshed func(accessToken, refreshToken string) } diff --git a/xiaomi/devices/air_conditioner.go b/xiaomi/devices/air_conditioner.go index 73918d0..68bf9bd 100644 --- a/xiaomi/devices/air_conditioner.go +++ b/xiaomi/devices/air_conditioner.go @@ -2,6 +2,8 @@ package devices import ( "context" + "fmt" + "xiaomihome/xiaomi" ) @@ -130,21 +132,21 @@ type AirConditioner interface { GetEco(ctx context.Context) (bool, error) // 订阅 - OnStateChanged(handler func(state ACState)) (string, error) + OnStateChanged(handler func(state ACState, changed string)) (string, error) } -// ACState 空调完整状态 +// ACState 空调完整状态(字段名对齐 SPEC type) type ACState struct { - Power bool `json:"power"` - Mode ACMode `json:"mode"` - TargetTemp float64 `json:"target_temp"` - CurrentTemp float64 `json:"current_temp"` - TargetHumidity int `json:"target_humidity"` - CurrentHumidity int `json:"current_humidity"` - FanSpeed FanSpeed `json:"fan_speed"` - Swing SwingMode `json:"swing"` - AuxHeat bool `json:"aux_heat"` - Eco bool `json:"eco"` + On bool `json:"on"` // SPEC: "on" + Mode ACMode `json:"mode"` // SPEC: "mode" + TargetTemperature float64 `json:"targetTemperature"` // SPEC: "target-temperature" + Temperature float64 `json:"temperature"` // SPEC: "temperature" (当前温度) + TargetHumidity int `json:"targetHumidity"` // SPEC: "target-humidity" + CurrentHumidity int `json:"currentHumidity"` // SPEC: "relative-humidity" + FanLevel FanSpeed `json:"fanLevel"` // SPEC: "fan-level" + Swing SwingMode `json:"swing"` // SPEC: "swing" + AuxHeat bool `json:"auxHeat"` // SPEC: "heater" + Eco bool `json:"eco"` // SPEC: "eco" } type acDevice struct { @@ -159,6 +161,8 @@ type acDevice struct { propCurrentHumidity siidPiid // "relative-humidity" propAuxHeat siidPiid // "aux-heat" or "heater" propEco siidPiid // "eco" + + stateCache ACState // MQTT 推送维护的本地缓存 } // NewAirConditioner creates an AirConditioner control for the given device. @@ -430,19 +434,58 @@ func (d *acDevice) GetEco(ctx context.Context) (bool, error) { return false, nil } -func (d *acDevice) OnStateChanged(handler func(state ACState)) (string, error) { - if !d.propOn.valid { +// propField maps a SPEC short name to a siid/piid pair and a cache-update function. +type propField struct { + siid, piid int + name string + fill func(*ACState, interface{}) +} + +func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) (string, error) { + fields := d.propFields() + if len(fields) == 0 { return "", xiaomi.ErrNotSupported } - return d.SubProp(d.propOn.siid, 0, func(did string, prop *xiaomi.PropertyValue) { - state := ACState{} - if b, ok := prop.Value.(bool); ok { - state.Power = b + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for _, f := range fields { + if f.siid == prop.SIID && f.piid == prop.PIID { + f.fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + return + } } - handler(state) }) } +func (d *acDevice) propFields() []propField { + var fields []propField + add := func(sp siidPiid, name string, fill func(*ACState, interface{})) { + if sp.valid { + fields = append(fields, propField{sp.siid, sp.piid, name, fill}) + } + } + add(d.propOn, "on", func(s *ACState, v interface{}) { s.On, _ = v.(bool) }) + add(d.propMode, "mode", func(s *ACState, v interface{}) { + switch m := v.(type) { + case string: s.Mode = ACMode(m) + case float64: s.Mode = ACMode(fmt.Sprintf("%.0f", m)) + } + }) + add(d.propTargetTemp, "target-temperature", func(s *ACState, v interface{}) { s.TargetTemperature = toFloat(v) }) + add(d.propCurrentTemp, "temperature", func(s *ACState, v interface{}) { s.Temperature = toFloat(v) }) + add(d.propFanSpeed, "fan-level", func(s *ACState, v interface{}) { + if v, ok := v.(float64); ok { s.FanLevel = FanSpeed(fmt.Sprintf("%.0f", v)) } + }) + add(d.propSwing, "swing", func(s *ACState, v interface{}) { + if v, ok := v.(float64); ok { s.Swing = SwingMode(fmt.Sprintf("%.0f", v)) } + }) + add(d.propTargetHumidity, "target-humidity", func(s *ACState, v interface{}) { s.TargetHumidity = int(toFloat(v)) }) + add(d.propCurrentHumidity, "relative-humidity", func(s *ACState, v interface{}) { s.CurrentHumidity = int(toFloat(v)) }) + add(d.propAuxHeat, "heater", func(s *ACState, v interface{}) { s.AuxHeat, _ = v.(bool) }) + add(d.propEco, "eco", func(s *ACState, v interface{}) { s.Eco, _ = v.(bool) }) + return fields +} + // toFloat converts an interface{} to float64. func toFloat(v interface{}) float64 { switch n := v.(type) { diff --git a/xiaomi/devices/cover.go b/xiaomi/devices/cover.go index 81dc894..cbf8e04 100644 --- a/xiaomi/devices/cover.go +++ b/xiaomi/devices/cover.go @@ -12,14 +12,18 @@ type Cover interface { Close(ctx context.Context) error Stop(ctx context.Context) error - SetPosition(ctx context.Context, pct int) error // 0=关, 100=全开 + SetPosition(ctx context.Context, pct int) error GetPosition(ctx context.Context) (int, error) + + OnStateChanged(handler func(position int)) (string, error) } type coverDevice struct { BaseDevice - propMotor siidPiid // "motor-control" — open/close/stop - propPosition siidPiid // "current-position" + propMotor siidPiid + propPosition siidPiid + + stateCache int } const ( @@ -28,65 +32,52 @@ const ( coverMotorStop = 2 ) -// NewCover creates a Cover control for the given device. func NewCover(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Cover, error) { - d := &coverDevice{ - BaseDevice: NewBaseDevice(client, info), - } - + d := &coverDevice{BaseDevice: NewBaseDevice(client, info)} resolver := d.getSpecResolver() if resolver == nil { return nil, xiaomi.ErrNoSPEC } - siid, piid, ok := resolver.find("motor-control") if !ok { return nil, xiaomi.ErrNotSupported } d.propMotor = siidPiid{siid, piid, true} - if s, p, ok := resolver.find("current-position"); ok { d.propPosition = siidPiid{s, p, true} } - return d, nil } func (d *coverDevice) Open(ctx context.Context) error { - if !d.propMotor.valid { - return xiaomi.ErrNotSupported - } + if !d.propMotor.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorOpen) } - func (d *coverDevice) Close(ctx context.Context) error { - if !d.propMotor.valid { - return xiaomi.ErrNotSupported - } + if !d.propMotor.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorClose) } - func (d *coverDevice) Stop(ctx context.Context) error { - if !d.propMotor.valid { - return xiaomi.ErrNotSupported - } + if !d.propMotor.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorStop) } - func (d *coverDevice) SetPosition(ctx context.Context, pct int) error { - if !d.propPosition.valid { - return xiaomi.ErrNotSupported - } + if !d.propPosition.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propPosition.siid, d.propPosition.piid, pct) } - func (d *coverDevice) GetPosition(ctx context.Context) (int, error) { - if !d.propPosition.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propPosition.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propPosition.siid, d.propPosition.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } return toInt(prop.Value), nil } + +func (d *coverDevice) OnStateChanged(handler func(position int)) (string, error) { + if !d.propPosition.valid { return "", xiaomi.ErrNotSupported } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + if prop.SIID == d.propPosition.siid && prop.PIID == d.propPosition.piid { + d.stateCache = toInt(prop.Value) + handler(d.stateCache) + } + }) +} diff --git a/xiaomi/devices/fan.go b/xiaomi/devices/fan.go index 77ed182..cfe7b4a 100644 --- a/xiaomi/devices/fan.go +++ b/xiaomi/devices/fan.go @@ -20,6 +20,16 @@ type Fan interface { SetNaturalWind(ctx context.Context, on bool) error IsNaturalWind(ctx context.Context) (bool, error) + + OnStateChanged(handler func(state FanState, changed string)) (string, error) +} + +// FanState 风扇完整状态(MQTT 缓存) +type FanState struct { + On bool `json:"on"` + FanLevel int `json:"fanLevel"` + Oscillation bool `json:"oscillation"` + NaturalWind bool `json:"naturalWind"` } type fanDevice struct { @@ -28,6 +38,8 @@ type fanDevice struct { propFanLevel siidPiid // "fan-level" propOscillation siidPiid // "horizontal-swing" or "oscillation" propNaturalWind siidPiid // "mode" (natural wind mode) + + stateCache FanState } // NewFan creates a Fan control for the given device. @@ -148,3 +160,36 @@ func (d *fanDevice) IsNaturalWind(ctx context.Context) (bool, error) { } return toInt(prop.Value) == 1, nil } + +func (d *fanDevice) OnStateChanged(handler func(state FanState, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*FanState, interface{}) } + var fields []field + if d.propOn.valid { + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", + func(s *FanState, v interface{}) { s.On, _ = v.(bool) }}) + } + if d.propFanLevel.valid { + fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", + func(s *FanState, v interface{}) { s.FanLevel = toInt(v) }}) + } + if d.propOscillation.valid { + fields = append(fields, field{d.propOscillation.siid, d.propOscillation.piid, "swing", + func(s *FanState, v interface{}) { s.Oscillation, _ = v.(bool) }}) + } + if d.propNaturalWind.valid { + fields = append(fields, field{d.propNaturalWind.siid, d.propNaturalWind.piid, "mode", + func(s *FanState, v interface{}) { s.NaturalWind = toInt(v) == 1 }}) + } + if len(fields) == 0 { + return "", xiaomi.ErrNotSupported + } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for _, f := range fields { + if f.siid == prop.SIID && f.piid == prop.PIID { + f.fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + return + } + } + }) +} diff --git a/xiaomi/devices/humidifier.go b/xiaomi/devices/humidifier.go index 02c31eb..37bb7e3 100644 --- a/xiaomi/devices/humidifier.go +++ b/xiaomi/devices/humidifier.go @@ -11,108 +11,97 @@ type Humidifier interface { TurnOn(ctx context.Context) error TurnOff(ctx context.Context) error IsOn(ctx context.Context) (bool, error) - SetTargetHumidity(ctx context.Context, pct int) error GetTargetHumidity(ctx context.Context) (int, error) - SetFanLevel(ctx context.Context, level int) error GetFanLevel(ctx context.Context) (int, error) + + OnStateChanged(handler func(state HumidifierState, changed string)) (string, error) +} + +// HumidifierState 加湿器状态(MQTT 缓存) +type HumidifierState struct { + On bool `json:"on"` + TargetHumidity int `json:"targetHumidity"` + FanLevel int `json:"fanLevel"` } type humidifierDevice struct { BaseDevice - propOn siidPiid // "on" - propTargetHumidity siidPiid // "target-humidity" - propFanLevel siidPiid // "fan-level" + propOn siidPiid + propTargetHumidity siidPiid + propFanLevel siidPiid + stateCache HumidifierState } -// NewHumidifier creates a Humidifier control for the given device. func NewHumidifier(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Humidifier, error) { - d := &humidifierDevice{ - BaseDevice: NewBaseDevice(client, info), - } - + d := &humidifierDevice{BaseDevice: NewBaseDevice(client, info)} resolver := d.getSpecResolver() - if resolver == nil { - return nil, xiaomi.ErrNoSPEC - } - + if resolver == nil { return nil, xiaomi.ErrNoSPEC } siid, piid, ok := resolver.find("on") - if !ok { - return nil, xiaomi.ErrNotSupported - } + if !ok { return nil, xiaomi.ErrNotSupported } d.propOn = siidPiid{siid, piid, true} - - if s, p, ok := resolver.find("target-humidity"); ok { - d.propTargetHumidity = siidPiid{s, p, true} - } - if s, p, ok := resolver.find("fan-level"); ok { - d.propFanLevel = siidPiid{s, p, true} - } - + if s, p, ok := resolver.find("target-humidity"); ok { d.propTargetHumidity = siidPiid{s, p, true} } + if s, p, ok := resolver.find("fan-level"); ok { d.propFanLevel = siidPiid{s, p, true} } return d, nil } func (d *humidifierDevice) TurnOn(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } + if !d.propOn.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true) } - func (d *humidifierDevice) TurnOff(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } + if !d.propOn.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false) } - func (d *humidifierDevice) IsOn(ctx context.Context) (bool, error) { - if !d.propOn.valid { - return false, xiaomi.ErrNotSupported - } + if !d.propOn.valid { return false, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid) - if err != nil { - return false, err - } - if b, ok := prop.Value.(bool); ok { - return b, nil - } + if err != nil { return false, err } + if b, ok := prop.Value.(bool); ok { return b, nil } return false, nil } - func (d *humidifierDevice) SetTargetHumidity(ctx context.Context, pct int) error { - if !d.propTargetHumidity.valid { - return xiaomi.ErrNotSupported - } + if !d.propTargetHumidity.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propTargetHumidity.siid, d.propTargetHumidity.piid, pct) } - func (d *humidifierDevice) GetTargetHumidity(ctx context.Context) (int, error) { - if !d.propTargetHumidity.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propTargetHumidity.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propTargetHumidity.siid, d.propTargetHumidity.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } return toInt(prop.Value), nil } - func (d *humidifierDevice) SetFanLevel(ctx context.Context, level int) error { - if !d.propFanLevel.valid { - return xiaomi.ErrNotSupported - } + if !d.propFanLevel.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid, level) } - func (d *humidifierDevice) GetFanLevel(ctx context.Context) (int, error) { - if !d.propFanLevel.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propFanLevel.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } return toInt(prop.Value), nil } + +func (d *humidifierDevice) OnStateChanged(handler func(state HumidifierState, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*HumidifierState, interface{}) } + var fields []field + if d.propOn.valid { + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HumidifierState, v interface{}) { s.On, _ = v.(bool) }}) + } + if d.propTargetHumidity.valid { + fields = append(fields, field{d.propTargetHumidity.siid, d.propTargetHumidity.piid, "target-humidity", func(s *HumidifierState, v interface{}) { s.TargetHumidity = toInt(v) }}) + } + if d.propFanLevel.valid { + fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", func(s *HumidifierState, v interface{}) { s.FanLevel = toInt(v) }}) + } + if len(fields) == 0 { return "", xiaomi.ErrNotSupported } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for _, f := range fields { + if f.siid == prop.SIID && f.piid == prop.PIID { + f.fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + return + } + } + }) +} diff --git a/xiaomi/devices/light.go b/xiaomi/devices/light.go index 6683c2c..ca374b0 100644 --- a/xiaomi/devices/light.go +++ b/xiaomi/devices/light.go @@ -29,15 +29,15 @@ type Light interface { SetColor(ctx context.Context, r, g, b int) error GetColor(ctx context.Context) (r, g, b int, err error) - OnStateChanged(handler func(state LightState)) (string, error) + OnStateChanged(handler func(state LightState, changed string)) (string, error) } -// LightState 灯光完整状态 +// LightState 灯光完整状态(字段名对齐 SPEC type) type LightState struct { - Power bool `json:"power"` - Brightness int `json:"brightness"` - ColorTemp int `json:"color_temp"` - ColorRGB [3]int `json:"color_rgb"` + On bool `json:"on"` // SPEC: "on" + Brightness int `json:"brightness"` // SPEC: "brightness" + ColorTemperature int `json:"colorTemperature"` // SPEC: "color-temperature" + ColorRGB [3]int `json:"colorRgb"` // SPEC: "color" } type lightDevice struct { @@ -46,6 +46,8 @@ type lightDevice struct { propBrightness siidPiid // "brightness" propColorTemp siidPiid // "color-temperature" propColor siidPiid // "color" + + stateCache LightState // MQTT 推送维护的本地缓存 } // NewLight creates a Light control for the given device. @@ -179,16 +181,45 @@ func (d *lightDevice) GetColor(ctx context.Context) (r, g, b int, err error) { return r, g, b, nil } -func (d *lightDevice) OnStateChanged(handler func(state LightState)) (string, error) { - if !d.propOn.valid { +func (d *lightDevice) OnStateChanged(handler func(state LightState, changed string)) (string, error) { + type field struct { + siid, piid int + name string + fill func(*LightState, interface{}) + } + var fields []field + if d.propOn.valid { + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", + func(s *LightState, v interface{}) { s.On, _ = v.(bool) }}) + } + if d.propBrightness.valid { + fields = append(fields, field{d.propBrightness.siid, d.propBrightness.piid, "brightness", + func(s *LightState, v interface{}) { s.Brightness = toInt(v) }}) + } + if d.propColorTemp.valid { + fields = append(fields, field{d.propColorTemp.siid, d.propColorTemp.piid, "color-temperature", + func(s *LightState, v interface{}) { s.ColorTemperature = toInt(v) }}) + } + if d.propColor.valid { + fields = append(fields, field{d.propColor.siid, d.propColor.piid, "color", + func(s *LightState, v interface{}) { + if v, ok := v.(float64); ok { + c := int(v) + s.ColorRGB = [3]int{(c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF} + } + }}) + } + if len(fields) == 0 { return "", xiaomi.ErrNotSupported } - return d.SubProp(d.propOn.siid, d.propOn.piid, func(did string, prop *xiaomi.PropertyValue) { - state := LightState{} - if b, ok := prop.Value.(bool); ok { - state.Power = b + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for _, f := range fields { + if f.siid == prop.SIID && f.piid == prop.PIID { + f.fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + return + } } - handler(state) }) } diff --git a/xiaomi/devices/occupancy_sensor.go b/xiaomi/devices/occupancy_sensor.go index 993fb51..529045f 100644 --- a/xiaomi/devices/occupancy_sensor.go +++ b/xiaomi/devices/occupancy_sensor.go @@ -10,79 +10,64 @@ import ( // OccupancySensor 人在传感器读取接口(只读)。 type OccupancySensor interface { Device - - // IsOccupied 是否有人。 IsOccupied(ctx context.Context) (bool, error) - - // GetIlluminance 环境光照度(lux),部分传感器支持。 GetIlluminance(ctx context.Context) (float64, error) -} -// NewOccupancySensor 创建人在传感器。失败返回错误(含无 SPEC / 无相关属性)。 -func NewOccupancySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (OccupancySensor, error) { - if info == nil { - return nil, xiaomi.ErrDeviceNotFound - } - - miotDev := client.GetMIoTDevice(info.DID) - if miotDev == nil { - return nil, fmt.Errorf("%w: device not initialized", xiaomi.ErrDeviceNotFound) - } - spec := miotDev.SpecInstance() - if spec == nil { - return nil, fmt.Errorf("%w: device has no SPEC information", xiaomi.ErrNotSupported) - } - - d := &occupancyDevice{ - BaseDevice: BaseDevice{client: client, info: info}, - } - - for _, svc := range spec.Services { - for _, prop := range svc.Properties { - switch propertyShortName(prop.Type) { - case "occupancy": - if !d.propOccupancy.valid { - d.propOccupancy = siidPiid{svc.IID, prop.PIID, true} - } - case "illumination": - if !d.propIlluminance.valid { - d.propIlluminance = siidPiid{svc.IID, prop.PIID, true} - } - } - } - } - - if !d.propOccupancy.valid { - return nil, fmt.Errorf("%w: no occupancy property in SPEC", xiaomi.ErrNotSupported) - } - - return d, nil + OnStateChanged(handler func(occupied bool, illuminance float64)) (string, error) } type occupancyDevice struct { BaseDevice propOccupancy siidPiid propIlluminance siidPiid + stateCache [2]interface{} // [0]=bool occupied, [1]=float64 lux +} + +func NewOccupancySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (OccupancySensor, error) { + if info == nil { return nil, xiaomi.ErrDeviceNotFound } + miotDev := client.GetMIoTDevice(info.DID) + if miotDev == nil { return nil, fmt.Errorf("%w: device not initialized", xiaomi.ErrDeviceNotFound) } + spec := miotDev.SpecInstance() + if spec == nil { return nil, fmt.Errorf("%w: device has no SPEC information", xiaomi.ErrNotSupported) } + d := &occupancyDevice{BaseDevice: BaseDevice{client: client, info: info}} + for _, svc := range spec.Services { + for _, prop := range svc.Properties { + switch propertyShortName(prop.Type) { + case "occupancy": + if !d.propOccupancy.valid { d.propOccupancy = siidPiid{svc.IID, prop.PIID, true} } + case "illumination": + if !d.propIlluminance.valid { d.propIlluminance = siidPiid{svc.IID, prop.PIID, true} } + } + } + } + if !d.propOccupancy.valid { return nil, fmt.Errorf("%w: no occupancy property in SPEC", xiaomi.ErrNotSupported) } + return d, nil } func (d *occupancyDevice) IsOccupied(ctx context.Context) (bool, error) { prop, err := d.GetProp(ctx, d.propOccupancy.siid, d.propOccupancy.piid) - if err != nil { - return false, err - } - if b, ok := prop.Value.(bool); ok { - return b, nil - } + if err != nil { return false, err } + if b, ok := prop.Value.(bool); ok { return b, nil } return false, nil } - func (d *occupancyDevice) GetIlluminance(ctx context.Context) (float64, error) { - if !d.propIlluminance.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propIlluminance.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propIlluminance.siid, d.propIlluminance.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } return toFloat(prop.Value), nil } + +func (d *occupancyDevice) OnStateChanged(handler func(occupied bool, illuminance float64)) (string, error) { + if !d.propOccupancy.valid && !d.propIlluminance.valid { return "", xiaomi.ErrNotSupported } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + switch { + case d.propOccupancy.valid && prop.SIID == d.propOccupancy.siid && prop.PIID == d.propOccupancy.piid: + if b, ok := prop.Value.(bool); ok { d.stateCache[0] = b } + case d.propIlluminance.valid && prop.SIID == d.propIlluminance.siid && prop.PIID == d.propIlluminance.piid: + d.stateCache[1] = toFloat(prop.Value) + } + occ, _ := d.stateCache[0].(bool) + lux, _ := d.stateCache[1].(float64) + handler(occ, lux) + }) +} diff --git a/xiaomi/devices/switch.go b/xiaomi/devices/switch.go index 8a3b4c5..011aadc 100644 --- a/xiaomi/devices/switch.go +++ b/xiaomi/devices/switch.go @@ -23,13 +23,14 @@ type Switch interface { ToggleChannel(ctx context.Context, ch int) error IsChannelOn(ctx context.Context, ch int) (bool, error) - OnStateChanged(handler func(on bool)) (string, error) + OnStateChanged(handler func(ch int, on bool)) (string, error) } // switchDevice is the concrete Switch implementation. type switchDevice struct { BaseDevice - channels []siidPiid // "on" property per channel (index 0 = channel 1) + channels []siidPiid // "on" property per channel (index 0 = channel 1) + stateCache []bool // MQTT 推送维护的本地缓存,索引 0 = 通道 1 } type siidPiid struct { @@ -63,6 +64,7 @@ func NewSwitch(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Switch, error) { return nil, xiaomi.ErrNotSupported } d.channels = channels + d.stateCache = make([]bool, len(channels)) return d, nil } @@ -129,14 +131,25 @@ func (d *switchDevice) IsChannelOn(ctx context.Context, ch int) (bool, error) { return false, nil } -func (d *switchDevice) OnStateChanged(handler func(on bool)) (string, error) { - p, ok := d.channel(1) - if !ok { +func (d *switchDevice) OnStateChanged(handler func(ch int, on bool)) (string, error) { + if len(d.channels) == 0 { return "", xiaomi.ErrNotSupported } - return d.SubProp(p.siid, p.piid, func(did string, prop *xiaomi.PropertyValue) { - if b, ok := prop.Value.(bool); ok { - handler(b) + // 构建 siid/piid → channel index 的快速查找表 + type chField struct{ siid, piid int } + lookup := make(map[chField]int) // key → channel index (0-based) + for i, c := range d.channels { + lookup[chField{c.siid, c.piid}] = i + } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for k, idx := range lookup { + if k.siid == prop.SIID && k.piid == prop.PIID { + if b, ok := prop.Value.(bool); ok { + d.stateCache[idx] = b + handler(idx+1, b) // ch = idx+1 + } + return + } } }) } diff --git a/xiaomi/devices/temp_humidity_sensor.go b/xiaomi/devices/temp_humidity_sensor.go index a978d0c..b49fb27 100644 --- a/xiaomi/devices/temp_humidity_sensor.go +++ b/xiaomi/devices/temp_humidity_sensor.go @@ -10,76 +10,64 @@ import ( // TempHumiditySensor 温湿度传感器读取接口(只读)。 type TempHumiditySensor interface { Device - GetTemperature(ctx context.Context) (float64, error) GetHumidity(ctx context.Context) (float64, error) -} -// NewTempHumiditySensor 创建温湿度传感器。失败返回错误(含无 SPEC / 无相关属性)。 -func NewTempHumiditySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (TempHumiditySensor, error) { - if info == nil { - return nil, xiaomi.ErrDeviceNotFound - } - - miotDev := client.GetMIoTDevice(info.DID) - if miotDev == nil { - return nil, fmt.Errorf("%w: device not initialized", xiaomi.ErrDeviceNotFound) - } - spec := miotDev.SpecInstance() - if spec == nil { - return nil, fmt.Errorf("%w: device has no SPEC information", xiaomi.ErrNotSupported) - } - - d := &tempHumidityDevice{ - BaseDevice: BaseDevice{client: client, info: info}, - } - - for _, svc := range spec.Services { - for _, prop := range svc.Properties { - switch propertyShortName(prop.Type) { - case "temperature": - if !d.propTemp.valid { - d.propTemp = siidPiid{svc.IID, prop.PIID, true} - } - case "relative-humidity": - if !d.propHumidity.valid { - d.propHumidity = siidPiid{svc.IID, prop.PIID, true} - } - } - } - } - - if !d.propTemp.valid && !d.propHumidity.valid { - return nil, fmt.Errorf("%w: no temperature or humidity property in SPEC", xiaomi.ErrNotSupported) - } - - return d, nil + OnStateChanged(handler func(temp, humidity float64)) (string, error) } type tempHumidityDevice struct { BaseDevice propTemp siidPiid propHumidity siidPiid + stateCache [2]float64 // [0]=temperature, [1]=humidity +} + +func NewTempHumiditySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (TempHumiditySensor, error) { + if info == nil { return nil, xiaomi.ErrDeviceNotFound } + miotDev := client.GetMIoTDevice(info.DID) + if miotDev == nil { return nil, fmt.Errorf("%w: device not initialized", xiaomi.ErrDeviceNotFound) } + spec := miotDev.SpecInstance() + if spec == nil { return nil, fmt.Errorf("%w: device has no SPEC information", xiaomi.ErrNotSupported) } + d := &tempHumidityDevice{BaseDevice: BaseDevice{client: client, info: info}} + for _, svc := range spec.Services { + for _, prop := range svc.Properties { + switch propertyShortName(prop.Type) { + case "temperature": + if !d.propTemp.valid { d.propTemp = siidPiid{svc.IID, prop.PIID, true} } + case "relative-humidity": + if !d.propHumidity.valid { d.propHumidity = siidPiid{svc.IID, prop.PIID, true} } + } + } + } + if !d.propTemp.valid && !d.propHumidity.valid { + return nil, fmt.Errorf("%w: no temperature or humidity property in SPEC", xiaomi.ErrNotSupported) + } + return d, nil } func (d *tempHumidityDevice) GetTemperature(ctx context.Context) (float64, error) { - if !d.propTemp.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propTemp.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propTemp.siid, d.propTemp.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } + return toFloat(prop.Value), nil +} +func (d *tempHumidityDevice) GetHumidity(ctx context.Context) (float64, error) { + if !d.propHumidity.valid { return 0, xiaomi.ErrNotSupported } + prop, err := d.GetProp(ctx, d.propHumidity.siid, d.propHumidity.piid) + if err != nil { return 0, err } return toFloat(prop.Value), nil } -func (d *tempHumidityDevice) GetHumidity(ctx context.Context) (float64, error) { - if !d.propHumidity.valid { - return 0, xiaomi.ErrNotSupported - } - prop, err := d.GetProp(ctx, d.propHumidity.siid, d.propHumidity.piid) - if err != nil { - return 0, err - } - return toFloat(prop.Value), nil +func (d *tempHumidityDevice) OnStateChanged(handler func(temp, humidity float64)) (string, error) { + if !d.propTemp.valid && !d.propHumidity.valid { return "", xiaomi.ErrNotSupported } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + switch { + case d.propTemp.valid && prop.SIID == d.propTemp.siid && prop.PIID == d.propTemp.piid: + d.stateCache[0] = toFloat(prop.Value) + case d.propHumidity.valid && prop.SIID == d.propHumidity.siid && prop.PIID == d.propHumidity.piid: + d.stateCache[1] = toFloat(prop.Value) + } + handler(d.stateCache[0], d.stateCache[1]) + }) } diff --git a/xiaomi/devices/thermostat.go b/xiaomi/devices/thermostat.go index df15bbc..2f72dc4 100644 --- a/xiaomi/devices/thermostat.go +++ b/xiaomi/devices/thermostat.go @@ -5,136 +5,106 @@ import ( "xiaomihome/xiaomi" ) -// Thermostat 温控器/电热毯统一控制接口 +// Thermostat 温控器统一控制接口 type Thermostat interface { Device TurnOn(ctx context.Context) error TurnOff(ctx context.Context) error IsOn(ctx context.Context) (bool, error) - - SetTargetTemp(ctx context.Context, celsius float64) error + SetTargetTemp(ctx context.Context, temp float64) error GetTargetTemp(ctx context.Context) (float64, error) - GetCurrentTemp(ctx context.Context) (float64, error) - SetHeating(ctx context.Context, on bool) error IsHeating(ctx context.Context) (bool, error) + + OnStateChanged(handler func(state HeaterState, changed string)) (string, error) } type thermostatDevice struct { BaseDevice - propOn siidPiid // "on" - propTargetTemp siidPiid // "target-temperature" - propCurrentTemp siidPiid // "temperature" - propHeating siidPiid // "heating" or "mode" + propOn siidPiid + propTargetTemp siidPiid + propCurrentTemp siidPiid + propHeating siidPiid + stateCache HeaterState } -// NewThermostat creates a Thermostat control for the given device. func NewThermostat(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Thermostat, error) { - d := &thermostatDevice{ - BaseDevice: NewBaseDevice(client, info), - } - + d := &thermostatDevice{BaseDevice: NewBaseDevice(client, info)} resolver := d.getSpecResolver() - if resolver == nil { - return nil, xiaomi.ErrNoSPEC - } - + if resolver == nil { return nil, xiaomi.ErrNoSPEC } siid, piid, ok := resolver.find("on") - if !ok { - return nil, xiaomi.ErrNotSupported - } + if !ok { return nil, xiaomi.ErrNotSupported } d.propOn = siidPiid{siid, piid, true} - - if s, p, ok := resolver.find("target-temperature"); ok { - d.propTargetTemp = siidPiid{s, p, true} - } - if s, p, ok := resolver.find("temperature"); ok { - d.propCurrentTemp = siidPiid{s, p, true} - } - if s, p, ok := resolver.find("heating"); ok { - d.propHeating = siidPiid{s, p, true} - } else if s, p, ok := resolver.find("heater"); ok { - d.propHeating = siidPiid{s, p, true} - } - + if s, p, ok := resolver.find("target-temperature"); ok { d.propTargetTemp = siidPiid{s, p, true} } + if s, p, ok := resolver.find("temperature"); ok { d.propCurrentTemp = siidPiid{s, p, true} } + if s, p, ok := resolver.find("heater"); ok { d.propHeating = siidPiid{s, p, true} } return d, nil } func (d *thermostatDevice) TurnOn(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } + if !d.propOn.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true) } - func (d *thermostatDevice) TurnOff(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } + if !d.propOn.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false) } - func (d *thermostatDevice) IsOn(ctx context.Context) (bool, error) { - if !d.propOn.valid { - return false, xiaomi.ErrNotSupported - } + if !d.propOn.valid { return false, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid) - if err != nil { - return false, err - } - if b, ok := prop.Value.(bool); ok { - return b, nil - } + if err != nil { return false, err } + if b, ok := prop.Value.(bool); ok { return b, nil } return false, nil } - -func (d *thermostatDevice) SetTargetTemp(ctx context.Context, celsius float64) error { - if !d.propTargetTemp.valid { - return xiaomi.ErrNotSupported - } - return d.SetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid, celsius) +func (d *thermostatDevice) SetTargetTemp(ctx context.Context, temp float64) error { + if !d.propTargetTemp.valid { return xiaomi.ErrNotSupported } + return d.SetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid, temp) } - func (d *thermostatDevice) GetTargetTemp(ctx context.Context) (float64, error) { - if !d.propTargetTemp.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propTargetTemp.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } return toFloat(prop.Value), nil } - func (d *thermostatDevice) GetCurrentTemp(ctx context.Context) (float64, error) { - if !d.propCurrentTemp.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propCurrentTemp.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propCurrentTemp.siid, d.propCurrentTemp.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } return toFloat(prop.Value), nil } - func (d *thermostatDevice) SetHeating(ctx context.Context, on bool) error { - if !d.propHeating.valid { - return xiaomi.ErrNotSupported - } + if !d.propHeating.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propHeating.siid, d.propHeating.piid, on) } - func (d *thermostatDevice) IsHeating(ctx context.Context) (bool, error) { - if !d.propHeating.valid { - return false, xiaomi.ErrNotSupported - } + if !d.propHeating.valid { return false, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propHeating.siid, d.propHeating.piid) - if err != nil { - return false, err - } - if b, ok := prop.Value.(bool); ok { - return b, nil - } + if err != nil { return false, err } + if b, ok := prop.Value.(bool); ok { return b, nil } return false, nil } + +func (d *thermostatDevice) OnStateChanged(handler func(state HeaterState, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*HeaterState, interface{}) } + var fields []field + if d.propOn.valid { + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HeaterState, v interface{}) { s.On, _ = v.(bool) }}) + } + if d.propTargetTemp.valid { + fields = append(fields, field{d.propTargetTemp.siid, d.propTargetTemp.piid, "target-temperature", func(s *HeaterState, v interface{}) { s.TargetTemp = toFloat(v) }}) + } + if d.propCurrentTemp.valid { + fields = append(fields, field{d.propCurrentTemp.siid, d.propCurrentTemp.piid, "temperature", func(s *HeaterState, v interface{}) { s.CurrentTemp = toFloat(v) }}) + } + if len(fields) == 0 { return "", xiaomi.ErrNotSupported } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for _, f := range fields { + if f.siid == prop.SIID && f.piid == prop.PIID { + f.fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + return + } + } + }) +} diff --git a/xiaomi/devices/vacuum.go b/xiaomi/devices/vacuum.go index b40f830..2499636 100644 --- a/xiaomi/devices/vacuum.go +++ b/xiaomi/devices/vacuum.go @@ -11,131 +11,113 @@ type Vacuum interface { Start(ctx context.Context) error Stop(ctx context.Context) error ReturnToDock(ctx context.Context) error - SetFanLevel(ctx context.Context, level int) error GetFanLevel(ctx context.Context) (int, error) - GetStatus(ctx context.Context) (VacuumStatus, error) + + OnStateChanged(handler func(state VacuumStatus, changed string)) (string, error) } -// VacuumStatus 扫地机运行状态 +// VacuumStatus 扫地机运行状态(字段名对齐 SPEC type) type VacuumStatus struct { - Power bool `json:"power"` - Mode int `json:"mode"` // 0=idle, 1=sweep, 2=mop, 3=sweep+mop, 4=charging - FanLevel int `json:"fan_level"` - Battery int `json:"battery"` // 0-100 + On bool `json:"on"` + Mode int `json:"mode"` + FanLevel int `json:"fanLevel"` + Battery int `json:"battery"` } type vacuumDevice struct { BaseDevice - propOn siidPiid // "on" — start/stop control - propMode siidPiid // "mode" — 0=charge(dock), 1=sweep, etc. - propFanLevel siidPiid // "fan-level" (suction power) - propBattery siidPiid // "battery-level" + propOn siidPiid + propMode siidPiid + propFanLevel siidPiid + propBattery siidPiid + stateCache VacuumStatus } -// NewVacuum creates a Vacuum control for the given device. func NewVacuum(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Vacuum, error) { - d := &vacuumDevice{ - BaseDevice: NewBaseDevice(client, info), - } - + d := &vacuumDevice{BaseDevice: NewBaseDevice(client, info)} resolver := d.getSpecResolver() - if resolver == nil { - return nil, xiaomi.ErrNoSPEC - } - + if resolver == nil { return nil, xiaomi.ErrNoSPEC } siid, piid, ok := resolver.find("on") - if !ok { - return nil, xiaomi.ErrNotSupported - } + if !ok { return nil, xiaomi.ErrNotSupported } d.propOn = siidPiid{siid, piid, true} - - if s, p, ok := resolver.find("mode"); ok { - d.propMode = siidPiid{s, p, true} - } - if s, p, ok := resolver.find("fan-level"); ok { - d.propFanLevel = siidPiid{s, p, true} - } - if s, p, ok := resolver.find("battery-level"); ok { - d.propBattery = siidPiid{s, p, true} - } - + if s, p, ok := resolver.find("mode"); ok { d.propMode = siidPiid{s, p, true} } + if s, p, ok := resolver.find("fan-level"); ok { d.propFanLevel = siidPiid{s, p, true} } + if s, p, ok := resolver.find("battery-level"); ok { d.propBattery = siidPiid{s, p, true} } return d, nil } func (d *vacuumDevice) Start(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } - return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true) + _, err := d.Action(ctx, d.propOn.siid, 1, nil) + return err } - func (d *vacuumDevice) Stop(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } - return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false) + _, err := d.Action(ctx, d.propOn.siid, 0, nil) + return err } - func (d *vacuumDevice) ReturnToDock(ctx context.Context) error { - if !d.propMode.valid { - // Try using on=false as fallback - return d.Stop(ctx) - } - return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, 0) + _, err := d.Action(ctx, d.propOn.siid, 2, nil) + return err } - func (d *vacuumDevice) SetFanLevel(ctx context.Context, level int) error { - if !d.propFanLevel.valid { - return xiaomi.ErrNotSupported - } + if !d.propFanLevel.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid, level) } - func (d *vacuumDevice) GetFanLevel(ctx context.Context) (int, error) { - if !d.propFanLevel.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propFanLevel.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } return toInt(prop.Value), nil } - func (d *vacuumDevice) GetStatus(ctx context.Context) (VacuumStatus, error) { + // 云端实时查询 + keys := []xiaomi.PropKey{} + add := func(sp siidPiid) { + if sp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: sp.siid, PIID: sp.piid}) } + } + add(d.propOn); add(d.propMode); add(d.propFanLevel); add(d.propBattery) + props, err := d.client.GetProps(ctx, keys) + if err != nil { return VacuumStatus{}, err } status := VacuumStatus{} - - if d.propOn.valid { - prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid) - if err == nil { - if b, ok := prop.Value.(bool); ok { - status.Power = b - } + for _, p := range props { + switch { + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: + status.On, _ = p.Value.(bool) + case d.propMode.valid && p.SIID == d.propMode.siid && p.PIID == d.propMode.piid: + status.Mode = toInt(p.Value) + case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid: + status.FanLevel = toInt(p.Value) + case d.propBattery.valid && p.SIID == d.propBattery.siid && p.PIID == d.propBattery.piid: + status.Battery = toInt(p.Value) } } - - if d.propMode.valid { - prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid) - if err == nil { - status.Mode = toInt(prop.Value) - } - } - - if d.propFanLevel.valid { - prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid) - if err == nil { - status.FanLevel = toInt(prop.Value) - } - } - - if d.propBattery.valid { - prop, err := d.GetProp(ctx, d.propBattery.siid, d.propBattery.piid) - if err == nil { - status.Battery = toInt(prop.Value) - } - } - return status, nil } + +func (d *vacuumDevice) OnStateChanged(handler func(state VacuumStatus, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*VacuumStatus, interface{}) } + var fields []field + if d.propOn.valid { + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *VacuumStatus, v interface{}) { s.On, _ = v.(bool) }}) + } + if d.propMode.valid { + fields = append(fields, field{d.propMode.siid, d.propMode.piid, "mode", func(s *VacuumStatus, v interface{}) { s.Mode = toInt(v) }}) + } + if d.propFanLevel.valid { + fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", func(s *VacuumStatus, v interface{}) { s.FanLevel = toInt(v) }}) + } + if d.propBattery.valid { + fields = append(fields, field{d.propBattery.siid, d.propBattery.piid, "battery", func(s *VacuumStatus, v interface{}) { s.Battery = toInt(v) }}) + } + if len(fields) == 0 { return "", xiaomi.ErrNotSupported } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for _, f := range fields { + if f.siid == prop.SIID && f.piid == prop.PIID { + f.fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + return + } + } + }) +} diff --git a/xiaomi/devices/water_heater.go b/xiaomi/devices/water_heater.go index 5a6a0e0..9590c43 100644 --- a/xiaomi/devices/water_heater.go +++ b/xiaomi/devices/water_heater.go @@ -5,106 +5,98 @@ import ( "xiaomihome/xiaomi" ) -// WaterHeater 热水器统一控制接口 +// WaterHeater 热水器/饮水机统一控制接口 type WaterHeater interface { Device TurnOn(ctx context.Context) error TurnOff(ctx context.Context) error IsOn(ctx context.Context) (bool, error) - - SetTargetTemp(ctx context.Context, celsius float64) error + SetTargetTemp(ctx context.Context, temp float64) error GetTargetTemp(ctx context.Context) (float64, error) - GetCurrentTemp(ctx context.Context) (float64, error) + + OnStateChanged(handler func(state HeaterState, changed string)) (string, error) +} + +// HeaterState 热水器状态(MQTT 缓存) +type HeaterState struct { + On bool `json:"on"` + TargetTemp float64 `json:"targetTemperature"` + CurrentTemp float64 `json:"temperature"` } type waterHeaterDevice struct { BaseDevice - propOn siidPiid // "on" - propTargetTemp siidPiid // "target-temperature" - propCurrentTemp siidPiid // "temperature" + propOn siidPiid + propTargetTemp siidPiid + propCurrentTemp siidPiid + stateCache HeaterState } -// NewWaterHeater creates a WaterHeater control for the given device. func NewWaterHeater(client *xiaomi.Client, info *xiaomi.DeviceInfo) (WaterHeater, error) { - d := &waterHeaterDevice{ - BaseDevice: NewBaseDevice(client, info), - } - + d := &waterHeaterDevice{BaseDevice: NewBaseDevice(client, info)} resolver := d.getSpecResolver() - if resolver == nil { - return nil, xiaomi.ErrNoSPEC - } - + if resolver == nil { return nil, xiaomi.ErrNoSPEC } siid, piid, ok := resolver.find("on") - if !ok { - return nil, xiaomi.ErrNotSupported - } + if !ok { return nil, xiaomi.ErrNotSupported } d.propOn = siidPiid{siid, piid, true} - - if s, p, ok := resolver.find("target-temperature"); ok { - d.propTargetTemp = siidPiid{s, p, true} - } - if s, p, ok := resolver.find("temperature"); ok { - d.propCurrentTemp = siidPiid{s, p, true} - } - + if s, p, ok := resolver.find("target-temperature"); ok { d.propTargetTemp = siidPiid{s, p, true} } + if s, p, ok := resolver.find("temperature"); ok { d.propCurrentTemp = siidPiid{s, p, true} } return d, nil } func (d *waterHeaterDevice) TurnOn(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } + if !d.propOn.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true) } - func (d *waterHeaterDevice) TurnOff(ctx context.Context) error { - if !d.propOn.valid { - return xiaomi.ErrNotSupported - } + if !d.propOn.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false) } - func (d *waterHeaterDevice) IsOn(ctx context.Context) (bool, error) { - if !d.propOn.valid { - return false, xiaomi.ErrNotSupported - } + if !d.propOn.valid { return false, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid) - if err != nil { - return false, err - } - if b, ok := prop.Value.(bool); ok { - return b, nil - } + if err != nil { return false, err } + if b, ok := prop.Value.(bool); ok { return b, nil } return false, nil } - -func (d *waterHeaterDevice) SetTargetTemp(ctx context.Context, celsius float64) error { - if !d.propTargetTemp.valid { - return xiaomi.ErrNotSupported - } - return d.SetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid, celsius) +func (d *waterHeaterDevice) SetTargetTemp(ctx context.Context, temp float64) error { + if !d.propTargetTemp.valid { return xiaomi.ErrNotSupported } + return d.SetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid, temp) } - func (d *waterHeaterDevice) GetTargetTemp(ctx context.Context) (float64, error) { - if !d.propTargetTemp.valid { - return 0, xiaomi.ErrNotSupported - } + if !d.propTargetTemp.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid) - if err != nil { - return 0, err - } + if err != nil { return 0, err } + return toFloat(prop.Value), nil +} +func (d *waterHeaterDevice) GetCurrentTemp(ctx context.Context) (float64, error) { + if !d.propCurrentTemp.valid { return 0, xiaomi.ErrNotSupported } + prop, err := d.GetProp(ctx, d.propCurrentTemp.siid, d.propCurrentTemp.piid) + if err != nil { return 0, err } return toFloat(prop.Value), nil } -func (d *waterHeaterDevice) GetCurrentTemp(ctx context.Context) (float64, error) { - if !d.propCurrentTemp.valid { - return 0, xiaomi.ErrNotSupported +func (d *waterHeaterDevice) OnStateChanged(handler func(state HeaterState, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*HeaterState, interface{}) } + var fields []field + if d.propOn.valid { + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HeaterState, v interface{}) { s.On, _ = v.(bool) }}) } - prop, err := d.GetProp(ctx, d.propCurrentTemp.siid, d.propCurrentTemp.piid) - if err != nil { - return 0, err + if d.propTargetTemp.valid { + fields = append(fields, field{d.propTargetTemp.siid, d.propTargetTemp.piid, "target-temperature", func(s *HeaterState, v interface{}) { s.TargetTemp = toFloat(v) }}) } - return toFloat(prop.Value), nil + if d.propCurrentTemp.valid { + fields = append(fields, field{d.propCurrentTemp.siid, d.propCurrentTemp.piid, "temperature", func(s *HeaterState, v interface{}) { s.CurrentTemp = toFloat(v) }}) + } + if len(fields) == 0 { return "", xiaomi.ErrNotSupported } + return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { + for _, f := range fields { + if f.siid == prop.SIID && f.piid == prop.PIID { + f.fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + return + } + } + }) } diff --git a/xiaomi/examples/01_user_home/main.go b/xiaomi/examples/01_user_home/main.go index 824d354..92b75af 100644 --- a/xiaomi/examples/01_user_home/main.go +++ b/xiaomi/examples/01_user_home/main.go @@ -21,10 +21,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/02_device_list/main.go b/xiaomi/examples/02_device_list/main.go index 7947106..40c60d0 100644 --- a/xiaomi/examples/02_device_list/main.go +++ b/xiaomi/examples/02_device_list/main.go @@ -7,8 +7,8 @@ import ( "log" "time" - "xiaomihome/xiaomi" miotconfig "xiaomihome/miot/examples/config" + "xiaomihome/xiaomi" ) func main() { @@ -21,10 +21,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/03_switch_control/main.go b/xiaomi/examples/03_switch_control/main.go index 8031d66..b83dfb5 100644 --- a/xiaomi/examples/03_switch_control/main.go +++ b/xiaomi/examples/03_switch_control/main.go @@ -22,10 +22,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/04_light_control/main.go b/xiaomi/examples/04_light_control/main.go index 6ab6efc..be5d080 100644 --- a/xiaomi/examples/04_light_control/main.go +++ b/xiaomi/examples/04_light_control/main.go @@ -22,10 +22,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) @@ -88,9 +93,10 @@ func main() { log.Printf("当前颜色: R=%d G=%d B=%d", r, g, b) } - subID, err := lt.OnStateChanged(func(state devices.LightState) { - log.Printf("灯光状态变化: power=%v brightness=%d colorTemp=%dK rgb=[%d,%d,%d]", - state.Power, state.Brightness, state.ColorTemp, + subID, err := lt.OnStateChanged(func(state devices.LightState, changed string) { + log.Printf("灯光状态变化: %s → on=%v brightness=%d colorTemp=%dK rgb=[%d,%d,%d]", + changed, + state.On, state.Brightness, state.ColorTemperature, state.ColorRGB[0], state.ColorRGB[1], state.ColorRGB[2]) }) if err != nil { diff --git a/xiaomi/examples/05_ac_control/main.go b/xiaomi/examples/05_ac_control/main.go index 750a0ed..c4b30df 100644 --- a/xiaomi/examples/05_ac_control/main.go +++ b/xiaomi/examples/05_ac_control/main.go @@ -22,10 +22,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) @@ -96,10 +101,10 @@ func main() { log.Println("开启节能模式...") ac.SetEco(ctx, true) - subID, err := ac.OnStateChanged(func(state devices.ACState) { - log.Printf("空调状态变化: power=%v mode=%s target=%.1f current=%.1f fan=%s swing=%s", - state.Power, state.Mode, state.TargetTemp, state.CurrentTemp, - state.FanSpeed, state.Swing) + subID, err := ac.OnStateChanged(func(state devices.ACState, changed string) { + log.Printf("空调状态变化: %s → on=%v mode=%s target=%.1f current=%.1f fan=%s swing=%s", + changed, state.On, state.Mode, state.TargetTemperature, state.Temperature, + state.FanLevel, state.Swing) }) if err != nil { log.Printf("订阅状态失败: %v", err) diff --git a/xiaomi/examples/06_fan_control/main.go b/xiaomi/examples/06_fan_control/main.go index 34f3909..e6e2d2e 100644 --- a/xiaomi/examples/06_fan_control/main.go +++ b/xiaomi/examples/06_fan_control/main.go @@ -22,10 +22,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/07_prop_subscribe/main.go b/xiaomi/examples/07_prop_subscribe/main.go index 847e649..f62fa71 100644 --- a/xiaomi/examples/07_prop_subscribe/main.go +++ b/xiaomi/examples/07_prop_subscribe/main.go @@ -21,10 +21,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/08_adv_filter_classify/main.go b/xiaomi/examples/08_adv_filter_classify/main.go index 632715c..f453030 100644 --- a/xiaomi/examples/08_adv_filter_classify/main.go +++ b/xiaomi/examples/08_adv_filter_classify/main.go @@ -26,10 +26,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/09_spec_parser/main.go b/xiaomi/examples/09_spec_parser/main.go index 1a443a7..a165dc0 100644 --- a/xiaomi/examples/09_spec_parser/main.go +++ b/xiaomi/examples/09_spec_parser/main.go @@ -27,10 +27,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/10_cover_control/main.go b/xiaomi/examples/10_cover_control/main.go index 3326175..448ee16 100644 --- a/xiaomi/examples/10_cover_control/main.go +++ b/xiaomi/examples/10_cover_control/main.go @@ -22,10 +22,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/11_sensor_control/main.go b/xiaomi/examples/11_sensor_control/main.go index 9c56c39..4a18955 100644 --- a/xiaomi/examples/11_sensor_control/main.go +++ b/xiaomi/examples/11_sensor_control/main.go @@ -21,10 +21,15 @@ func main() { } client, err := xiaomi.NewClient(ctx, xiaomi.Config{ - ClientSecret: "", - AccessToken: cfg.AuthInfo.AccessToken, - UID: cfg.UID, - UUID: cfg.UUID, + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, }) if err != nil { log.Fatal(err) diff --git a/xiaomi/examples/12_ac_subscribe/data/cert/mihome_ca.cert b/xiaomi/examples/12_ac_subscribe/data/cert/mihome_ca.cert new file mode 100644 index 0000000..59d8a0c --- /dev/null +++ b/xiaomi/examples/12_ac_subscribe/data/cert/mihome_ca.cert @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIBazCCAQ+gAwIBAgIEA/UKYDAMBggqhkjOPQQDAgUAMCIxEzARBgNVBAoTCk1p +amlhIFJvb3QxCzAJBgNVBAYTAkNOMCAXDTE2MTEyMzAxMzk0NVoYDzIwNjYxMTEx +MDEzOTQ1WjAiMRMwEQYDVQQKEwpNaWppYSBSb290MQswCQYDVQQGEwJDTjBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABL71iwLa4//4VBqgRI+6xE23xpovqPCxtv96 +2VHbZij61/Ag6jmi7oZ/3Xg/3C+whglcwoUEE6KALGJ9vccV9PmjLzAtMAwGA1Ud +EwQFMAMBAf8wHQYDVR0OBBYEFJa3onw5sblmM6n40QmyAGDI5sURMAwGCCqGSM49 +BAMCBQADSAAwRQIgchciK9h6tZmfrP8Ka6KziQ4Lv3hKfrHtAZXMHPda4IYCIQCG +az93ggFcbrG9u2wixjx1HKW4DUA5NXZG0wWQTpJTbQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBjzCCATWgAwIBAgIBATAKBggqhkjOPQQDAjAiMRMwEQYDVQQKEwpNaWppYSBS +b290MQswCQYDVQQGEwJDTjAgFw0yMjA2MDkxNDE0MThaGA8yMDcyMDUyNzE0MTQx +OFowLDELMAkGA1UEBhMCQ04xHTAbBgNVBAoMFE1JT1QgQ0VOVFJBTCBHQVRFV0FZ +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdYrzbnp/0x/cZLZnuEDXTFf8mhj4 +CVpZPwgj9e9Ve5r3K7zvu8Jjj7JF1JjQYvEC6yhp1SzBgglnK4L8xQzdiqNQME4w +HQYDVR0OBBYEFCf9+YBU7pXDs6K6CAQPRhlGJ+cuMB8GA1UdIwQYMBaAFJa3onw5 +sblmM6n40QmyAGDI5sURMAwGA1UdEwQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIh +AKUv+c8v98vypkGMTzMwckGjjVqTef8xodsy6PhcSCq+AiA/n9mDs62hAo5zXyJy +Bs1s7mqXPf1XgieoxIvs1MqyiA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/xiaomi/examples/12_ac_subscribe/data/specs/urn_miot-spec-v2_device_air-condition-outlet_0000A045_sixwgh-ym01_1_0000D01F.json b/xiaomi/examples/12_ac_subscribe/data/specs/urn_miot-spec-v2_device_air-condition-outlet_0000A045_sixwgh-ym01_1_0000D01F.json new file mode 100644 index 0000000..0655db9 --- /dev/null +++ b/xiaomi/examples/12_ac_subscribe/data/specs/urn_miot-spec-v2_device_air-condition-outlet_0000A045_sixwgh-ym01_1_0000D01F.json @@ -0,0 +1 @@ +{"type":"urn:miot-spec-v2:device:air-condition-outlet:0000A045:sixwgh-ym01:1:0000D01F","description":"sixwgh acpartner ym01","services":[{"iid":1,"type":"urn:miot-spec-v2:service:device-information:00007801:sixwgh-ym01:1:0000D01F","description":"Device Information","properties":[{"iid":1,"type":"urn:miot-spec-v2:property:manufacturer:00000001:sixwgh-ym01:1:0000D01F","description":"Device Manufacturer","format":"string","access":["read"],"source":4},{"iid":2,"type":"urn:miot-spec-v2:property:model:00000002:sixwgh-ym01:1:0000D01F","description":"Device Model","format":"string","access":["read"],"source":4},{"iid":3,"type":"urn:miot-spec-v2:property:serial-number:00000003:sixwgh-ym01:1:0000D01F","description":"Device ID","format":"string","access":["read"],"source":4},{"iid":4,"type":"urn:miot-spec-v2:property:firmware-revision:00000005:sixwgh-ym01:1:0000D01F","description":"Current Firmware Version","format":"string","access":["read"],"source":4},{"iid":5,"type":"urn:miot-spec-v2:property:serial-no:000000FF:sixwgh-ym01:1:0000D01F","description":"Serial Number","format":"string","access":["read"],"source":4}]},{"iid":2,"type":"urn:miot-spec-v2:service:air-condition-outlet:000078A3:sixwgh-ym01:1:0000D01F","description":"Air Condition Outlet","properties":[{"iid":1,"type":"urn:miot-spec-v2:property:on:00000006:sixwgh-ym01:1:0000D01F","description":"Switch Status","format":"bool","access":["read","write","notify"],"source":1}]},{"iid":3,"type":"urn:miot-spec-v2:service:air-conditioner:0000780F:sixwgh-ym01:1:0000D01F","description":"Air Conditioner","properties":[{"iid":1,"type":"urn:miot-spec-v2:property:fault:00000009:sixwgh-ym01:1:0000D01F","description":"Fault","format":"uint8","access":["read","notify"],"value-list":[{"value":0,"description":"No Faults"}],"source":1},{"iid":2,"type":"urn:miot-spec-v2:property:mode:00000008:sixwgh-ym01:1:0000D01F","description":"Mode","format":"uint8","access":["read","write","notify"],"value-list":[{"value":0,"description":"Cool"},{"value":1,"description":"Heat"},{"value":2,"description":"Auto"},{"value":3,"description":"Fan"},{"value":4,"description":"Dry"}],"source":1,"gatt-access":[]},{"iid":3,"type":"urn:miot-spec-v2:property:target-temperature:00000021:sixwgh-ym01:1:0000D01F","description":"Target Temperature","format":"float","access":["read","write","notify"],"unit":"celsius","value-range":[16,30,1],"source":1,"gatt-access":[]},{"iid":4,"type":"urn:miot-spec-v2:property:sleep-mode:00000028:sixwgh-ym01:1","description":"Sleep Mode","format":"bool","access":["read","write","notify"]},{"iid":5,"type":"urn:miot-spec-v2:property:on:00000006:sixwgh-ym01:1","description":"Switch Status","format":"bool","access":["read","write","notify"]}],"actions":[{"iid":1,"type":"urn:miot-spec-v2:action:toggle:00002811:sixwgh-ym01:1","description":"Toggle","in":[],"out":[]}]},{"iid":4,"type":"urn:miot-spec-v2:service:fan-control:00007809:sixwgh-ym01:1:0000D01F","description":"Fan Control","properties":[{"iid":2,"type":"urn:miot-spec-v2:property:fan-level:00000016:sixwgh-ym01:1:0000D01F","description":"Fan Level","format":"uint8","access":["read","write","notify"],"value-list":[{"value":0,"description":"Auto"},{"value":1,"description":"Low"},{"value":2,"description":"Medium"},{"value":3,"description":"High"}],"source":1,"gatt-access":[]},{"iid":3,"type":"urn:miot-spec-v2:property:vertical-swing:00000018:sixwgh-ym01:1:0000D01F","description":"Vertical Swing","format":"bool","access":["read","write","notify"],"source":1}]},{"iid":7,"type":"urn:miot-spec-v2:service:power-consumption:0000780E:sixwgh-ym01:1:0000D01F","description":"Power Consumption","properties":[{"iid":1,"type":"urn:miot-spec-v2:property:power-consumption:0000002F:sixwgh-ym01:1:0000D01F","description":"Power Consumption","format":"float","access":["read","notify"],"value-range":[0,65535,0.01],"source":1,"gatt-access":[]},{"iid":6,"type":"urn:miot-spec-v2:property:electric-power:00000066:sixwgh-ym01:1:0000D01F","description":"Electric Power","format":"float","access":["read","notify"],"unit":"watt","value-range":[0,65535,1],"source":1,"gatt-access":[]},{"iid":7,"type":"urn:miot-spec-v2:property:power-consumption-accumulation-way:00000183:sixwgh-ym01:1","description":"Power Consumption Accumulation Way","format":"bool","access":["read","notify"]}]},{"iid":6,"type":"urn:miot-spec-v2:service:indicator-light:00007803:sixwgh-ym01:1:0000D01F","description":"Indicator Light","properties":[{"iid":1,"type":"urn:miot-spec-v2:property:on:00000006:sixwgh-ym01:1:0000D01F","description":"Switch Status","format":"bool","access":["read","write","notify"],"source":1}]},{"iid":8,"type":"urn:miot-spec-v2:service:air-condition-outlet-matching:000078B9:sixwgh-ym01:1","description":"Air Condition Outlet Matching","properties":[{"iid":2,"type":"urn:miot-spec-v2:property:ac-work-mode:00000142:sixwgh-ym01:1","description":"Ac Work Mode","format":"uint8","access":["read","write","notify"],"value-list":[{"value":0,"description":"Ac Mode With Outlet"},{"value":1,"description":"Ac Mode Without Outlet"}]}]},{"iid":9,"type":"urn:sixwgh-spec:service:custom:00007801:sixwgh-ym01:1","description":"custom","properties":[{"iid":3,"type":"urn:sixwgh-spec:property:indicator-set:00000001:sixwgh-ym01:1","description":"","format":"string","access":["read","notify","write"]},{"iid":4,"type":"urn:sixwgh-spec:property:set-device:00000002:sixwgh-ym01:1","description":"","format":"uint16","access":["read","notify"],"value-range":[0,65535,1]},{"iid":5,"type":"urn:sixwgh-spec:property:safe-sleep-mode:00000003:sixwgh-ym01:1","description":"","format":"string","access":["read","notify","write"]},{"iid":6,"type":"urn:sixwgh-spec:property:match-confirmation:00000004:sixwgh-ym01:1","description":"","format":"bool","access":["write"]},{"iid":11,"type":"urn:sixwgh-spec:property:ir-code-one:00000005:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":12,"type":"urn:sixwgh-spec:property:ir-code-two:00000006:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":13,"type":"urn:sixwgh-spec:property:ir-code-three:00000007:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":14,"type":"urn:sixwgh-spec:property:ir-code-four:00000008:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":15,"type":"urn:sixwgh-spec:property:ir-code-five:00000009:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":16,"type":"urn:sixwgh-spec:property:ir-code-six:0000000a:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":17,"type":"urn:sixwgh-spec:property:ir-code-seven:0000000b:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":18,"type":"urn:sixwgh-spec:property:ir-code-eight:0000000c:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":19,"type":"urn:sixwgh-spec:property:ir-code-nine:0000000d:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":20,"type":"urn:sixwgh-spec:property:ir-code-ten:0000000e:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":21,"type":"urn:sixwgh-spec:property:ir-code-eleven:0000000f:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":22,"type":"urn:sixwgh-spec:property:ir-code-twelve:00000010:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":23,"type":"urn:sixwgh-spec:property:ir-code-thirteen:00000011:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":24,"type":"urn:sixwgh-spec:property:ir-code-fourteen:00000012:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":25,"type":"urn:sixwgh-spec:property:ir-code-fifteen:00000013:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":26,"type":"urn:sixwgh-spec:property:ir-code-sixteen:00000014:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":27,"type":"urn:sixwgh-spec:property:ir-code-seventeen:00000015:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":28,"type":"urn:sixwgh-spec:property:ir-code-eighteen:00000016:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":29,"type":"urn:sixwgh-spec:property:ir-code-nineteen:00000017:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":30,"type":"urn:sixwgh-spec:property:ir-code-twenty:00000018:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":31,"type":"urn:sixwgh-spec:property:ir-code-twentyone:00000019:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":32,"type":"urn:sixwgh-spec:property:ir-code-twentytwo:0000001a:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":33,"type":"urn:sixwgh-spec:property:ir-code-twentythree:0000001b:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":34,"type":"urn:sixwgh-spec:property:ir-code-twentyfour:0000001c:sixwgh-ym01:1","description":"","format":"string","access":["write"]},{"iid":36,"type":"urn:sixwgh-spec:property:mode:0000001d:sixwgh-ym01:1","description":"","format":"uint8","access":["write"],"value-list":[{"value":0,"description":"Cool"},{"value":1,"description":"Heat"},{"value":2,"description":"Auto"},{"value":3,"description":"Fan"},{"value":4,"description":"Dry"}]},{"iid":37,"type":"urn:sixwgh-spec:property:target-temperature:0000001e:sixwgh-ym01:1","description":"","format":"float","access":["write"],"value-range":[16,30,1]},{"iid":38,"type":"urn:sixwgh-spec:property:fan-level:0000001f:sixwgh-ym01:1","description":"","format":"uint8","access":["write"],"value-list":[{"value":0,"description":"Auto"},{"value":1,"description":"Level1"},{"value":2,"description":"Level2"},{"value":3,"description":"Level3"}]},{"iid":39,"type":"urn:sixwgh-spec:property:vertical-swing:00000020:sixwgh-ym01:1","description":"","format":"bool","access":["write"]}],"actions":[{"iid":1,"type":"urn:sixwgh-spec:action:ir-code-send-one:00002801:sixwgh-ym01:1","description":"ir-code-send-one","in":[11,12,13,14,15],"out":[]},{"iid":2,"type":"urn:sixwgh-spec:action:ir-code-send-two:00002802:sixwgh-ym01:1","description":"ir-code-send-two","in":[16,17,18,19,20],"out":[]},{"iid":3,"type":"urn:sixwgh-spec:action:ir-code-send-three:00002803:sixwgh-ym01:1","description":"ir-code-send-three","in":[21,22,23,24,25],"out":[]},{"iid":4,"type":"urn:sixwgh-spec:action:ir-code-send-four:00002804:sixwgh-ym01:1","description":"ir-code-send-four","in":[26,27,28,29,30],"out":[]},{"iid":5,"type":"urn:sixwgh-spec:action:ir-code-send-five:00002805:sixwgh-ym01:1","description":"ir-code-send-five","in":[31,32,33,34],"out":[]},{"iid":6,"type":"urn:sixwgh-spec:action:specified-state:00002806:sixwgh-ym01:1","description":"specified-state","in":[36,37,38,39],"out":[]}]},{"iid":10,"type":"urn:sixwgh-spec:service:quick-cool:00007802:sixwgh-ym01:1","description":"quick-cool","properties":[{"iid":1,"type":"urn:sixwgh-spec:property:quick-cool-switch:00000001:sixwgh-ym01:1","description":"","format":"bool","access":["read","notify","write"]},{"iid":2,"type":"urn:sixwgh-spec:property:quick-cool-time:00000002:sixwgh-ym01:1","description":"","format":"uint8","access":["read","notify","write"],"unit":"minutes","value-range":[1,59,1]},{"iid":3,"type":"urn:sixwgh-spec:property:turn-on-comfort:00000003:sixwgh-ym01:1","description":"","format":"uint8","access":["write"],"value-list":[{"value":1,"description":"Turn On Comfort"}]}]}]} \ No newline at end of file diff --git a/xiaomi/examples/12_ac_subscribe/main.go b/xiaomi/examples/12_ac_subscribe/main.go new file mode 100644 index 0000000..07e2c30 --- /dev/null +++ b/xiaomi/examples/12_ac_subscribe/main.go @@ -0,0 +1,71 @@ +// Package main demonstrates AirConditioner.OnStateChanged subscription. +// 运行后等待状态变化,Ctrl+C 退出。 +package main + +import ( + "context" + "log" + "os" + "os/signal" + + "xiaomihome/xiaomi" + "xiaomihome/xiaomi/devices" + miotconfig "xiaomihome/miot/examples/config" +) + +func main() { + cfg, err := miotconfig.LoadConfig("../../../miot/examples/config.json") + if err != nil { + log.Fatal("请先运行 miot/examples/cloud_oauth 获取 token:", err) + } + + client, err := xiaomi.NewClient(context.Background(), xiaomi.Config{ + AuthInfo: xiaomi.AuthInfo{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresTS: cfg.AuthInfo.ExpiresTS, + }, + DataDir: "./data", + UUID: cfg.UUID, + UID: cfg.UID, + }) + if err != nil { + log.Fatal(err) + } + defer client.Close() + + const targetDID = "2053225728" + info, err := client.GetDevice(context.Background(), targetDID) + if err != nil { + log.Fatalf("获取设备 %s 失败: %v", targetDID, err) + } + log.Printf("设备: %s (DID: %s, 型号: %s)", info.Name, info.DID, info.Model) + + ac, err := devices.NewAirConditioner(client, info) + if err != nil { + log.Fatal("创建 AirConditioner 失败:", err) + } + + // 先查一次当前状态 + on, _ := ac.IsOn(context.Background()) + log.Printf("初始状态: on=%v", on) + + // 订阅状态变化 + subID, err := ac.OnStateChanged(func(state devices.ACState, changed string) { + log.Printf("[%s] on=%v mode=%s target=%.1f current=%.1f fan=%s swing=%s", + changed, state.On, state.Mode, state.TargetTemperature, state.Temperature, + state.FanLevel, state.Swing) + }) + if err != nil { + log.Printf("订阅失败: %v", err) + } else { + log.Printf("已订阅: subID=%s", subID) + } + + log.Println("\n等待状态变化,Ctrl+C 退出...") + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt) + <-ch + log.Println("退出") +} diff --git a/xiaomi/migration/FIX_CONFIG.md b/xiaomi/migration/FIX_CONFIG.md new file mode 100644 index 0000000..ad2cbd3 --- /dev/null +++ b/xiaomi/migration/FIX_CONFIG.md @@ -0,0 +1,87 @@ +# Config 补回 UUID / UID + +> 2026-06-30 + +## UUID 和 UID 的作用 + +| 字段 | 作用 | 约束 | +|------|------|------| +| UUID | MIPS MQTT 客户端标识,`"ha.{uuid}"` 格式连接小米云端 MQTT | OAuth 前生成,永久不变,变更则 token 失效 | +| UID | 小米帐号唯一 ID,证书生成、storage 文件命名、OAuth 流程用到 | OAuth 后固定的,从 `GetHomeInfos` API 取 `uid` 字段 | + +--- + +## 修改清单 + +### 1. `xiaomi/config.go` + +```diff + type Config struct { + AccessToken string + RefreshToken string ++ UUID string // MIPS 设备标识,OAuth 前生成,永久不可变 ++ UID string // 小米帐号 UID,OAuth 后从 API 获取 + } +``` + +### 2. `xiaomi/client.go` — 第 33 行、第 40 行 + +```diff +- uuid := generateUUID() ++ if cfg.UUID == "" { ++ return nil, fmt.Errorf("xiaomi: Config.UUID is required") ++ } ++ uuid := cfg.UUID + +- miot.NewMIoTClient("xiaomi_sdk", entryData, "", "cn", miot.CtrlModeAuto) ++ miot.NewMIoTClient("xiaomi_sdk", entryData, cfg.UID, "cn", miot.CtrlModeAuto) +``` + +### 3. 11 个示例文件 — 每个补两行 + +``` +xiaomi/examples/01_user_home/main.go +xiaomi/examples/02_device_list/main.go +xiaomi/examples/03_switch_control/main.go +xiaomi/examples/04_light_control/main.go +xiaomi/examples/05_ac_control/main.go +xiaomi/examples/06_fan_control/main.go +xiaomi/examples/07_prop_subscribe/main.go +xiaomi/examples/08_adv_filter_classify/main.go +xiaomi/examples/09_spec_parser/main.go +xiaomi/examples/10_cover_control/main.go +xiaomi/examples/11_sensor_control/main.go +``` + +每个文件当前是: + +```go +client, err := xiaomi.NewClient(ctx, xiaomi.Config{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, +}) +``` + +改为: + +```go +client, err := xiaomi.NewClient(ctx, xiaomi.Config{ + AccessToken: cfg.AuthInfo.AccessToken, + RefreshToken: cfg.AuthInfo.RefreshToken, + UUID: cfg.UUID, + UID: cfg.UID, +}) +``` + +--- + +## 调用方使用流程 + +``` +1. OAuth 前:生成 UUID(crypto/rand 16字节 hex),存库 +2. OAuth:获取 AccessToken + RefreshToken +3. OAuth 后:调 GetHomeInfos 获取 UID,存库 +4. 每次创建 Client:从库读四个值传入 Config +5. Token 刷新:调 RefreshToken(),新 AccessToken/RefreshToken 写回库 + UID 和 UUID 永不更新 +``` diff --git a/xiaomi/oauth.go b/xiaomi/oauth.go index 896732d..6f98bd6 100644 --- a/xiaomi/oauth.go +++ b/xiaomi/oauth.go @@ -5,15 +5,17 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "time" "xiaomihome/miot" ) -// TokenResult OAuth token 交换结果 +// TokenResult OAuth token 交换/刷新结果 type TokenResult struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` + ExpiresTS int64 `json:"expires_ts"` UID string `json:"uid"` } @@ -30,10 +32,12 @@ func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, e if err != nil { return nil, fmt.Errorf("xiaomi: exchange token: %w", err) } + expiresIn := intVal(result, "expires_in") return &TokenResult{ AccessToken: strVal(result, "access_token"), RefreshToken: strVal(result, "refresh_token"), - ExpiresIn: intVal(result, "expires_in"), + ExpiresIn: expiresIn, + ExpiresTS: time.Now().Unix() + int64(expiresIn), UID: strVal(result, "uid"), }, nil }