refactor: 重构 token 管理,新增 NATS adapter 和多示例

按照 TOKEN_REFACTOR.md / FIX_CONFIG.md 方案重构核心层:
- miot: Init() 优先读 storage token,新增 RefreshAuthInfo() 和刷新回调
- xiaomi: Config 改用 AuthInfo 结构体(含 UUID/UID/DataDir/OnTokenRefreshed)
- xiaomi: NewClient(ctx, Config) 新签名,新增 RefreshToken(),后台自动刷新
- xiaomi/devices: 设备控制接口重构
- 新增示例 12_ac_subscribe,其余示例适配新 Config
This commit is contained in:
2026-06-30 12:16:49 +08:00
parent a3d94c4b9f
commit 8ffc9e4e44
33 changed files with 1019 additions and 593 deletions
+1 -1
View File
@@ -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`)
+53 -16
View File
@@ -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
+17 -7
View File
@@ -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)
}
+62 -19
View File
@@ -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) {
+24 -33
View File
@@ -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)
}
})
}
+45
View File
@@ -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
}
}
})
}
+53 -64
View File
@@ -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
}
}
})
}
+44 -13
View File
@@ -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)
})
}
+42 -57
View File
@@ -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)
})
}
+21 -8
View File
@@ -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
}
}
})
}
+44 -56
View File
@@ -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])
})
}
+55 -85
View File
@@ -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
}
}
})
}
+72 -90
View File
@@ -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
}
}
})
}
+56 -64
View File
@@ -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
}
}
})
}
+9 -4
View File
@@ -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)
+10 -5
View File
@@ -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)
+9 -4
View File
@@ -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)
+13 -7
View File
@@ -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 {
+13 -8
View File
@@ -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)
+9 -4
View File
@@ -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)
+9 -4
View File
@@ -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)
@@ -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)
+9 -4
View File
@@ -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)
+9 -4
View File
@@ -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)
+9 -4
View File
@@ -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)
@@ -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-----
+71
View File
@@ -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("退出")
}
+87
View File
@@ -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 永不更新
```
+6 -2
View File
@@ -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
}