diff --git a/.gitignore b/.gitignore index 8436a13..61d2389 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ ip.xdb /cmd/cloud/admin/go_build_ant_scream_cmd_cloud_admin.exe /xiaomi/examples/05_ac_control/data/miot_config/*.dict *.dict +/xiaomi/examples/*/data/ diff --git a/bridge/account.go b/bridge/account.go new file mode 100644 index 0000000..8230683 --- /dev/null +++ b/bridge/account.go @@ -0,0 +1,88 @@ +package bridge + +import ( + "context" + "strconv" + + "xiaomihome/xiaomi" + "xiaomihome/xiaomi/devices" +) + +// ── 账号 / Token / 家庭 ── + +func (b *Bridge) RefreshToken(ctx context.Context) (*xiaomi.TokenResult, error) { + return b.client.RefreshToken(ctx) +} + +func (b *Bridge) GetUserInfo(ctx context.Context) (*xiaomi.UserInfo, error) { + return b.client.GetUserInfo(ctx) +} + +func (b *Bridge) GetHomeList(ctx context.Context) ([]*xiaomi.HomeInfo, error) { + return b.client.GetHomeList(ctx) +} + +func (b *Bridge) GetHomeDevices(ctx context.Context, homeID int64) ([]*xiaomi.DeviceInfo, error) { + return b.client.GetDevices(ctx, xiaomi.FilterByHome(strconv.FormatInt(homeID, 10))) +} + +func (b *Bridge) GetHome(ctx context.Context, homeID int64) (*xiaomi.HomeInfo, error) { + return b.client.GetHome(ctx, homeID) +} + +func (b *Bridge) GetRooms(ctx context.Context, homeID int64) ([]*xiaomi.RoomInfo, error) { + return b.client.GetRooms(ctx, homeID) +} + +func (b *Bridge) GetRoomDevices(ctx context.Context, homeID, roomID int64) ([]string, error) { + return b.client.GetRoomDevices(ctx, homeID, roomID) +} + +func (b *Bridge) RefreshDevices(ctx context.Context, homeIDs ...int64) error { + var ids []string + if len(homeIDs) > 0 { + ids = make([]string, len(homeIDs)) + for i, v := range homeIDs { + ids[i] = strconv.FormatInt(v, 10) + } + } + err := b.client.RefreshDevices(ctx, ids...) + if err != nil { + return err + } + + // 获取设备列表:指定家庭则只取家庭设备,否则全量 + b.mu.Lock() + defer b.mu.Unlock() + + if len(homeIDs) > 0 { + for _, hid := range homeIDs { + devs, err := b.client.GetDevices(ctx, xiaomi.FilterByHome(strconv.FormatInt(hid, 10))) + if err != nil { + return err + } + for _, info := range devs { + if _, exists := b.dv[info.DID]; !exists { + if d, createErr := devices.Create(b.client, info); createErr == nil { + b.dv[info.DID] = d + } + } + } + } + return nil + } + + // 全量 + devList, err := b.client.GetDevices(ctx) + if err != nil { + return err + } + for _, info := range devList { + if _, exists := b.dv[info.DID]; !exists { + if d, createErr := devices.Create(b.client, info); createErr == nil { + b.dv[info.DID] = d + } + } + } + return nil +} diff --git a/bridge/bridge.go b/bridge/bridge.go new file mode 100644 index 0000000..c531c90 --- /dev/null +++ b/bridge/bridge.go @@ -0,0 +1,266 @@ +// Package bridge 提供多用户小米设备管理门面。 +// +// 外部只需 import 这一个包,无需直接依赖 xiaomi/devices/miot。 +// +// 用法: +// +// pool := bridge.NewPool() +// pool.Add(ctx, id, uuid, auth, dataDir) +// b, _ := pool.Get(id) +// ac, _ := b.AC("did") +// ac.TurnOn(ctx) +// state := ac.GetState() +package bridge + +import ( + "context" + "fmt" + "sync" + + "xiaomihome/logger" + "xiaomihome/xiaomi" + "xiaomihome/xiaomi/devices" +) + +// ── 类型别名:业务层不需要 import devices ── + +type ( + ACState = devices.ACState + LightState = devices.LightState + FanState = devices.FanState + HumidifierState = devices.HumidifierState + HeaterState = devices.HeaterState + VacuumStatus = devices.VacuumStatus +) + +// ── Pool ── + +// Pool 全局路由:id → Bridge。 +type Pool struct { + mu sync.RWMutex + items map[int64]*Bridge +} + +// NewPool 创建 Pool。 +func NewPool() *Pool { + return &Pool{items: make(map[int64]*Bridge)} +} + +// Get 按 id 查找。 +func (p *Pool) Get(id int64) (*Bridge, bool) { + p.mu.RLock() + defer p.mu.RUnlock() + b, ok := p.items[id] + return b, ok +} + +// Add 新建并注册一个 Bridge。 +func (p *Pool) Add(ctx context.Context, id int64, uuid string, auth xiaomi.AuthInfo, dataDir string, homeIDs []int64) (*Bridge, error) { + p.mu.Lock() + defer p.mu.Unlock() + if _, exists := p.items[id]; exists { + return nil, fmt.Errorf("bridge: %d already exists", id) + } + b, err := newBridge(ctx, id, uuid, auth, dataDir, homeIDs) + if err != nil { + return nil, err + } + p.items[id] = b + return b, nil +} + +// Remove 移除并关闭。 +func (p *Pool) Remove(id int64) error { + p.mu.Lock() + b, ok := p.items[id] + if ok { + delete(p.items, id) + } + p.mu.Unlock() + if !ok { + return fmt.Errorf("bridge: %d not found", id) + } + return b.Close() +} + +// Close 关闭所有。 +func (p *Pool) Close() { + p.mu.Lock() + defer p.mu.Unlock() + for id, b := range p.items { + b.Close() + delete(p.items, id) + } +} + +// ── Bridge ── + +// Bridge 封装单个用户的 Client + 类型化设备控制器 + 状态缓存。 +type Bridge struct { + id int64 + client *xiaomi.Client + lgr logger.Logger + mu sync.RWMutex + dv map[string]interface{} // DID → devices.Xxx interface + + onStateChanged func(id int64, did, deviceType, changed string, state interface{}) +} + +func newBridge(ctx context.Context, id int64, uuid string, auth xiaomi.AuthInfo, dataDir string, homeIDs []int64) (*Bridge, error) { + idStr := fmt.Sprintf("%d", id) + client, err := xiaomi.NewClient(ctx, xiaomi.Config{ + AuthInfo: auth, + DataDir: dataDir, + UUID: uuid, + UID: idStr, + HomeIDs: homeIDs, + }) + if err != nil { + return nil, fmt.Errorf("bridge: %w", err) + } + + b := &Bridge{ + id: id, + client: client, + lgr: logger.Default(), + dv: make(map[string]interface{}), + } + + devList, err := client.GetDevices(ctx) + if err != nil { + client.Close() + return nil, fmt.Errorf("bridge: GetDevices: %w", err) + } + + for _, info := range devList { + d, createErr := devices.Create(client, info) + if createErr != nil { + continue + } + b.dv[info.DID] = d + } + + b.lgr.Infof("[bridge] %d: %d devices", id, len(b.dv)) + return b, nil +} + +// SetLogger 设置日志记录器。 +func (b *Bridge) SetLogger(l logger.Logger) { b.lgr = l } + +func (b *Bridge) Close() error { return b.client.Close() } + +// Devices 返回设备列表(DID → 类型名)。 +func (b *Bridge) Devices() map[string]string { + b.mu.RLock() + defer b.mu.RUnlock() + out := make(map[string]string, len(b.dv)) + for did, d := range b.dv { + out[did] = deviceTypeName(d) + } + return out +} + +func deviceTypeName(d interface{}) string { + switch d.(type) { + case devices.AirConditioner: return "air_conditioner" + case devices.Light: return "light" + case devices.Switch: return "switch" + case devices.Fan: return "fan" + case devices.Cover: return "cover" + case devices.Humidifier: return "humidifier" + case devices.Vacuum: return "vacuum" + case devices.WaterHeater: return "water_heater" + case devices.Thermostat: return "thermostat" + case devices.TempHumiditySensor: return "temp_humidity_sensor" + case devices.OccupancySensor: return "occupancy_sensor" + case devices.Speaker: return "speaker" + default: return "unknown" + } +} + +// ── 类型化设备访问器 ── + +func (b *Bridge) AC(did string) (devices.AirConditioner, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + ac, ok := d.(devices.AirConditioner) + if !ok { return nil, fmt.Errorf("bridge: device %s is not an AirConditioner", did) } + return ac, nil +} +func (b *Bridge) Light(did string) (devices.Light, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + lt, ok := d.(devices.Light) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Light", did) } + return lt, nil +} +func (b *Bridge) Switch(did string) (devices.Switch, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + sw, ok := d.(devices.Switch) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Switch", did) } + return sw, nil +} +func (b *Bridge) Fan(did string) (devices.Fan, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + f, ok := d.(devices.Fan) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Fan", did) } + return f, nil +} +func (b *Bridge) Cover(did string) (devices.Cover, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + cv, ok := d.(devices.Cover) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Cover", did) } + return cv, nil +} +func (b *Bridge) Humidifier(did string) (devices.Humidifier, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + h, ok := d.(devices.Humidifier) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Humidifier", did) } + return h, nil +} +func (b *Bridge) Vacuum(did string) (devices.Vacuum, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + v, ok := d.(devices.Vacuum) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Vacuum", did) } + return v, nil +} +func (b *Bridge) WaterHeater(did string) (devices.WaterHeater, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + wh, ok := d.(devices.WaterHeater) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a WaterHeater", did) } + return wh, nil +} +func (b *Bridge) Thermostat(did string) (devices.Thermostat, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + t, ok := d.(devices.Thermostat) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Thermostat", did) } + return t, nil +} +func (b *Bridge) TempHumidity(did string) (devices.TempHumiditySensor, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + s, ok := d.(devices.TempHumiditySensor) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a TempHumiditySensor", did) } + return s, nil +} +func (b *Bridge) Occupancy(did string) (devices.OccupancySensor, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + s, ok := d.(devices.OccupancySensor) + if !ok { return nil, fmt.Errorf("bridge: device %s is not an OccupancySensor", did) } + return s, nil +} +func (b *Bridge) Speaker(did string) (devices.Speaker, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + sp, ok := d.(devices.Speaker) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Speaker", did) } + return sp, nil +} diff --git a/bridge/subscribe.go b/bridge/subscribe.go new file mode 100644 index 0000000..ab185e2 --- /dev/null +++ b/bridge/subscribe.go @@ -0,0 +1,147 @@ +package bridge + +import ( + "context" + "fmt" + + "xiaomihome/xiaomi/devices" +) + +// ── 回调 ── + +// OnStateChanged 注册全局设备状态变化回调。state 是 devices 包的类型化 State 结构体, +// 调用方自行 switch 类型(ACState / LightState / FanState ...)。 +func (b *Bridge) OnStateChanged(handler func(id int64, did, deviceType, changed string, state interface{})) { + b.mu.Lock() + b.onStateChanged = handler + b.mu.Unlock() +} + +// StartReceiving 为所有设备注册 MQTT 消息接收回调(MQTT 订阅已在 miot 层完成)。 +func (b *Bridge) StartReceiving() { + b.mu.RLock() + defer b.mu.RUnlock() + for did, d := range b.dv { + devType := deviceTypeName(d) + switch ctrl := d.(type) { + case devices.AirConditioner: + ctrl.OnStateChanged(func(s devices.ACState, ch string) { + b.fire(did, devType, ch, s) + }) + case devices.Light: + ctrl.OnStateChanged(func(s devices.LightState, ch string) { + b.fire(did, devType, ch, s) + }) + case devices.Switch: + ctrl.OnStateChanged(func(ch int, on bool) { + b.fire(did, devType, fmt.Sprintf("ch%d", ch), map[string]interface{}{"channel": ch, "on": on}) + }) + case devices.Fan: + ctrl.OnStateChanged(func(s devices.FanState, ch string) { + b.fire(did, devType, ch, s) + }) + case devices.Cover: + ctrl.OnStateChanged(func(pos int) { + b.fire(did, devType, devices.PropPosition, pos) + }) + case devices.Humidifier: + ctrl.OnStateChanged(func(s devices.HumidifierState, ch string) { + b.fire(did, devType, ch, s) + }) + case devices.Vacuum: + ctrl.OnStateChanged(func(s devices.VacuumStatus, ch string) { + b.fire(did, devType, ch, s) + }) + case devices.WaterHeater: + ctrl.OnStateChanged(func(s devices.HeaterState, ch string) { + b.fire(did, devType, ch, s) + }) + case devices.Thermostat: + ctrl.OnStateChanged(func(s devices.HeaterState, ch string) { + b.fire(did, devType, ch, s) + }) + case devices.TempHumiditySensor: + ctrl.OnStateChanged(func(temp, hum float64) { + b.fire(did, devType, "temperature_humidity", map[string]float64{"temperature": temp, "humidity": hum}) + }) + case devices.OccupancySensor: + ctrl.OnStateChanged(func(occ bool, lux float64) { + b.fire(did, devType, "occupancy", map[string]interface{}{"occupied": occ, "illuminance": lux}) + }) + case devices.Speaker: + ctrl.OnStateChanged(func(s devices.SpeakerState, ch string) { + b.fire(did, devType, ch, s) + }) + } + } +} + +func (b *Bridge) fire(did, devType, changed string, state interface{}) { + b.mu.RLock() + cb := b.onStateChanged + b.mu.RUnlock() + if cb != nil { + cb(b.id, did, devType, changed, state) + } +} + +func deviceTypeNameRaw(d interface{}) string { + if d == nil { return "unknown" } + return deviceTypeName(d) +} + +// ── 状态 ── + +// FetchAllStates 批量云端拉取所有设备状态,更新缓存。 +func (b *Bridge) FetchAllStates(ctx context.Context) { + b.mu.RLock() + defer b.mu.RUnlock() + for _, d := range b.dv { + switch ctrl := d.(type) { + case devices.AirConditioner: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d AC state: on=%v mode=%s target=%.1f", b.id, s.On, s.Mode, s.TargetTemperature) + } + case devices.Light: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Light state: on=%v brightness=%d", b.id, s.On, s.Brightness) + } + case devices.Switch: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Switch state: %v", b.id, s) + } + case devices.Fan: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Fan state: on=%v level=%d", b.id, s.On, s.FanLevel) + } + case devices.Humidifier: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Humidifier state: on=%v hum=%d", b.id, s.On, s.TargetHumidity) + } + case devices.Vacuum: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Vacuum state: on=%v mode=%d", b.id, s.On, s.Mode) + } + case devices.WaterHeater: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Heater: on=%v target=%.1f", b.id, s.On, s.TargetTemp) + } + case devices.Thermostat: + if s, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Thermostat: target=%.1f current=%.1f", b.id, s.TargetTemp, s.CurrentTemp) + } + case devices.Cover: + if pos, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Cover: pos=%d", b.id, pos) + } + case devices.TempHumiditySensor: + if t, h, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Sensor: temp=%.1f hum=%.1f", b.id, t, h) + } + case devices.OccupancySensor: + if o, l, err := ctrl.FetchState(ctx); err == nil { + b.lgr.Infof("[bridge] %d Occupancy: occ=%v lux=%.0f", b.id, o, l) + } + } + } +} diff --git a/go.mod b/go.mod index c908b04..e323ae0 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/miekg/dns v1.1.72 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index f153704..330f292 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/hashicorp/mdns v1.0.7 h1:yWoQVMW5JOiDxQnIUcm3IDt0kCjf3TuXHDbdEKPsbAY= github.com/hashicorp/mdns v1.0.7/go.mod h1:yjuhYhZyPDqXXL48xC7cdpGwGUMwu7OViDmsuT5COvg= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..bb40061 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,25 @@ +package logger + +import "github.com/sirupsen/logrus" + +// Logger 通用日志接口。logrus.Entry 原生满足,无需适配器。 +type Logger interface { + Debugf(format string, args ...interface{}) + Infof(format string, args ...interface{}) + Warnf(format string, args ...interface{}) + Errorf(format string, args ...interface{}) +} + +var defaultLogger Logger = logrus.StandardLogger() + +func init() { + // l := logrus.New() + // l.SetReportCaller(true) + // defaultLogger = logrus.NewEntry(l) +} + +// Default 返回全局默认 Logger(未注入时使用)。 +func Default() Logger { return defaultLogger } + +// SetDefault 设置全局默认 Logger。 +func SetDefault(l Logger) { defaultLogger = l } diff --git a/miot/examples/config.json b/miot/examples/config.json deleted file mode 100644 index f3eee17..0000000 --- a/miot/examples/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "auth_info": { - "access_token": "V3_bmQhfIrkI-AxA2Tic4l7-t6bDURwgsZnwqTGLFGwWpk94q_8eUqkx0GONdP31teHuKekfTH9SzYnFQk1aShA6_t6peafUG11897cV8wPgPnUh1aww_PvLaRLagRGyNMjj01gqWp3_jf10D4wLctL8IPI8ZkGEA_n9HgKyxH-aMKuJf9BLbicmogQHBru2bVZ", - "refresh_token": "R3_dRDvElS486JEgbmmTOuhzLvGFMM89Chalyb9Q8p-S88EGwzC8SFdMfbNGTLrmggkJ-EPkJrNywQBWMpLGcve1MqknnhPA6S1df0R9V5xzoNC_sQoEYH4ltR_j3ymj9LSlaHJZSsC_bTfEKmY5nlQbmSTwdR0pIraQVS92IOwIUpmO4hiFOBc01RcndZNCwfpkmltHclDYj7rgUenzRMY2g", - "mac_key": "cLY53QhwiAtvlIGeZ6_Q_FgHyaQ", - "expires_in": 259200, - "expires_ts": 1782811831 - }, - "uid": "2897121707", - "cloud_server": "cn", - "uuid": "0684e27d49b7a234ff1a4d82f8fca234", - "redirect_url": "http://homeassistant.local:8123", - "language": "en", - "ctrl_mode": "auto", - "storage_path": "", - "nick_name": "Xiaomi" -} \ No newline at end of file diff --git a/miot/examples/full_client/main.go b/miot/examples/full_client/main.go index 90de2bb..f94bce1 100644 --- a/miot/examples/full_client/main.go +++ b/miot/examples/full_client/main.go @@ -89,7 +89,7 @@ func main() { // 10. Refresh device list fmt.Println("\n刷新设备列表...") - if err := client.RefreshDevices(); err != nil { + if err := client.RefreshDevices(nil); err != nil { fmt.Printf("RefreshDevices warning: %v\n", err) } else { devices := client.DeviceList() diff --git a/miot/miot_client.go b/miot/miot_client.go index 0b8144a..33f18e3 100644 --- a/miot/miot_client.go +++ b/miot/miot_client.go @@ -8,6 +8,8 @@ import ( "fmt" "sync" "time" + + "xiaomihome/logger" ) // ============================================================================ @@ -26,25 +28,26 @@ type MIoTClient struct { ctrlMode CtrlMode // === Network & Storage & Auth (aligned with Python) === - network interface{} // MIoTNetwork (optional, for network status monitoring) - storage *MIoTStorage - mipsSvc interface{} // MipsService (optional, for mDNS service discovery) - http *MIoTHttpClient - cert *MIoTCert - miotLan interface{} // MIoTLan (optional, for LAN control) + network interface{} // MIoTNetwork (optional, for network status monitoring) + storage *MIoTStorage + mipsSvc interface{} // MipsService (optional, for mDNS service discovery) + http *MIoTHttpClient + cert *MIoTCert + miotLan interface{} // MIoTLan (optional, for LAN control) // === OAuth client (aligned with Python miot_cloud.py MIoTOauthClient) === - oauth *MIoTOauthClient + oauth *MIoTOauthClient + homeIDs []string // 需要管理的家庭 ID 列表,nil/空=全部 // === Device list (aligned with Python _device_list_cache etc.) === // Only store metadata map[string]interface{}, not *MIoTDevice objects // Python: _device_list_cache: dict[str, dict] - deviceListCache map[string]map[string]interface{} - deviceListCloud map[string]map[string]interface{} - deviceListGateway map[string]map[string]interface{} - deviceListLan map[string]map[string]interface{} + deviceListCache map[string]map[string]interface{} + deviceListCloud map[string]map[string]interface{} + deviceListGateway map[string]map[string]interface{} + deviceListLan map[string]map[string]interface{} deviceListUpdateTs int64 - deviceListMu sync.RWMutex + deviceListMu sync.RWMutex // === Subscription related (aligned with Python _sub_source_list / _sub_tree / _sub_device_state) === subSourceList map[string]string // did -> source ("cloud"/"lan"/group_id) @@ -53,9 +56,9 @@ type MIoTClient struct { deviceStateSubs map[string]*deviceStateSub // did -> handler (H4) // === Property refresh (aligned with Python _refresh_props_list etc.) === - refreshPropsList map[string]map[string]interface{} // key="did|siid|piid" - refreshPropsTimer *time.Timer - refreshRetryCount int + refreshPropsList map[string]map[string]interface{} // key="did|siid|piid" + refreshPropsTimer *time.Timer + refreshRetryCount int // === i18n (aligned with Python MIoTI18n) === i18n *MIoTI18n @@ -65,10 +68,9 @@ type MIoTClient struct { devices map[string]*MIoTDevice // did → *MIoTDevice instance cache // === Timers (aligned with Python) === - refreshTokenTimer *time.Timer - refreshCertTimer *time.Timer - refreshCloudDevicesTimer *time.Timer - mipsLocalStateTimers map[string]*time.Timer + refreshCertTimer *time.Timer + refreshCloudDevicesTimer *time.Timer + mipsLocalStateTimers map[string]*time.Timer showDevicesChangedNotifyTimer *time.Timer // === MIPS clients (aligned with Python _mips_cloud / _mips_local) === @@ -79,13 +81,13 @@ type MIoTClient struct { persistenceNotify func(notifyID, title, message string) // === Config properties (M1: aligned with Python MIoTClient attributes) === - ActionDebug bool - HideNonStandardEntities bool - DisplayBinaryText bool - DisplayBinaryBool bool - CoverDeadZoneWidth int + ActionDebug bool + HideNonStandardEntities bool + DisplayBinaryText bool + DisplayBinaryBool bool + CoverDeadZoneWidth int DisplayDevicesChangedNotify []string - PersistentNotify func(string, string, string) // notifyID, title, message + PersistentNotify func(string, string, string) // notifyID, title, message // === Cloud API helpers === // These fields align with Python miot_cloud.py MIoTHttpClient methods @@ -94,13 +96,13 @@ type MIoTClient struct { // === Run control === // Go uses context.Context instead of Python's asyncio loop - ctx context.Context - cancel context.CancelFunc - mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex refreshMu sync.Mutex // token 刷新互斥锁 - // === Callbacks === - tokenRefreshCB func(accessToken, refreshToken string) + // === Logger === + lgr logger.Logger } // NewMIoTClient creates a new MIoTClient. @@ -108,28 +110,33 @@ type MIoTClient struct { func NewMIoTClient(entryID string, entryData map[string]interface{}, uid string, cloudServer string, ctrlMode CtrlMode) *MIoTClient { ctx, cancel := context.WithCancel(context.Background()) - return &MIoTClient{ - entryID: entryID, - entryData: entryData, - uid: uid, - cloudServer: cloudServer, - ctrlMode: ctrlMode, - deviceListCache: make(map[string]map[string]interface{}), - deviceListCloud: make(map[string]map[string]interface{}), - deviceListGateway: make(map[string]map[string]interface{}), - deviceListLan: make(map[string]map[string]interface{}), - subSourceList: make(map[string]string), - subTree: NewMIoTMatcher(), - subDeviceState: make(map[string]*MipsDeviceState), - deviceStateSubs: make(map[string]*deviceStateSub), - refreshPropsList: make(map[string]map[string]interface{}), - mipsLocal: make(map[string]*MipsLocalClient), - mipsLocalStateTimers: make(map[string]*time.Timer), - ctx: ctx, - cancel: cancel, + c := &MIoTClient{ + entryID: entryID, + entryData: entryData, + uid: uid, + cloudServer: cloudServer, + ctrlMode: ctrlMode, + deviceListCache: make(map[string]map[string]interface{}), + deviceListCloud: make(map[string]map[string]interface{}), + deviceListGateway: make(map[string]map[string]interface{}), + deviceListLan: make(map[string]map[string]interface{}), + subSourceList: make(map[string]string), + subTree: NewMIoTMatcher(), + subDeviceState: make(map[string]*MipsDeviceState), + deviceStateSubs: make(map[string]*deviceStateSub), + refreshPropsList: make(map[string]map[string]interface{}), + mipsLocal: make(map[string]*MipsLocalClient), + mipsLocalStateTimers: make(map[string]*time.Timer), + ctx: ctx, + cancel: cancel, + lgr: logger.Default(), } + return c } +// SetLogger sets a custom logger for MIoTClient. +func (c *MIoTClient) SetLogger(l logger.Logger) { c.lgr = l } + // Init initializes the MIoTClient. // Aligns with Python MIoTClient.init_async. func (c *MIoTClient) Init() error { @@ -149,7 +156,7 @@ func (c *MIoTClient) Init() error { if et, ok := authInfo["expires_ts"].(float64); ok { c.entryData["expires_ts"] = et } - fmt.Printf("[MIoTClient] Loaded token from storage\n") + c.lgr.Infof("[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 @@ -161,16 +168,18 @@ func (c *MIoTClient) Init() error { "refresh_token": refreshToken, "expires_ts": expiresTS, }, true) - fmt.Printf("[MIoTClient] Saved initial token to storage\n") + c.lgr.Infof("[MIoTClient] Saved initial token to storage\n") } } // Keep access_token in entryData for downstream use - c.entryData["access_token"] = accessToken + if c.entryData != nil { + 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) + c.lgr.Warnf("[MIoTClient] Warning: failed to load device cache: %v\n", err) } // 3.1 Initialize OAuth client @@ -178,27 +187,27 @@ func (c *MIoTClient) Init() error { oauthRedirectURL, _ := c.entryData["oauth_redirect_url"].(string) uuid, _ := c.entryData["uuid"].(string) c.oauth = NewMIoTOauthClient(OAUTH2_CLIENT_ID, oauthRedirectURL, c.cloudServer, uuid) - fmt.Printf("[MIoTClient] OAuth client initialized\n") + c.lgr.Infof("[MIoTClient] OAuth client initialized\n") } // 3.2 Initialize Cert client cert, err := NewMIoTCert(c.storage, c.uid, c.cloudServer) if err != nil { - fmt.Printf("[MIoTClient] Warning: cert init failed: %v\n", err) + c.lgr.Warnf("[MIoTClient] Warning: cert init failed: %v\n", err) } else { c.cert = cert c.cert.VerifyCACert() - fmt.Printf("[MIoTClient] Cert client initialized\n") + c.lgr.Infof("[MIoTClient] Cert client initialized\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) + c.lgr.Warnf("[MIoTClient] Warning: failed to create HTTP client: %v\n", err) } else { c.http = httpClient - fmt.Printf("[MIoTClient] HTTP client created\n") + c.lgr.Infof("[MIoTClient] HTTP client created\n") } } @@ -214,9 +223,9 @@ func (c *MIoTClient) Init() error { // Connect asynchronously to not block Init c.mu.Unlock() if err := mips.Connect(); err != nil { - fmt.Printf("[MIoTClient] Warning: MIPS cloud connect failed: %v\n", err) + c.lgr.Warnf("[MIoTClient] Warning: MIPS cloud connect failed: %v\n", err) } else { - fmt.Printf("[MIoTClient] MIPS cloud connected\n") + c.lgr.Infof("[MIoTClient] MIPS cloud connected\n") } c.mu.Lock() } @@ -230,7 +239,7 @@ func (c *MIoTClient) Init() error { nw.SubNetworkStatus(key, c.onNetworkStatusChanged) status := nw.GetNetworkStatus() c.onNetworkStatusChanged(status) - fmt.Printf("[MIoTClient] Network status subscribed (online=%v)\n", status) + c.lgr.Infof("[MIoTClient] Network status subscribed (online=%v)\n", status) } } @@ -249,12 +258,12 @@ func (c *MIoTClient) Init() error { groupID, _ := info["group_id"].(string) if groupID != "" { svc.SubServiceChange(key, groupID, c.onMipsServiceStateChange) - fmt.Printf("[MIoTClient] mDNS service subscribed: home=%s, group=%s\n", homeID, groupID) + c.lgr.Infof("[MIoTClient] mDNS service subscribed: home=%s, group=%s\n", homeID, groupID) // Check if service already discovered serviceData := svc.GetServices(groupID) if sd, exists := serviceData[groupID]; exists && sd.validService() { - fmt.Printf("[MIoTClient] Central mips service scanned: %s, %v\n", homeID, sd) + c.lgr.Infof("[MIoTClient] Central mips service scanned: %s, %v\n", homeID, sd) mips := NewMipsLocalClient( getVirtualDID(c.entryData), sd.Addresses[0], @@ -285,7 +294,7 @@ func (c *MIoTClient) Init() error { if lan.InitDone() { c.onMiotLanStateChange(true) } - fmt.Printf("[MIoTClient] LAN control initialized\n") + c.lgr.Infof("[MIoTClient] LAN control initialized\n") } } } else { @@ -315,9 +324,9 @@ func (c *MIoTClient) Init() error { } c.i18n = NewMIoTI18n(lang) c.i18n.Init() - fmt.Printf("[MIoTClient] i18n initialized (lang=%s)\n", lang) + c.lgr.Infof("[MIoTClient] i18n initialized (lang=%s)\n", lang) - fmt.Printf("[MIoTClient] Initialized\n") + c.lgr.Infof("[MIoTClient] Initialized\n") return nil } @@ -349,7 +358,7 @@ func (c *MIoTClient) Deinit() error { if c.mipsCloud != nil { c.mipsCloud.UnsubState(key) c.mipsCloud.Disconnect() - fmt.Printf("[MIoTClient] MIPS cloud disconnected\n") + c.lgr.Infof("[MIoTClient] MIPS cloud disconnected\n") } // 4. Cancel refresh cloud devices timer @@ -397,11 +406,7 @@ func (c *MIoTClient) Deinit() error { } } - // 6. Stop token/cert timers - if c.refreshTokenTimer != nil { - c.refreshTokenTimer.Stop() - c.refreshTokenTimer = nil - } + // 6. Stop cert timers if c.refreshCertTimer != nil { c.refreshCertTimer.Stop() c.refreshCertTimer = nil @@ -436,9 +441,9 @@ func (c *MIoTClient) Deinit() error { } deviceKey := fmt.Sprintf("%s_%s", c.uid, c.cloudServer) if err := c.storage.Save("miot_devices", deviceKey, deviceListForStorage); err != nil { - fmt.Printf("[MIoTClient] Warning: failed to save devices on deinit: %v\n", err) + c.lgr.Warnf("[MIoTClient] Warning: failed to save devices on deinit: %v\n", err) } - fmt.Printf("[MIoTClient] Device cache saved to storage\n") + c.lgr.Infof("[MIoTClient] Device cache saved to storage\n") } // Cancel context @@ -449,7 +454,7 @@ func (c *MIoTClient) Deinit() error { // Clear subscription tree c.subTree.Clear() - fmt.Printf("[MIoTClient] Deinitialized\n") + c.lgr.Infof("[MIoTClient] Deinitialized\n") return nil } @@ -488,7 +493,7 @@ func (c *MIoTClient) loadCacheDevice() error { c.deviceListCache[did] = infoMap } } - fmt.Printf("[MIoTClient] Loaded %d devices from cache\n", len(deviceList)) + c.lgr.Infof("[MIoTClient] Loaded %d devices from cache\n", len(deviceList)) return nil } @@ -578,122 +583,9 @@ func (c *MIoTClient) UnsubDeviceState(did string) error { return nil } -// ============================================================================ -// 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 -// ============================================================================ - -// RequestRefreshAuthInfo schedules an OAuth token refresh after delaySec seconds. -// Uses MIHOME_CERT_EXPIRE_MARGIN to determine when refresh is needed. -// Aligns with Python: __request_refresh_auth_info(delay_sec). -func (c *MIoTClient) RequestRefreshAuthInfo(delaySec int) { - if c.oauth == nil || c.storage == nil { - return - } - d := time.Duration(delaySec) * time.Second - c.mu.Lock() - if c.refreshTokenTimer != nil { - c.refreshTokenTimer.Stop() - } - c.refreshTokenTimer = time.AfterFunc(d, func() { - c.refreshAuthInfo() - }) - c.mu.Unlock() -} - -// 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 - } - - // Load current auth config - authInfo := c.storage.LoadUserConfig(c.uid, c.cloudServer, nil) - accessToken, _ := authInfo["access_token"].(string) - refreshToken, _ := authInfo["refresh_token"].(string) - expiresTS, _ := authInfo["expires_ts"].(float64) - - // Check if refresh is needed: expires_ts - now <= MIHOME_REFRESH_THRESHOLD - now := float64(time.Now().UnixMilli()) / 1000 - needRefresh := (expiresTS - now) <= float64(MIHOME_REFRESH_THRESHOLD) - - if !needRefresh && accessToken != "" { - // Schedule next check - nextDelay := int(expiresTS - now - float64(MIHOME_REFRESH_THRESHOLD)) - if nextDelay < 60 { - nextDelay = 60 - } - c.RequestRefreshAuthInfo(nextDelay) - fmt.Printf("[MIoTClient] Token still valid, recheck in %ds\n", nextDelay) - return - } - - if refreshToken == "" && accessToken == "" { - fmt.Printf("[MIoTClient] No refresh token available\n") - return - } - - // Refresh access token - result, err := c.oauth.RefreshAccessToken(refreshToken) - if err != nil { - fmt.Printf("[MIoTClient] Token refresh FAILED: %v\n", err) - // Retry after delay - c.RequestRefreshAuthInfo(RefreshAuthRetryDelay) - return - } - - newAccessToken, _ := result["access_token"].(string) - newRefreshToken, _ := result["refresh_token"].(string) - 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") - return - } - - // Update HTTP client - if c.http != nil { - c.http.UpdateHTTPHeader(c.cloudServer, OAUTH2_CLIENT_ID, newAccessToken) - } - - // Update MIPS cloud client - if c.mipsCloud != nil { - c.mipsCloud.UpdateAccessToken(newAccessToken) - } - - // Save new auth info - newAuthInfo := map[string]interface{}{ - "access_token": newAccessToken, - "refresh_token": newRefreshToken, - "expires_ts": newExpiresTS, - } - c.storage.UpdateUserConfig(c.uid, c.cloudServer, newAuthInfo, true) - - 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_REFRESH_THRESHOLD)) - if nextDelay < 60 { - nextDelay = 60 - } - c.RequestRefreshAuthInfo(nextDelay) +// SetHomeIDs sets the home IDs for device refresh scope. nil/empty = all homes. +func (c *MIoTClient) SetHomeIDs(homeIDs []string) { + c.homeIDs = homeIDs } // RefreshAuthInfo 手动触发 token 刷新并同步返回结果(对齐 Python refresh_oauth_info_async)。 @@ -755,7 +647,7 @@ func (c *MIoTClient) RefreshAuthInfo() (map[string]interface{}, error) { c.mipsCloud.UpdateAccessToken(ac) } - fmt.Printf("[MIoTClient] RefreshAuthInfo: token refreshed\n") + c.lgr.Infof("[MIoTClient] RefreshAuthInfo: token refreshed\n") return result, nil } @@ -786,7 +678,7 @@ func (c *MIoTClient) refreshUserCert() { // Verify CA cert if ok, err := c.cert.VerifyCACert(); err != nil || !ok { - fmt.Printf("[MIoTClient] CA cert verification FAILED: %v\n", err) + c.lgr.Errorf("[MIoTClient] CA cert verification FAILED: %v\n", err) } // Check remaining time @@ -798,14 +690,14 @@ func (c *MIoTClient) refreshUserCert() { nextDelay = 60 } c.RequestRefreshUserCert(nextDelay) - fmt.Printf("[MIoTClient] Cert still valid for %ds, recheck in %ds\n", remainSec, nextDelay) + c.lgr.Infof("[MIoTClient] Cert still valid for %ds, recheck in %ds\n", remainSec, nextDelay) return } // Load user key _, err := c.cert.LoadUserKey() if err != nil { - fmt.Printf("[MIoTClient] Cert refresh: load key FAILED: %v\n", err) + c.lgr.Errorf("[MIoTClient] Cert refresh: load key FAILED: %v\n", err) // Retry after delay c.RequestRefreshUserCert(3600) return @@ -814,7 +706,7 @@ func (c *MIoTClient) refreshUserCert() { // Generate CSR csr, err := c.cert.GenUserCSR() if err != nil { - fmt.Printf("[MIoTClient] Cert refresh: gen CSR FAILED: %v\n", err) + c.lgr.Errorf("[MIoTClient] Cert refresh: gen CSR FAILED: %v\n", err) c.RequestRefreshUserCert(3600) return } @@ -822,18 +714,18 @@ func (c *MIoTClient) refreshUserCert() { // Request cert from cloud certPEM, err := c.http.GetCentralCert(csr) if err != nil { - fmt.Printf("[MIoTClient] Cert refresh: get cert from cloud FAILED: %v\n", err) + c.lgr.Errorf("[MIoTClient] Cert refresh: get cert from cloud FAILED: %v\n", err) c.RequestRefreshUserCert(RefreshCertRetryDelay) return } // Save cert if err := c.cert.SaveUserCert(certPEM); err != nil { - fmt.Printf("[MIoTClient] Cert refresh: save cert FAILED: %v\n", err) + c.lgr.Errorf("[MIoTClient] Cert refresh: save cert FAILED: %v\n", err) return } - fmt.Printf("[MIoTClient] Cert refreshed successfully\n") + c.lgr.Infof("[MIoTClient] Cert refreshed successfully\n") // Update MIPS local clients with new certs (if any are still connected) for _, mips := range c.mipsLocal { @@ -891,13 +783,13 @@ func (c *MIoTClient) ShowCentralStateChangedNotify(connected bool) { // Aligns with Python: __on_mips_cloud_state_changed. func (c *MIoTClient) onMipsCloudStateChanged(key string, connected bool) { if connected { - fmt.Printf("[MIoTClient] MIPS cloud connected\n") + c.lgr.Infof("[MIoTClient] MIPS cloud connected\n") // Refresh devices on reconnect c.ScheduleRefreshDevices(RefreshCloudDevicesDelay * time.Millisecond) return } - fmt.Printf("[MIoTClient] MIPS cloud disconnected\n") + c.lgr.Infof("[MIoTClient] MIPS cloud disconnected\n") // Disconnect: mark all cloud devices offline c.deviceListMu.Lock() @@ -929,7 +821,7 @@ func (c *MIoTClient) onMipsCloudStateChanged(key string, connected bool) { // onNetworkStatusChanged handles network connectivity changes. // Aligns with Python: __on_network_status_changed. func (c *MIoTClient) onNetworkStatusChanged(status bool) { - fmt.Printf("[MIoTClient] Network status changed: online=%v\n", status) + c.lgr.Infof("[MIoTClient] Network status changed: online=%v\n", status) if status { // Network is back; trigger device refresh and reconnect MIPS if c.mipsCloud != nil && !c.mipsCloud.IsConnected() { @@ -942,15 +834,15 @@ func (c *MIoTClient) onNetworkStatusChanged(status bool) { // onMipsServiceStateChange handles mDNS service discoveries for central gateways. // Aligns with Python: __on_mips_service_state_change. func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceState, data map[string]interface{}) { - fmt.Printf("[MIoTClient] MIPS service change: group=%s, state=%s\n", groupID, state) + c.lgr.Infof("[MIoTClient] MIPS service change: group=%s, state=%s\n", groupID, state) virtualDID := getVirtualDID(c.entryData) if virtualDID == "" { - fmt.Printf("[MIoTClient] MIPS service: missing virtual_did, skip\n") + c.lgr.Infof("[MIoTClient] MIPS service: missing virtual_did, skip\n") return } if c.cert == nil { - fmt.Printf("[MIoTClient] MIPS service: cert not initialized, skip\n") + c.lgr.Infof("[MIoTClient] MIPS service: cert not initialized, skip\n") return } @@ -959,7 +851,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS // Create new MipsLocalClient host, _ := data["addresses"].([]interface{}) if len(host) == 0 { - fmt.Printf("[MIoTClient] MIPS service: no addresses for %s\n", groupID) + c.lgr.Infof("[MIoTClient] MIPS service: no addresses for %s\n", groupID) return } hostStr, _ := host[0].(string) @@ -980,7 +872,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS mips.SubState(groupID, c.onMipsLocalStateChanged) mips.Connect() c.mipsLocal[groupID] = mips - fmt.Printf("[MIoTClient] MIPS local client created: %s @ %s:%d\n", groupID, hostStr, int(port)) + c.lgr.Infof("[MIoTClient] MIPS local client created: %s @ %s:%d\n", groupID, hostStr, int(port)) case MipsServiceUpdated: // Disconnect old, connect new @@ -1010,7 +902,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS mips.SubState(groupID, c.onMipsLocalStateChanged) mips.Connect() c.mipsLocal[groupID] = mips - fmt.Printf("[MIoTClient] MIPS local client updated: %s @ %s:%d\n", groupID, hostStr, int(port)) + c.lgr.Infof("[MIoTClient] MIPS local client updated: %s @ %s:%d\n", groupID, hostStr, int(port)) case MipsServiceRemoved: if mips, exists := c.mipsLocal[groupID]; exists { @@ -1018,7 +910,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS mips.UnsubState(groupID) mips.Disconnect() delete(c.mipsLocal, groupID) - fmt.Printf("[MIoTClient] MIPS local client removed: %s\n", groupID) + c.lgr.Infof("[MIoTClient] MIPS local client removed: %s\n", groupID) } } } @@ -1027,7 +919,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS // Aligns with Python: __on_mips_local_state_changed. // key is the groupID of the gateway. func (c *MIoTClient) onMipsLocalStateChanged(key string, connected bool) { - fmt.Printf("[MIoTClient] MIPS local state changed: group=%s, connected=%v\n", key, connected) + c.lgr.Infof("[MIoTClient] MIPS local state changed: group=%s, connected=%v\n", key, connected) if connected { // Reconnect: pull gateway device list @@ -1074,7 +966,7 @@ func (c *MIoTClient) onMipsLocalStateChanged(key string, connected bool) { // onMiotLanStateChange handles LAN controller state transitions. // Aligns with Python: __on_miot_lan_state_change. func (c *MIoTClient) onMiotLanStateChange(initialized bool) { - fmt.Printf("[MIoTClient] LAN state changed: initialized=%v\n", initialized) + c.lgr.Infof("[MIoTClient] LAN state changed: initialized=%v\n", initialized) if initialized { c.deviceListMu.Lock() lan, ok := c.miotLan.(*MIoTLan) @@ -1134,7 +1026,7 @@ func (c *MIoTClient) onLanDeviceStateChanged(did string, state map[string]interf // Aligns with Python: __on_gw_device_list_changed. // Called when a MipsLocalClient reports that its device list has changed. func (c *MIoTClient) onGWDeviceListChanged() { - fmt.Printf("[MIoTClient] GW device list changed\n") + c.lgr.Infof("[MIoTClient] GW device list changed\n") for groupID, mips := range c.mipsLocal { if mips == nil || !mips.IsConnected() { @@ -1144,7 +1036,7 @@ func (c *MIoTClient) onGWDeviceListChanged() { // Pull device list from gateway payload, err := mips.GetDevList("", MipsRequestTimeoutDefault*time.Millisecond) if err != nil { - fmt.Printf("[MIoTClient] GW device list pull failed for %s: %v\n", groupID, err) + c.lgr.Errorf("[MIoTClient] GW device list pull failed for %s: %v\n", groupID, err) continue } @@ -1153,7 +1045,7 @@ func (c *MIoTClient) onGWDeviceListChanged() { Result map[string]interface{} `json:"result"` } if err := json.Unmarshal([]byte(payload), &result); err != nil { - fmt.Printf("[MIoTClient] GW device list parse failed for %s: %v\n", groupID, err) + c.lgr.Errorf("[MIoTClient] GW device list parse failed for %s: %v\n", groupID, err) continue } @@ -1198,7 +1090,7 @@ func (c *MIoTClient) updateDevicesFromGW(groupID string, devices map[string]inte } if changed { - fmt.Printf("[MIoTClient] GW devices updated: group=%s, count=%d\n", groupID, len(devices)) + c.lgr.Infof("[MIoTClient] GW devices updated: group=%s, count=%d\n", groupID, len(devices)) } } diff --git a/miot/miot_client_api.go b/miot/miot_client_api.go index d43d81a..ff6f16d 100644 --- a/miot/miot_client_api.go +++ b/miot/miot_client_api.go @@ -31,11 +31,12 @@ func (c *MIoTClient) SetProp(did string, siid, piid int, value interface{}) erro return err } - if len(results) > 0 && results[0].Code != 0 { + // code=0 或 code=1 均视为成功(0=读取成功,1=设置成功) + if len(results) > 0 && results[0].Code != 0 && results[0].Code != 1 { return fmt.Errorf("set property failed, code=%d", results[0].Code) } - fmt.Printf("[MIoTClient] SetProp: %s/%d/%d = %v\n", did, siid, piid, value) + c.lgr.Infof("[MIoTClient] SetProp: %s/%d/%d = %v\n", did, siid, piid, value) return nil } @@ -67,7 +68,7 @@ func (c *MIoTClient) GetProp(did string, siid, piid int) (interface{}, error) { return nil, fmt.Errorf("get property failed, code=%d", results[0].Code) } - fmt.Printf("[MIoTClient] GetProp: %s/%d/%d = %v\n", did, siid, piid, results[0].Value) + c.lgr.Infof("[MIoTClient] GetProp: %s/%d/%d = %v\n", did, siid, piid, results[0].Value) return results[0].Value, nil } @@ -88,7 +89,7 @@ func (c *MIoTClient) Action(did string, siid, aiid int, inList []interface{}) (m return nil, err } - fmt.Printf("[MIoTClient] Action: %s/%d/%d, result=%v\n", did, siid, aiid, result) + c.lgr.Infof("[MIoTClient] Action: %s/%d/%d, result=%v\n", did, siid, aiid, result) return result, nil } diff --git a/miot/miot_client_device.go b/miot/miot_client_device.go index bed66c3..59d1a08 100644 --- a/miot/miot_client_device.go +++ b/miot/miot_client_device.go @@ -38,7 +38,7 @@ func (c *MIoTClient) getOrCreateDevice(did string, info map[string]interface{}) spec, err := c.specParser.Parse(urn, model) if err != nil { - fmt.Printf("[MIoTClient] getOrCreateDevice: SPEC parse failed for %s (%s/%s): %v\n", did, urn, model, err) + c.lgr.Errorf("[MIoTClient] getOrCreateDevice: SPEC parse failed for %s (%s/%s): %v\n", did, urn, model, err) return nil } @@ -60,15 +60,15 @@ func (c *MIoTClient) getOrCreateDevice(did string, info map[string]interface{}) // Device management (aligned with Python miot_client.py) // ============================================================================ -// RefreshDevices refreshes device list from cloud. +// RefreshDevices refreshes device list from cloud for the given home IDs (nil=all). // Aligns with Python: __update_devices_from_cloud_async (line 1384). -func (c *MIoTClient) RefreshDevices() error { +func (c *MIoTClient) RefreshDevices(homeIDs []string) error { if c.http == nil { return fmt.Errorf("HTTP client not available") } // 1. Get full device list from cloud - result, err := c.http.GetDevices(nil) + result, err := c.http.GetDevices(homeIDs) if err != nil { return fmt.Errorf("failed to get devices: %v", err) } @@ -143,7 +143,12 @@ func (c *MIoTClient) RefreshDevices() error { } } - // 3. Diff: compare cloudDevices with current deviceListCloud + // 3. Merge shared devices into cloud devices (python treats them the same) + for did, info := range sharedDevices { + cloudDevices[did] = info + } + + // 4. Diff: compare cloudDevices with current deviceListCloud changed := false // Detect additions and updates @@ -160,7 +165,7 @@ func (c *MIoTClient) RefreshDevices() error { } else { c.deviceListCache[did] = cloneMap(info) } - fmt.Printf("[MIoTClient] Device added: %s (%v)\n", did, info["name"]) + c.lgr.Infof("[MIoTClient] Device added: %s (%v)\n", did, info["name"]) changed = true } else { // Check if online status changed @@ -172,7 +177,7 @@ func (c *MIoTClient) RefreshDevices() error { if cacheInfo, ok := c.deviceListCache[did]; ok { cacheInfo["online"] = newOnline } - fmt.Printf("[MIoTClient] Device online changed: %s online=%v\n", did, newOnline) + c.lgr.Infof("[MIoTClient] Device online changed: %s online=%v\n", did, newOnline) changed = true } } @@ -186,7 +191,7 @@ func (c *MIoTClient) RefreshDevices() error { cacheInfo["online"] = false cacheInfo["push_available"] = false } - fmt.Printf("[MIoTClient] Device removed from cloud: %s\n", did) + c.lgr.Infof("[MIoTClient] Device removed from cloud: %s\n", did) changed = true } } @@ -209,7 +214,7 @@ func (c *MIoTClient) RefreshDevices() error { } deviceKey := fmt.Sprintf("%s_%s", c.uid, c.cloudServer) if err := c.storage.Save("miot_devices", deviceKey, deviceListForStorage); err != nil { - fmt.Printf("[MIoTClient] Warning: failed to save devices: %v\n", err) + c.lgr.Warnf("[MIoTClient] Warning: failed to save devices: %v\n", err) } } @@ -218,7 +223,7 @@ func (c *MIoTClient) RefreshDevices() error { c.ShowDevicesChangedNotify() } - fmt.Printf("[MIoTClient] Devices refreshed: cloud=%d, cache=%d\n", len(cloudDevices), len(c.deviceListCache)) + c.lgr.Infof("[MIoTClient] Devices refreshed: cloud=%d, cache=%d\n", len(cloudDevices), len(c.deviceListCache)) return nil } @@ -303,11 +308,11 @@ func (c *MIoTClient) aggregateDeviceState(did string) { func (c *MIoTClient) LoadDevices() error { // Try to load from cache first if err := c.loadCacheDevice(); err != nil { - fmt.Printf("[MIoTClient] Failed to load from cache: %v\n", err) + c.lgr.Errorf("[MIoTClient] Failed to load from cache: %v\n", err) } // Then refresh from cloud - return c.RefreshDevices() + return c.RefreshDevices(c.homeIDs) } // GetDeviceInfo returns device metadata for a given DID. @@ -351,15 +356,15 @@ func (c *MIoTClient) ScheduleRefreshDevices(delay time.Duration) { } c.refreshCloudDevicesTimer = time.AfterFunc(delay, func() { - fmt.Printf("[MIoTClient] Refreshing devices...\n") - if err := c.RefreshDevices(); err != nil { - fmt.Printf("[MIoTClient] Refresh failed, will retry: %v\n", err) + c.lgr.Infof("[MIoTClient] Refreshing devices...\n") + if err := c.RefreshDevices(c.homeIDs); err != nil { + c.lgr.Errorf("[MIoTClient] Refresh failed, will retry: %v\n", err) // Retry after 60s c.ScheduleRefreshDevices(RefreshCloudDevicesRetryDelay * time.Millisecond) } }) - fmt.Printf("[MIoTClient] Device refresh scheduled in %v\n", delay) + c.lgr.Infof("[MIoTClient] Device refresh scheduled in %v\n", delay) } // Start starts the MIoTClient and schedules initial device load. @@ -373,7 +378,7 @@ func (c *MIoTClient) Start() error { // Schedule initial device load (delayed, aligns with REFRESH_CLOUD_DEVICES_DELAY=6 in Python) c.ScheduleRefreshDevices(RefreshCloudDevicesDelay * time.Millisecond) - fmt.Printf("[MIoTClient] Started\n") + c.lgr.Infof("[MIoTClient] Started\n") return nil } diff --git a/miot/miot_client_device_test.go b/miot/miot_client_device_test.go index 9938648..4e23171 100644 --- a/miot/miot_client_device_test.go +++ b/miot/miot_client_device_test.go @@ -76,7 +76,7 @@ func TestRefreshDevices_NoHTTP(t *testing.T) { c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) defer c.Stop() - err := c.RefreshDevices() + err := c.RefreshDevices(nil) if err == nil { t.Error("RefreshDevices without HTTP client should error") } diff --git a/miot/miot_client_prop.go b/miot/miot_client_prop.go index 9aadb2d..e11516d 100644 --- a/miot/miot_client_prop.go +++ b/miot/miot_client_prop.go @@ -41,7 +41,7 @@ func (c *MIoTClient) RefreshDeviceAllProps(did string) error { } } - fmt.Printf("[MIoTClient] RefreshDeviceAllProps: %s → %d props enqueued\n", did, count) + c.lgr.Infof("[MIoTClient] RefreshDeviceAllProps: %s → %d props enqueued\n", did, count) return nil } @@ -59,7 +59,7 @@ func (c *MIoTClient) RequestRefreshProp(did string, siid, piid int) { // Check if already in refresh list if _, exists := c.refreshPropsList[key]; exists { - fmt.Printf("[MIoTClient] RequestRefreshProp: SKIPPED (already queued) %s\n", key) + c.lgr.Infof("[MIoTClient] RequestRefreshProp: SKIPPED (already queued) %s\n", key) return } @@ -71,7 +71,7 @@ func (c *MIoTClient) RequestRefreshProp(did string, siid, piid int) { // Schedule refresh if not already scheduled if c.refreshPropsTimer == nil { - fmt.Printf("[MIoTClient] RequestRefreshProp: Scheduling refresh timer for %s\n", key) + c.lgr.Infof("[MIoTClient] RequestRefreshProp: Scheduling refresh timer for %s\n", key) // Reset retry count for new batch c.refreshRetryCount = 0 @@ -80,14 +80,14 @@ func (c *MIoTClient) RequestRefreshProp(did string, siid, piid int) { c.refreshPropsTimer = nil c.mu.Unlock() - fmt.Printf("[MIoTClient] RequestRefreshProp: Timer fired, calling refreshPropsHandler()\n") + c.lgr.Infof("[MIoTClient] RequestRefreshProp: Timer fired, calling refreshPropsHandler()\n") c.refreshPropsHandler() }) } else { - fmt.Printf("[MIoTClient] RequestRefreshProp: Timer already scheduled, added to queue: %s\n", key) + c.lgr.Infof("[MIoTClient] RequestRefreshProp: Timer already scheduled, added to queue: %s\n", key) } - fmt.Printf("[MIoTClient] RequestRefreshProp: QUEUED %s (queue size: %d)\n", key, len(c.refreshPropsList)) + c.lgr.Infof("[MIoTClient] RequestRefreshProp: QUEUED %s (queue size: %d)\n", key, len(c.refreshPropsList)) } // refreshPropsHandler handles property refresh from cloud. @@ -107,7 +107,7 @@ func (c *MIoTClient) refreshPropsHandler() { c.refreshPropsList = make(map[string]map[string]interface{}) c.mu.Unlock() - fmt.Printf("[MIoTClient] refreshPropsHandler: START, %d props to refresh\n", len(propsToRefresh)) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: START, %d props to refresh\n", len(propsToRefresh)) // Group props by source (cloud / gateway / lan) cloudParams := make([]PropParam, 0) @@ -123,6 +123,38 @@ func (c *MIoTClient) refreshPropsHandler() { siid, _ := prop["siid"].(int) piid, _ := prop["piid"].(int) + // siid=0 && piid=0 → 通配符,展开为设备所有可读属性 + if siid == 0 && piid == 0 { + if info, ok := c.deviceListCache[did]; ok { + dev := c.getOrCreateDevice(did, info) + if dev != nil { + for _, svc := range dev.ServiceList() { + if svc == nil { + continue + } + for _, p := range svc.Properties { + if p == nil || !p.Readable() { + continue + } + param := PropParam{DID: did, Siid: p.SIID, Piid: p.PIID} + source := c.subSourceList[did] + if source == "" { + source = "cloud" + } + if source == "cloud" { + cloudParams = append(cloudParams, param) + } else if source == "lan" { + lanParams = append(lanParams, param) + } else { + gatewayParams[source] = append(gatewayParams[source], param) + } + } + } + } + } + continue + } + param := PropParam{DID: did, Siid: siid, Piid: piid} source, exists := c.subSourceList[did] @@ -155,12 +187,12 @@ func (c *MIoTClient) refreshPropsHandler() { batch := cloudParams[i:end] results, err := c.http.GetProps(batch) if err != nil { - fmt.Printf("[MIoTClient] refreshPropsHandler: cloud GetProps FAILED: %v\n", err) + c.lgr.Errorf("[MIoTClient] refreshPropsHandler: cloud GetProps FAILED: %v\n", err) c.retryProps(propsToRefresh) return } allResults = append(allResults, results...) - fmt.Printf("[MIoTClient] refreshPropsHandler: cloud batch %d returned %d results\n", i/150+1, len(results)) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: cloud batch %d returned %d results\n", i/150+1, len(results)) } } @@ -168,7 +200,7 @@ func (c *MIoTClient) refreshPropsHandler() { for groupID, params := range gatewayParams { mips, exists := c.mipsLocal[groupID] if !exists { - fmt.Printf("[MIoTClient] refreshPropsHandler: gateway %s not found, routing to cloud\n", groupID) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: gateway %s not found, routing to cloud\n", groupID) // Fallback to cloud if c.http != nil { results, err := c.http.GetProps(params) @@ -180,7 +212,7 @@ func (c *MIoTClient) refreshPropsHandler() { } results, err := mips.GetProps(params, MipsRequestTimeoutDefault*time.Millisecond) if err != nil { - fmt.Printf("[MIoTClient] refreshPropsHandler: gateway %s GetProps FAILED: %v\n", groupID, err) + c.lgr.Errorf("[MIoTClient] refreshPropsHandler: gateway %s GetProps FAILED: %v\n", groupID, err) // Fallback to cloud if c.http != nil { results, err := c.http.GetProps(params) @@ -191,7 +223,7 @@ func (c *MIoTClient) refreshPropsHandler() { continue } allResults = append(allResults, results...) - fmt.Printf("[MIoTClient] refreshPropsHandler: gateway %s returned %d results\n", groupID, len(results)) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: gateway %s returned %d results\n", groupID, len(results)) } // LAN source @@ -200,7 +232,7 @@ func (c *MIoTClient) refreshPropsHandler() { if ok { results, err := lan.GetProps(lanParams) if err != nil { - fmt.Printf("[MIoTClient] refreshPropsHandler: LAN GetProps FAILED: %v\n", err) + c.lgr.Errorf("[MIoTClient] refreshPropsHandler: LAN GetProps FAILED: %v\n", err) // Fallback to cloud if c.http != nil { results, err := c.http.GetProps(lanParams) @@ -210,7 +242,7 @@ func (c *MIoTClient) refreshPropsHandler() { } } else { allResults = append(allResults, results...) - fmt.Printf("[MIoTClient] refreshPropsHandler: LAN returned %d results\n", len(results)) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: LAN returned %d results\n", len(results)) } } } @@ -220,16 +252,16 @@ func (c *MIoTClient) refreshPropsHandler() { c.refreshRetryCount = 0 c.mu.Unlock() - fmt.Printf("[MIoTClient] refreshPropsHandler: total %d results\n", len(allResults)) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: total %d results\n", len(allResults)) // Dispatch results to subscribers for _, result := range allResults { if result.Code != 0 { - fmt.Printf("[MIoTClient] refreshPropsHandler: FAILED %s/%d/%d, code=%d\n", result.DID, result.Siid, result.Piid, result.Code) + c.lgr.Errorf("[MIoTClient] refreshPropsHandler: FAILED %s/%d/%d, code=%d\n", result.DID, result.Siid, result.Piid, result.Code) continue } - fmt.Printf("[MIoTClient] refreshPropsHandler: SUCCESS %s/%d/%d = %v\n", result.DID, result.Siid, result.Piid, result.Value) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: SUCCESS %s/%d/%d = %v\n", result.DID, result.Siid, result.Piid, result.Value) params := map[string]interface{}{ "did": result.DID, "siid": result.Siid, @@ -248,7 +280,7 @@ func (c *MIoTClient) retryProps(props []map[string]interface{}) { c.mu.Unlock() if retryCount >= RefreshPropsRetryMax { - fmt.Printf("[MIoTClient] refreshPropsHandler: GIVING UP after %d retries\n", retryCount) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: GIVING UP after %d retries\n", retryCount) c.mu.Lock() c.refreshPropsList = make(map[string]map[string]interface{}) c.refreshRetryCount = 0 @@ -256,7 +288,7 @@ func (c *MIoTClient) retryProps(props []map[string]interface{}) { return } - fmt.Printf("[MIoTClient] refreshPropsHandler: RETRYING (%d/%d)\n", retryCount, RefreshPropsRetryMax) + c.lgr.Infof("[MIoTClient] refreshPropsHandler: RETRYING (%d/%d)\n", retryCount, RefreshPropsRetryMax) c.mu.Lock() for _, prop := range props { key := fmt.Sprintf("%s|%d|%d", prop["did"].(string), prop["siid"].(int), prop["piid"].(int)) diff --git a/miot/miot_client_sub.go b/miot/miot_client_sub.go index 91b073a..fdf14ee 100644 --- a/miot/miot_client_sub.go +++ b/miot/miot_client_sub.go @@ -25,12 +25,16 @@ func (c *MIoTClient) SubProp(did string, handler func(map[string]interface{}, in subID := fmt.Sprintf("%p", handler) c.subTree.Sub(topic, handler, nil) - fmt.Printf("[MIoTClient] Subscribed to property: %s (topic=%s, subID=%s)\n", did, topic, subID) + c.lgr.Infof("[MIoTClient] Subscribed to property: %s (topic=%s, subID=%s)\n", did, topic, subID) c.mu.Unlock() - // RequestRefreshProp must be outside the lock (it also acquires c.mu) - c.RequestRefreshProp(did, siid, piid) + // siid=0 && piid=0 为通配符订阅,展开为设备所有可读属性逐条刷新 + if siid == 0 && piid == 0 { + c.RefreshDeviceAllProps(did) + } else { + c.RequestRefreshProp(did, siid, piid) + } return subID } @@ -48,7 +52,7 @@ func (c *MIoTClient) UnsubProp(did string, subID string) { // TODO: Implement proper tracking of per-device subscription count // For now, just log - fmt.Printf("[MIoTClient] Unsubscribed from property: %s (subID=%s)\n", did, subID) + c.lgr.Infof("[MIoTClient] Unsubscribed from property: %s (subID=%s)\n", did, subID) } // SubEvent subscribes to device events. @@ -65,7 +69,7 @@ func (c *MIoTClient) SubEvent(did string, handler func(map[string]interface{}, i subID := fmt.Sprintf("%p", handler) c.subTree.Sub(topic, handler, nil) - fmt.Printf("[MIoTClient] Subscribed to event: %s (topic=%s, subID=%s)\n", did, topic, subID) + c.lgr.Infof("[MIoTClient] Subscribed to event: %s (topic=%s, subID=%s)\n", did, topic, subID) return subID } @@ -79,7 +83,7 @@ func (c *MIoTClient) UnsubEvent(did string, subID string) { // Remove from subTree by subID c.subTree.Unsub(subID) - fmt.Printf("[MIoTClient] Unsubscribed from event: %s (subID=%s)\n", did, subID) + c.lgr.Infof("[MIoTClient] Unsubscribed from event: %s (subID=%s)\n", did, subID) } // updateDeviceMsgSub updates device message subscription. @@ -151,7 +155,7 @@ func (c *MIoTClient) updateDeviceMsgSub(did string) { // Update subscription source c.subSourceList[did] = newSource - fmt.Printf("[MIoTClient] updateDeviceMsgSub: %s source %s → %s (gw=%v/%v, lan=%v/%v, cloud=%v)\n", + c.lgr.Infof("[MIoTClient] updateDeviceMsgSub: %s source %s → %s (gw=%v/%v, lan=%v/%v, cloud=%v)\n", did, oldSource, newSource, gwOnline, gwPushAvailable, lanOnline, lanPushAvailable, cloudOnline) } @@ -256,13 +260,13 @@ func (c *MIoTClient) handleMIPSMessage(did string, payload []byte) { // Fallback: binary MIPS protocol (used by gateway mipsLocal) msg := UnpackMipsMessage(payload) if msg == nil || msg.Payload == "" { - fmt.Printf("[MIoTClient] handleMIPSMessage: empty or invalid payload for %s (%d bytes)\n", did, len(payload)) + c.lgr.Infof("[MIoTClient] handleMIPSMessage: empty or invalid payload for %s (%d bytes)\n", did, len(payload)) return } // Parse JSON from unpacked payload if err := json.Unmarshal([]byte(msg.Payload), ¶ms); err != nil { - fmt.Printf("[MIoTClient] handleMIPSMessage: JSON parse error for %s: %v\n", did, err) + c.lgr.Errorf("[MIoTClient] handleMIPSMessage: JSON parse error for %s: %v\n", did, err) return } @@ -285,7 +289,7 @@ func (c *MIoTClient) dispatchMIPSMessage(did string, params map[string]interface } else if _, ok := params["eiid"]; ok { c.OnEventMsg(params, nil) } else { - fmt.Printf("[MIoTClient] handleMIPSMessage: unknown message format for %s: %v\n", did, params) + c.lgr.Infof("[MIoTClient] handleMIPSMessage: unknown message format for %s: %v\n", did, params) } } @@ -297,11 +301,11 @@ func (c *MIoTClient) handleMIPSStateMessage(did string, payload []byte) { // Fallback: binary MIPS protocol msg := UnpackMipsMessage(payload) if msg == nil || msg.Payload == "" { - fmt.Printf("[MIoTClient] handleMIPSStateMessage: empty payload for %s (%d bytes)\n", did, len(payload)) + c.lgr.Infof("[MIoTClient] handleMIPSStateMessage: empty payload for %s (%d bytes)\n", did, len(payload)) return } if err := json.Unmarshal([]byte(msg.Payload), ¶ms); err != nil { - fmt.Printf("[MIoTClient] handleMIPSStateMessage: JSON parse error for %s: %v\n", did, err) + c.lgr.Errorf("[MIoTClient] handleMIPSStateMessage: JSON parse error for %s: %v\n", did, err) return } } @@ -322,7 +326,7 @@ func (c *MIoTClient) handleMIPSStateMessage(did string, payload []byte) { } if len(state) == 0 { - fmt.Printf("[MIoTClient] handleMIPSStateMessage: no state info in payload for %s: %v\n", did, params) + c.lgr.Infof("[MIoTClient] handleMIPSStateMessage: no state info in payload for %s: %v\n", did, params) return } @@ -368,7 +372,7 @@ func (c *MIoTClient) OnPropMsg(params map[string]interface{}, ctx interface{}) { } if did == "" { - fmt.Printf("[MIoTClient] OnPropMsg: missing did in params=%v\n", params) + c.lgr.Infof("[MIoTClient] OnPropMsg: missing did in params=%v\n", params) return } @@ -420,7 +424,7 @@ func (c *MIoTClient) OnEventMsg(params map[string]interface{}, ctx interface{}) } if did == "" { - fmt.Printf("[MIoTClient] OnEventMsg: missing did in params=%v\n", params) + c.lgr.Infof("[MIoTClient] OnEventMsg: missing did in params=%v\n", params) return } diff --git a/miot/miot_client_test.go b/miot/miot_client_test.go index 84f42e2..ea6a20b 100644 --- a/miot/miot_client_test.go +++ b/miot/miot_client_test.go @@ -4,7 +4,6 @@ package miot import ( "testing" - "time" ) // ============================================================================ @@ -178,22 +177,6 @@ func TestDeinit_ClearsSubTree(t *testing.T) { } } -func TestDeinit_StopsTimers(t *testing.T) { - c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) - - // Simulate a timer being set - c.refreshTokenTimer = time.NewTimer(time.Hour) - c.refreshCloudDevicesTimer = time.NewTimer(time.Hour) - - c.Deinit() - - // Timers should be stopped (check by trying to stop again) - if c.refreshTokenTimer != nil { - // Already stopped by Deinit, Stop() returns false - _ = c.refreshTokenTimer - } -} - // ============================================================================ // Start / Stop // ============================================================================ diff --git a/miot/miot_device.go b/miot/miot_device.go index 3a4fb8d..5cd21d6 100644 --- a/miot/miot_device.go +++ b/miot/miot_device.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "sync" + + "xiaomihome/logger" ) // ============================================================================ @@ -54,6 +56,9 @@ type MIoTDevice struct { valueSubList map[string]map[string]func(map[string]interface{}, interface{}) subID int subIDMu sync.Mutex + + // === Logger === + lgr logger.Logger } // NewMIoTDevice creates a new MIoTDevice. @@ -68,6 +73,11 @@ func NewMIoTDevice(miotClient *MIoTClient, deviceInfo map[string]interface{}, sp deviceStateSubList: make(map[string]map[string]func(string, interface{})), valueSubList: make(map[string]map[string]func(map[string]interface{}, interface{})), subDevices: make(map[int]*MIoTDevice), + lgr: logger.Default(), + } + + if specInstance != nil { + dev.serviceList = specInstance.GetServices() } // Parse device metadata from deviceInfo @@ -130,6 +140,9 @@ func NewMIoTDevice(miotClient *MIoTClient, deviceInfo map[string]interface{}, sp return dev } +// SetLogger sets a custom logger for MIoTDevice. +func (d *MIoTDevice) SetLogger(l logger.Logger) { d.lgr = l } + // LoadFromCloud creates a MIoTDevice from cloud device data. // Aligns with Python: populating device_info from cloud response. func LoadFromCloud(miotClient *MIoTClient, cloudData map[string]interface{}) *MIoTDevice { @@ -193,7 +206,7 @@ func (d *MIoTDevice) SetSpecInstance(specInstance *MIoTSpecInstance) { // Returns: subscription ID for unsubscribing. func (d *MIoTDevice) SubProp(siid, piid int, handler func(map[string]interface{}, interface{})) string { if d.miotClient == nil { - fmt.Printf("[MIoTDevice] Warning: miotClient is nil, cannot subscribe to property\n") + d.lgr.Warnf("[MIoTDevice] Warning: miotClient is nil, cannot subscribe to property\n") return "" } @@ -216,7 +229,7 @@ func (d *MIoTDevice) SubProp(siid, piid int, handler func(map[string]interface{} // Aligns with Python MIoTDevice.unsub_prop. func (d *MIoTDevice) UnsubProp(subID string) { if d.miotClient == nil { - fmt.Printf("[MIoTDevice] Warning: miotClient is nil, cannot unsubscribe from property\n") + d.lgr.Warnf("[MIoTDevice] Warning: miotClient is nil, cannot unsubscribe from property\n") return } @@ -242,7 +255,7 @@ func (d *MIoTDevice) UnsubProp(subID string) { // Returns: subscription ID for unsubscribing. func (d *MIoTDevice) SubEvent(siid, eiid int, handler func(map[string]interface{}, interface{})) string { if d.miotClient == nil { - fmt.Printf("[MIoTDevice] Warning: miotClient is nil, cannot subscribe to event\n") + d.lgr.Warnf("[MIoTDevice] Warning: miotClient is nil, cannot subscribe to event\n") return "" } @@ -265,7 +278,7 @@ func (d *MIoTDevice) SubEvent(siid, eiid int, handler func(map[string]interface{ // Aligns with Python MIoTDevice.unsub_event. func (d *MIoTDevice) UnsubEvent(subID string) { if d.miotClient == nil { - fmt.Printf("[MIoTDevice] Warning: miotClient is nil, cannot unsubscribe from event\n") + d.lgr.Warnf("[MIoTDevice] Warning: miotClient is nil, cannot unsubscribe from event\n") return } diff --git a/miot/miot_lan.go b/miot/miot_lan.go index 792773f..e4b3037 100644 --- a/miot/miot_lan.go +++ b/miot/miot_lan.go @@ -12,13 +12,14 @@ import ( "encoding/binary" "encoding/json" "fmt" - "log" "math/big" "net" "strings" "sync" "sync/atomic" "time" + + "xiaomihome/logger" ) // ============================================================================ @@ -155,7 +156,7 @@ func newLanDevice(mgr *MIoTLan, did, tokenHex, ip string) (*LanDevice, error) { d.updateKeepAlive(LanDeviceDead) }) - log.Printf("[lan] device added: did=%s", d.DID) + mgr.lgr.Infof("[lan] device added: did=%s", d.DID) return d, nil } @@ -164,7 +165,7 @@ func (d *LanDevice) KeepAlive(ip, ifName string) { d.IP = ip if d.IfName != ifName { d.IfName = ifName - log.Printf("[lan] device if_name changed: %s, did=%s", d.IfName, d.DID) + d.manager.lgr.Infof("[lan] device if_name changed: %s, did=%s", d.IfName, d.DID) } d.updateKeepAlive(LanDeviceFresh) } @@ -315,12 +316,12 @@ func (d *LanDevice) Subscribe() { func (d *LanDevice) subscribeHandler(msg map[string]interface{}, subTS int) { result, _ := msg["result"].(map[string]interface{}) if result == nil { - log.Printf("[lan] subscribe error: no result, did=%s, msg=%v", d.DID, msg) + d.manager.lgr.Infof("[lan] subscribe error: no result, did=%s, msg=%v", d.DID, msg) return } code, _ := result["code"].(float64) if code != 0 { - log.Printf("[lan] subscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) + d.manager.lgr.Infof("[lan] subscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) return } d.subscribed = true @@ -329,7 +330,7 @@ func (d *LanDevice) subscribeHandler(msg map[string]interface{}, subTS int) { "online": d.online, "push_available": d.subscribed, }) - log.Printf("[lan] subscribe success: if=%s, did=%s", d.IfName, d.DID) + d.manager.lgr.Infof("[lan] subscribe success: if=%s, did=%s", d.IfName, d.DID) } // Unsubscribe sends a miIO.unsub request to the device. @@ -350,11 +351,11 @@ func (d *LanDevice) Unsubscribe() { result, _ := msg["result"].(map[string]interface{}) if result != nil { if code, _ := result["code"].(float64); code != 0 { - log.Printf("[lan] unsubscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) + d.manager.lgr.Infof("[lan] unsubscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) return } } - log.Printf("[lan] unsubscribe success: if=%s, did=%s", d.IfName, d.DID) + d.manager.lgr.Infof("[lan] unsubscribe success: if=%s, did=%s", d.IfName, d.DID) }, nil, 5000) d.subscribed = false d.manager.broadcastDeviceState(d.DID, map[string]interface{}{ @@ -373,7 +374,7 @@ func (d *LanDevice) OnDelete() { d.unstableTimer.Stop() d.unstableTimer = nil } - log.Printf("[lan] device deleted: did=%s", d.DID) + d.manager.lgr.Infof("[lan] device deleted: did=%s", d.DID) } // UpdateInfo updates the device token if it has changed. @@ -395,12 +396,12 @@ func (d *LanDevice) UpdateInfo(info map[string]interface{}) { aesIV := md5Sum(append(aesKey[:], d.Token...)) block, err := aes.NewCipher(aesKey[:]) if err != nil { - log.Printf("[lan] update token cipher error: %v, did=%s", err, d.DID) + d.manager.lgr.Infof("[lan] update token cipher error: %v, did=%s", err, d.DID) return } d.cipher = block d.aesIV = aesIV[:] - log.Printf("[lan] token updated: did=%s", d.DID) + d.manager.lgr.Infof("[lan] token updated: did=%s", d.DID) } // ============================================================================ @@ -412,7 +413,7 @@ func (d *LanDevice) updateKeepAlive(state LanDeviceState) { d.state = state if d.state != LanDeviceFresh { - log.Printf("[lan] device status: did=%s, state=%s", d.DID, d.state) + d.manager.lgr.Infof("[lan] device status: did=%s, state=%s", d.DID, d.state) } // Cancel existing timer @@ -440,11 +441,11 @@ func (d *LanDevice) updateKeepAlive(state LanDeviceState) { }) // Fast ping if d.IfName == "" { - log.Printf("[lan] if_name not set for device: did=%s", d.DID) + d.manager.lgr.Infof("[lan] if_name not set for device: did=%s", d.DID) return } if d.IP == "" { - log.Printf("[lan] ip not set for device: did=%s", d.DID) + d.manager.lgr.Infof("[lan] ip not set for device: did=%s", d.DID) return } d.manager.ping(d.IfName, d.IP) @@ -463,7 +464,7 @@ func (d *LanDevice) nextKATimeout() float64 { } func (d *LanDevice) changeOnline(online bool) { - log.Printf("[lan] change online: did=%s, online=%v", d.DID, online) + d.manager.lgr.Infof("[lan] change online: did=%s, online=%v", d.DID, online) tsNow := int(time.Now().Unix()) d.onlineHistory = append(d.onlineHistory, onlineEvent{ts: tsNow, online: online}) if len(d.onlineHistory) > networkUnstableCntTh { @@ -482,9 +483,9 @@ func (d *LanDevice) changeOnline(online bool) { float64(tsNow-d.onlineHistory[0].ts) > networkUnstableTimeTh { d.setOnline(true) } else { - log.Printf("[lan] unstable device detected: did=%s", d.DID) + d.manager.lgr.Infof("[lan] unstable device detected: did=%s", d.DID) d.unstableTimer = time.AfterFunc(time.Duration(networkUnstableResumeTh*float64(time.Second)), func() { - log.Printf("[lan] unstable resume threshold passed: did=%s", d.DID) + d.manager.lgr.Infof("[lan] unstable resume threshold passed: did=%s", d.DID) d.setOnline(true) }) } @@ -540,6 +541,8 @@ type MIoTLan struct { // Profile models (loaded from YAML, models that should NOT use LAN control) profileModels map[string]bool + + lgr logger.Logger } type pendingRequest struct { @@ -601,8 +604,10 @@ func NewMIoTLan(netIFs []string, network *MIoTNetwork, mipsService *MipsService, lanStateSubs: make(map[string]func(bool)), lanCtrlVotes: make(map[string]bool), profileModels: make(map[string]bool), + lgr: logger.Default(), } + for _, ifName := range netIFs { m.netIFs[ifName] = true } @@ -615,7 +620,7 @@ func NewMIoTLan(netIFs []string, network *MIoTNetwork, mipsService *MipsService, // Auto-init if no central hub gateway exists and we have interfaces if len(mipsService.GetServices("")) == 0 && len(netIFs) > 0 { - log.Println("[lan] no central hub gateway service, scheduling init") + m.lgr.Infof("[lan] no central hub gateway service, scheduling init") go func() { m.Init() }() @@ -624,6 +629,9 @@ func NewMIoTLan(netIFs []string, network *MIoTNetwork, mipsService *MipsService, return m, nil } +// SetLogger sets a custom logger for MIoTLan. +func (m *MIoTLan) SetLogger(l logger.Logger) { m.lgr = l } + // VirtualDID returns the virtual device ID used for LAN communication. func (m *MIoTLan) VirtualDID() string { return m.virtualDID @@ -648,12 +656,12 @@ func (m *MIoTLan) Init() { m.mu.Lock() if m.initDone { m.mu.Unlock() - log.Println("[lan] already initialized") + m.lgr.Infof("[lan] already initialized") return } if len(m.netIFs) == 0 { m.mu.Unlock() - log.Println("[lan] no net_ifs configured") + m.lgr.Infof("[lan] no net_ifs configured") return } @@ -667,13 +675,13 @@ func (m *MIoTLan) Init() { } if !hasVote { m.mu.Unlock() - log.Println("[lan] no vote for lan ctrl") + m.lgr.Infof("[lan] no vote for lan ctrl") return } if len(m.mipsService.GetServices("")) > 0 { m.mu.Unlock() - log.Println("[lan] central hub gateway service exists, skip LAN init") + m.lgr.Infof("[lan] central hub gateway service exists, skip LAN init") return } @@ -683,7 +691,7 @@ func (m *MIoTLan) Init() { } if len(m.availableIFs) == 0 { m.mu.Unlock() - log.Println("[lan] no available network interfaces") + m.lgr.Infof("[lan] no available network interfaces") return } @@ -697,7 +705,7 @@ func (m *MIoTLan) Init() { } if !hasValid { m.mu.Unlock() - log.Println("[lan] no valid net_ifs matching configured list") + m.lgr.Infof("[lan] no valid net_ifs matching configured list") return } @@ -721,7 +729,7 @@ func (m *MIoTLan) Init() { go h(true) } - log.Printf("[lan] initialized: netIFs=%v, available=%v", m.netIFs, m.availableIFs) + m.lgr.Infof("[lan] initialized: netIFs=%v, available=%v", m.netIFs, m.availableIFs) } // Deinit stops the LAN controller. @@ -732,7 +740,7 @@ func (m *MIoTLan) Deinit() { m.mu.Lock() if !m.initDone { m.mu.Unlock() - log.Println("[lan] not initialized") + m.lgr.Infof("[lan] not initialized") return } m.initDone = false @@ -765,7 +773,7 @@ func (m *MIoTLan) Deinit() { go h(false) } - log.Println("[lan] deinitialized") + m.lgr.Infof("[lan] deinitialized") } // ============================================================================ @@ -773,7 +781,7 @@ func (m *MIoTLan) Deinit() { // ============================================================================ func (m *MIoTLan) internalLoop() { - log.Println("[lan] internal loop started") + m.lgr.Infof("[lan] internal loop started") m.initSockets() // Start scan timer @@ -786,7 +794,7 @@ func (m *MIoTLan) internalLoop() { if cmd == nil { // Stop signal m.deinitInternal() - log.Println("[lan] internal loop stopped") + m.lgr.Infof("[lan] internal loop stopped") return } cmd() @@ -858,13 +866,13 @@ func (m *MIoTLan) createSocketLocked(ifName string) { ni := m.network.GetNetworkInfo() info, ok := ni[ifName] if !ok || info.IP == "" { - log.Printf("[lan] no IP for interface: %s", ifName) + m.lgr.Infof("[lan] no IP for interface: %s", ifName) return } conn, err := createBoundSocket(info.IP, m.localPort) if err != nil { - log.Printf("[lan] create socket error: if=%s, err=%v", ifName, err) + m.lgr.Infof("[lan] create socket error: if=%s, err=%v", ifName, err) return } @@ -877,7 +885,7 @@ func (m *MIoTLan) createSocketLocked(ifName string) { // Start reader goroutine for this socket go m.socketReader(ifName, conn) - log.Printf("[lan] socket created: if=%s, ip=%s, port=%d", ifName, info.IP, m.localPort) + m.lgr.Infof("[lan] socket created: if=%s, ip=%s, port=%d", ifName, info.IP, m.localPort) } func (m *MIoTLan) destroySocketLocked(ifName string) { @@ -887,7 +895,7 @@ func (m *MIoTLan) destroySocketLocked(ifName string) { } delete(m.socks, ifName) conn.Close() - log.Printf("[lan] socket destroyed: if=%s", ifName) + m.lgr.Infof("[lan] socket destroyed: if=%s", ifName) } // createBoundSocket creates a UDP socket bound to a specific IP address. @@ -936,7 +944,7 @@ func (m *MIoTLan) send(ifName, targetIP string, data []byte) { } else { conn, ok := m.socks[ifName] if !ok { - log.Printf("[lan] invalid socket: if=%s", ifName) + m.lgr.Infof("[lan] invalid socket: if=%s", ifName) return } conn.WriteToUDP(data, &net.UDPAddr{IP: net.ParseIP(targetIP), Port: otPort}) @@ -1110,7 +1118,7 @@ func (m *MIoTLan) handleRawMessage(data []byte, dataLen int, ip, ifName string) if dataLen > otProbeLen { msg, err := dev.DecryptPacket(data[:dataLen]) if err != nil { - log.Printf("[lan] decrypt error: did=%s, err=%v", did, err) + m.lgr.Infof("[lan] decrypt error: did=%s, err=%v", did, err) return } m.handleMessage(did, msg) @@ -1120,7 +1128,7 @@ func (m *MIoTLan) handleRawMessage(data []byte, dataLen int, ip, ifName string) func (m *MIoTLan) handleMessage(did string, msg map[string]interface{}) { msgID, ok := msg["id"] if !ok { - log.Printf("[lan] message without id: did=%s, msg=%v", did, msg) + m.lgr.Infof("[lan] message without id: did=%s, msg=%v", did, msg) return } id, ok := toInt(msgID) @@ -1161,7 +1169,7 @@ func (m *MIoTLan) handleMessage(did string, msg map[string]interface{}) { return } - log.Printf("[lan] message: did=%s, method=%s", did, method) + m.lgr.Infof("[lan] message: did=%s, method=%s", did, method) switch method { case "properties_changed": @@ -1256,7 +1264,7 @@ func (m *MIoTLan) scanDevices() { scanTime := m.nextScanTime() m.scanTimer = time.AfterFunc(time.Duration(scanTime*float64(time.Second)), m.scanDevices) - log.Printf("[lan] next scan in %.0fs", scanTime) + m.lgr.Infof("[lan] next scan in %.0fs", scanTime) } func (m *MIoTLan) nextScanTime() float64 { @@ -1272,7 +1280,7 @@ func (m *MIoTLan) nextScanTime() float64 { // ============================================================================ func (m *MIoTLan) onNetworkInfoChange(status InterfaceStatus, info *NetworkInfo) { - log.Printf("[lan] network info change: status=%s, if=%s", status, info.Name) + m.lgr.Infof("[lan] network info change: status=%s, if=%s", status, info.Name) m.mu.Lock() // Rebuild available IFs @@ -1296,7 +1304,7 @@ func (m *MIoTLan) onNetworkInfoChange(status InterfaceStatus, info *NetworkInfo) } if !valid { m.mu.Unlock() - log.Println("[lan] no valid net_ifs after change") + m.lgr.Infof("[lan] no valid net_ifs after change") go m.Deinit() return } @@ -1332,12 +1340,12 @@ func (m *MIoTLan) onNetworkInfoChange(status InterfaceStatus, info *NetworkInfo) // ============================================================================ func (m *MIoTLan) onMipsServiceChange(groupID string, state MipsServiceState, data map[string]interface{}) { - log.Printf("[lan] mips service change: groupID=%s, state=%s, data=%v", groupID, state, data) + m.lgr.Infof("[lan] mips service change: groupID=%s, state=%s, data=%v", groupID, state, data) if len(m.mipsService.GetServices("")) > 0 { - log.Println("[lan] central hub gateway found, deinit LAN") + m.lgr.Infof("[lan] central hub gateway found, deinit LAN") go m.Deinit() } else { - log.Println("[lan] no central hub gateway, init LAN") + m.lgr.Infof("[lan] no central hub gateway, init LAN") go m.Init() } } @@ -1348,7 +1356,7 @@ func (m *MIoTLan) onMipsServiceChange(groupID string, state MipsServiceState, da // VoteForLanCtrl votes for or against LAN control. func (m *MIoTLan) VoteForLanCtrl(key string, vote bool) { - log.Printf("[lan] vote for lan ctrl: key=%s, vote=%v", key, vote) + m.lgr.Infof("[lan] vote for lan ctrl: key=%s, vote=%v", key, vote) m.mu.Lock() m.lanCtrlVotes[key] = vote hasVote := false @@ -1369,7 +1377,7 @@ func (m *MIoTLan) VoteForLanCtrl(key string, vote bool) { // UpdateSubscribeOption enables or disables device subscriptions. func (m *MIoTLan) UpdateSubscribeOption(enable bool) { - log.Printf("[lan] update subscribe option: %v", enable) + m.lgr.Infof("[lan] update subscribe option: %v", enable) m.mu.Lock() oldEnable := m.enableSub m.enableSub = enable @@ -1398,12 +1406,12 @@ func (m *MIoTLan) UpdateDevices(devices map[string]map[string]interface{}) { for did, info := range devices { if !isNumeric(did) { - log.Printf("[lan] invalid did (non-numeric): %s", did) + m.lgr.Infof("[lan] invalid did (non-numeric): %s", did) continue } model, _ := info["model"].(string) if model != "" && m.profileModels[model] { - log.Printf("[lan] model not supported for LAN ctrl: did=%s, model=%s", did, model) + m.lgr.Infof("[lan] model not supported for LAN ctrl: did=%s, model=%s", did, model) continue } if existing, ok := m.devices[did]; ok { @@ -1411,13 +1419,13 @@ func (m *MIoTLan) UpdateDevices(devices map[string]map[string]interface{}) { } else { tokenStr, _ := info["token"].(string) if len(tokenStr) != 32 { - log.Printf("[lan] invalid token for device: did=%s", did) + m.lgr.Infof("[lan] invalid token for device: did=%s", did) continue } ip, _ := info["ip"].(string) dev, err := newLanDevice(m, did, tokenStr, ip) if err != nil { - log.Printf("[lan] create device error: did=%s, err=%v", did, err) + m.lgr.Infof("[lan] create device error: did=%s, err=%v", did, err) continue } m.devices[did] = dev @@ -1483,7 +1491,7 @@ func (m *MIoTLan) SubProp(did string, handler func(map[string]interface{}, inter } m.cmdCh <- func() { m.matcher.Sub(key, handler, ctx) - log.Printf("[lan] sub prop: key=%s", key) + m.lgr.Infof("[lan] sub prop: key=%s", key) } } @@ -1495,7 +1503,7 @@ func (m *MIoTLan) UnsubProp(did string, siid, piid int) { } m.cmdCh <- func() { m.matcher.Unsub(key) - log.Printf("[lan] unsub prop: key=%s", key) + m.lgr.Infof("[lan] unsub prop: key=%s", key) } } @@ -1507,7 +1515,7 @@ func (m *MIoTLan) SubEvent(did string, handler func(map[string]interface{}, inte } m.cmdCh <- func() { m.matcher.Sub(key, handler, ctx) - log.Printf("[lan] sub event: key=%s", key) + m.lgr.Infof("[lan] sub event: key=%s", key) } } @@ -1519,7 +1527,7 @@ func (m *MIoTLan) UnsubEvent(did string, siid, eiid int) { } m.cmdCh <- func() { m.matcher.Unsub(key) - log.Printf("[lan] unsub event: key=%s", key) + m.lgr.Infof("[lan] unsub event: key=%s", key) } } diff --git a/miot/miot_mdns.go b/miot/miot_mdns.go index ab5eeb9..1a6ef33 100644 --- a/miot/miot_mdns.go +++ b/miot/miot_mdns.go @@ -7,12 +7,13 @@ import ( "encoding/binary" "encoding/hex" "fmt" - "log" "strings" "sync" "time" "github.com/hashicorp/mdns" + + "xiaomihome/logger" ) // ============================================================================ @@ -165,6 +166,7 @@ type MipsService struct { mu sync.Mutex services map[string]*MipsServiceData // keyed by groupID subs map[subKey]MipsServiceChangeHandler + lgr logger.Logger stopCh chan struct{} } @@ -177,12 +179,17 @@ type subKey struct { // NewMipsService creates a new MipsService. func NewMipsService() *MipsService { - return &MipsService{ + s := &MipsService{ services: make(map[string]*MipsServiceData), subs: make(map[subKey]MipsServiceChangeHandler), + lgr: logger.Default(), } + return s } +// SetLogger sets a custom logger for MipsService. +func (s *MipsService) SetLogger(l logger.Logger) { s.lgr = l } + // Init starts the mDNS browser in a background goroutine. // Safe to call multiple times; subsequent calls after the first are no-ops // unless preceded by Deinit(). @@ -196,7 +203,7 @@ func (s *MipsService) Init() { s.mu.Unlock() go s.runBrowser() - log.Println("[mdns] mips service browser started") + s.lgr.Infof("[mdns] mips service browser started") } // Deinit stops the browser goroutine and clears all cached services and @@ -218,7 +225,7 @@ func (s *MipsService) Deinit() { s.subs = make(map[subKey]MipsServiceChangeHandler) s.mu.Unlock() - log.Println("[mdns] mips service deinitialized") + s.lgr.Infof("[mdns] mips service deinitialized") } // GetServices returns cached services. When groupID is non-empty, only that @@ -282,7 +289,7 @@ func (s *MipsService) runBrowser() { // Run the blocking Query in a goroutine so we can detect stop. go func() { if err := mdns.Query(params); err != nil { - log.Printf("[mdns] query error: %v", err) + s.lgr.Infof("[mdns] query error: %v", err) } close(entriesCh) }() @@ -304,7 +311,7 @@ func (s *MipsService) runBrowser() { func (s *MipsService) handleEntry(entry *mdns.ServiceEntry) { svc, err := entryToServiceData(entry) if err != nil { - log.Printf("[mdns] skip entry: %v", err) + s.lgr.Infof("[mdns] skip entry: %v", err) return } if !svc.validService() { @@ -332,7 +339,7 @@ func (s *MipsService) handleEntry(entry *mdns.ServiceEntry) { // callSubsLocked notifies all subscribers whose groupID matches. // Caller MUST hold s.mu. func (s *MipsService) callSubsLocked(groupID string, state MipsServiceState, data map[string]interface{}) { - log.Printf("[mdns] service change: groupID=%s, state=%s", groupID, state) + s.lgr.Infof("[mdns] service change: groupID=%s, state=%s", groupID, state) for sk, handler := range s.subs { if sk.groupID == groupID || sk.groupID == "*" { go handler(groupID, state, data) diff --git a/miot/miot_network.go b/miot/miot_network.go index 26f77f0..781f580 100644 --- a/miot/miot_network.go +++ b/miot/miot_network.go @@ -4,7 +4,6 @@ package miot import ( "fmt" - "log" "net" "net/http" "os/exec" @@ -12,6 +11,8 @@ import ( "strings" "sync" "time" + + "xiaomihome/logger" ) // ============================================================================ @@ -70,6 +71,8 @@ func (n *NetworkInfo) String() string { type MIoTNetwork struct { mu sync.Mutex + lgr logger.Logger + // Detection addresses with cached response times (seconds) ipAddrMap map[string]float64 httpAddrMap map[string]float64 @@ -123,6 +126,7 @@ func NewMIoTNetwork(ipAddrList, urlAddrList []string, refreshInterval time.Durat } n := &MIoTNetwork{ + lgr: logger.Default(), ipAddrMap: make(map[string]float64), httpAddrMap: make(map[string]float64), httpClient: &http.Client{Timeout: defaultDetectTimeout}, @@ -145,6 +149,9 @@ func NewMIoTNetwork(ipAddrList, urlAddrList []string, refreshInterval time.Durat return n } +// SetLogger sets a custom logger for MIoTNetwork. +func (n *MIoTNetwork) SetLogger(l logger.Logger) { n.lgr = l } + // Init starts the network monitor. func (n *MIoTNetwork) Init() { // Initial refresh @@ -248,7 +255,7 @@ func (n *MIoTNetwork) loop() { // refresh performs a full refresh: network status detection + interface info. func (n *MIoTNetwork) refresh() { newStatus := checkNetworkStatus(n) - newInfo := getNetworkInterfaces() + newInfo := n.getNetworkInterfaces() n.mu.Lock() oldStatus := n.networkStatus @@ -518,12 +525,12 @@ func httpMulti(urls []string) map[string]float64 { // ============================================================================ // getNetworkInterfaces returns all non-loopback, non-docker IPv4 interfaces. -func getNetworkInterfaces() map[string]*NetworkInfo { +func (n *MIoTNetwork) getNetworkInterfaces() map[string]*NetworkInfo { results := make(map[string]*NetworkInfo) ifaces, err := net.Interfaces() if err != nil { - log.Printf("[network] get interfaces error: %v", err) + n.lgr.Infof("[network] get interfaces error: %v", err) return results } diff --git a/miot/miot_test.go b/miot/miot_test.go index ab60a19..bd41dba 100644 --- a/miot/miot_test.go +++ b/miot/miot_test.go @@ -87,7 +87,7 @@ func TestMIoTClient_RefreshDevices(t *testing.T) { client.SetStorage(storage) // Refresh devices (will fail without proper HTTP client, but should not panic) - err := client.RefreshDevices() + err := client.RefreshDevices(nil) if err != nil { t.Logf("RefreshDevices error (expected in test): %v", err) } diff --git a/miot/mips_client.go b/miot/mips_client.go index 028817f..1dac23d 100644 --- a/miot/mips_client.go +++ b/miot/mips_client.go @@ -6,7 +6,6 @@ import ( "encoding/binary" "encoding/json" "fmt" - "log" "math" "math/rand" "strings" @@ -14,6 +13,8 @@ import ( "time" mqtt "github.com/eclipse/paho.mqtt.golang" + + "xiaomihome/logger" ) // ============================================================================ @@ -21,14 +22,14 @@ import ( // ============================================================================ const ( - mipsQoS = 2 - mipsReconnectIntervalMin = 10.0 // seconds - mipsReconnectIntervalMax = 600.0 // seconds - mipsSubPatch = 300 - mipsSubInterval = 1 // second - mipsMQTTInterval = 1 // second - uint32max = 0xFFFFFFFF - MipsRequestTimeoutDefault = 10000 // milliseconds + mipsQoS = 2 + mipsReconnectIntervalMin = 10.0 // seconds + mipsReconnectIntervalMax = 600.0 // seconds + mipsSubPatch = 300 + mipsSubInterval = 1 // second + mipsMQTTInterval = 1 // second + uint32max = 0xFFFFFFFF + MipsRequestTimeoutDefault = 10000 // milliseconds ) // ============================================================================ @@ -217,16 +218,16 @@ type MipsBroadcast struct { // mipsClient is the concrete MIPS client using paho MQTT. type mipsClient struct { - mu sync.Mutex - logger *log.Logger - clientID string - host string - port int - username string - password string - caFile string - certFile string - keyFile string + mu sync.Mutex + lgr logger.Logger + clientID string + host string + port int + username string + password string + caFile string + certFile string + keyFile string // MQTT connection mqttConn MQTTClient // interface to paho MQTT @@ -244,9 +245,9 @@ type mipsClient struct { subscriptions map[string]*MipsBroadcast // Requests - requestMu sync.Mutex - requests map[string]*MipsRequest // mid as string -> request - seedID int + requestMu sync.Mutex + requests map[string]*MipsRequest // mid as string -> request + seedID int // State subscribers stateSubMu sync.Mutex @@ -276,26 +277,31 @@ type MQTTClient interface { // newMipsClient creates a new mipsClient (internal). func newMipsClient(clientID, host string, port int, username, password, caFile, certFile, keyFile string) *mipsClient { - return &mipsClient{ - clientID: clientID, - host: host, - port: port, - username: username, - password: password, - caFile: caFile, - certFile: certFile, - keyFile: keyFile, - seedID: rand.Intn(uint32max), - pendingSubs: make(map[string]int), - subscriptions: make(map[string]*MipsBroadcast), - requests: make(map[string]*MipsRequest), - stateSubs: make(map[string]func(key string, connected bool)), + c := &mipsClient{ + clientID: clientID, + host: host, + port: port, + username: username, + password: password, + caFile: caFile, + certFile: certFile, + keyFile: keyFile, + lgr: logger.Default(), + seedID: rand.Intn(uint32max), + pendingSubs: make(map[string]int), + subscriptions: make(map[string]*MipsBroadcast), + requests: make(map[string]*MipsRequest), + stateSubs: make(map[string]func(key string, connected bool)), reconnectActive: true, - stopCh: make(chan struct{}), - doneCh: make(chan struct{}), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), } + return c } +// SetLogger sets a custom logger for mipsClient. +func (c *mipsClient) SetLogger(l logger.Logger) { c.lgr = l } + // connect starts the MQTT connection. func (c *mipsClient) connect(mqttClient MQTTClient) error { c.mu.Lock() @@ -363,7 +369,7 @@ func (c *mipsClient) subscribe(topic string, handler func(topic string, payload defer c.subMu.Unlock() if c.subscriptions[topic] != nil { - log.Printf("[mips] re-register subscription: %s", topic) + c.lgr.Debugf("[mips] re-register subscription: %s", topic) } c.subscriptions[topic] = &MipsBroadcast{ Topic: topic, @@ -388,7 +394,7 @@ func (c *mipsClient) unsubscribe(topic string) error { if c.mqttConn != nil && c.mqttConn.IsConnected() { if err := c.mqttConn.Unsubscribe(topic); err != nil { - log.Printf("[mips] unsubscribe error %s: %v", topic, err) + c.lgr.Debugf("[mips] unsubscribe error %s: %v", topic, err) } } return nil @@ -530,7 +536,7 @@ func (c *mipsClient) flushPendingSubs() { } if count > 3 { delete(c.pendingSubs, topic) - log.Printf("[mips] retry sub exceeded: %s", topic) + c.lgr.Debugf("[mips] retry sub exceeded: %s", topic) continue } subbed++ @@ -539,10 +545,10 @@ func (c *mipsClient) flushPendingSubs() { c.onMQTTMessage(t, p) }); err == nil { delete(c.pendingSubs, topic) - log.Printf("[mips] sub success: %s", topic) + c.lgr.Debugf("[mips] sub success: %s", topic) } else { c.pendingSubs[topic] = count + 1 - log.Printf("[mips] retry sub %d: %s %v", count, topic, err) + c.lgr.Debugf("[mips] retry sub %d: %s %v", count, topic, err) } } @@ -647,13 +653,13 @@ func (c *mipsClient) tryReconnect(immediate bool) { c.reconnectInterval = math.Min(c.reconnectInterval*2, mipsReconnectIntervalMax) } interval = time.Duration(c.reconnectInterval) * time.Second - log.Printf("[mips] reconnect after %v", interval) + c.lgr.Debugf("[mips] reconnect after %v", interval) } c.reconnectTimer = time.AfterFunc(interval, func() { if c.mqttConn != nil { if err := c.mqttConn.Connect(); err != nil { - log.Printf("[mips] reconnect failed: %v", err) + c.lgr.Debugf("[mips] reconnect failed: %v", err) c.tryReconnect(false) } } @@ -937,6 +943,8 @@ type pahoMQTTClient struct { certFile string keyFile string + lgr logger.Logger + onConnect func() onDisconnect func() onMessage func(topic string, payload []byte) @@ -946,7 +954,7 @@ type pahoMQTTClient struct { } func newPahoMQTTClient(clientID, host string, port int, username, password, caFile, certFile, keyFile string) *pahoMQTTClient { - return &pahoMQTTClient{ + c := &pahoMQTTClient{ clientID: clientID, host: host, port: port, @@ -955,9 +963,14 @@ func newPahoMQTTClient(clientID, host string, port int, username, password, caFi caFile: caFile, certFile: certFile, keyFile: keyFile, + lgr: logger.Default(), } + return c } +// SetLogger sets a custom logger for pahoMQTTClient. +func (p *pahoMQTTClient) SetLogger(l logger.Logger) { p.lgr = l } + func (p *pahoMQTTClient) Connect() error { opts := mqtt.NewClientOptions() opts.AddBroker(fmt.Sprintf("ssl://%s:%d", p.host, p.port)) @@ -969,7 +982,7 @@ func (p *pahoMQTTClient) Connect() error { opts.SetCleanSession(true) opts.OnConnect = func(c mqtt.Client) { - log.Printf("[mips] MQTT connected to %s:%d", p.host, p.port) + p.lgr.Debugf("[mips] MQTT connected to %s:%d", p.host, p.port) p.connected = true if p.onConnect != nil { p.onConnect() @@ -977,7 +990,7 @@ func (p *pahoMQTTClient) Connect() error { } opts.OnConnectionLost = func(c mqtt.Client, err error) { - log.Printf("[mips] MQTT connection lost: %v", err) + p.lgr.Debugf("[mips] MQTT connection lost: %v", err) p.connected = false if p.onDisconnect != nil { p.onDisconnect() @@ -985,7 +998,7 @@ func (p *pahoMQTTClient) Connect() error { } opts.DefaultPublishHandler = func(c mqtt.Client, msg mqtt.Message) { - log.Printf("[mips] MQTT message: %s (%d bytes)", msg.Topic(), len(msg.Payload())) + p.lgr.Debugf("[mips] MQTT message: %s (%d bytes) data:%s", msg.Topic(), len(msg.Payload()), string(msg.Payload())) if p.onMessage != nil { p.onMessage(msg.Topic(), msg.Payload()) } @@ -997,7 +1010,7 @@ func (p *pahoMQTTClient) Connect() error { return token.Error() } - log.Printf("[mips] MQTT connected to %s:%d as %s", p.host, p.port, p.clientID) + p.lgr.Debugf("[mips] MQTT connected to %s:%d as %s", p.host, p.port, p.clientID) return nil } @@ -1038,7 +1051,7 @@ func (p *pahoMQTTClient) Unsubscribe(topic string) error { if token.Wait() && token.Error() != nil { return token.Error() } - log.Printf("[mips] unsub success: %s", topic) + p.lgr.Debugf("[mips] unsub success: %s", topic) return nil } diff --git a/miot/spec_parser.go b/miot/spec_parser.go index c49671c..dd35fc3 100644 --- a/miot/spec_parser.go +++ b/miot/spec_parser.go @@ -7,7 +7,6 @@ import ( "fmt" "hash/crc32" "io" - "log" "math" "net/http" "os" @@ -17,6 +16,8 @@ import ( "strings" "sync" "time" + + "xiaomihome/logger" ) // ============================================================================ @@ -121,6 +122,7 @@ type MIoTSpecParser struct { storage *MIoTStorage specDir string stdLib *SpecStdLib + lgr logger.Logger initDone bool initMu sync.Mutex } @@ -130,14 +132,19 @@ func NewMIoTSpecParser(lang string, storage *MIoTStorage, specDir string) *MIoTS if lang == "" { lang = DEFAULT_INTEGRATION_LANGUAGE } - return &MIoTSpecParser{ + p := &MIoTSpecParser{ lang: lang, storage: storage, specDir: specDir, stdLib: NewSpecStdLib(lang), + lgr: logger.Default(), } + return p } +// SetLogger sets a custom logger for MIoTSpecParser. +func (p *MIoTSpecParser) SetLogger(l logger.Logger) { p.lgr = l } + // Init initializes the parser, loading or refreshing the standard library. func (p *MIoTSpecParser) Init() error { p.initMu.Lock() @@ -161,7 +168,7 @@ func (p *MIoTSpecParser) Init() error { ts, _ = v.Int64() } if ts > 0 && time.Now().Unix()-ts < SPEC_STD_LIB_EFFECTIVE_TIME { - log.Printf("[spec] using cached spec std lib, ts=%d", ts) + p.lgr.Debugf("[spec] using cached spec std lib, ts=%d", ts) p.stdLib.Load(dataMap) p.initDone = true return nil @@ -173,12 +180,12 @@ func (p *MIoTSpecParser) Init() error { // Refresh from cloud if err := p.stdLib.Refresh(); err != nil { - log.Printf("[spec] refresh std lib failed: %v", err) + p.lgr.Debugf("[spec] refresh std lib failed: %v", err) // Try stale cache as fallback if cacheMap, ok := stdLibCache.(map[string]interface{}); ok { if dataField, ok := cacheMap["data"]; ok { if dataMap, ok := dataField.(map[string]interface{}); ok { - log.Printf("[spec] using stale cached spec std lib") + p.lgr.Debugf("[spec] using stale cached spec std lib") p.stdLib.Load(dataMap) p.initDone = true return nil @@ -275,7 +282,7 @@ func (p *MIoTSpecParser) downloadSpecFile(urn, filePath string) error { return fmt.Errorf("write %s: %w", filePath, err) } - log.Printf("[spec] downloaded spec: %s -> %s", urn, filePath) + p.lgr.Debugf("[spec] downloaded spec: %s -> %s", urn, filePath) return nil } @@ -567,6 +574,7 @@ func (p *MIoTSpecParser) skipProprietary(item map[string]interface{}) bool { // SpecStdLib is the MIoT-Spec-V2 standard library for multi-language descriptions. type SpecStdLib struct { lang string + lgr logger.Logger devices map[string]map[string]string services map[string]map[string]string properties map[string]map[string]string @@ -580,8 +588,9 @@ func NewSpecStdLib(lang string) *SpecStdLib { if lang == "" { lang = DEFAULT_INTEGRATION_LANGUAGE } - return &SpecStdLib{ + s := &SpecStdLib{ lang: lang, + lgr: logger.Default(), devices: make(map[string]map[string]string), services: make(map[string]map[string]string), properties: make(map[string]map[string]string), @@ -589,8 +598,12 @@ func NewSpecStdLib(lang string) *SpecStdLib { actions: make(map[string]map[string]string), values: make(map[string]map[string]string), } + return s } +// SetLogger sets a custom logger for SpecStdLib. +func (s *SpecStdLib) SetLogger(l logger.Logger) { s.lgr = l } + // Load loads standard library data from a parsed map. func (s *SpecStdLib) Load(data map[string]interface{}) { if data == nil { @@ -690,7 +703,7 @@ func (s *SpecStdLib) Refresh() error { var lastErr error for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { - log.Printf("[spec] retry std lib refresh, attempt %d", attempt) + s.lgr.Debugf("[spec] retry std lib refresh, attempt %d", attempt) time.Sleep(time.Duration(attempt) * time.Second) } @@ -723,7 +736,7 @@ func (s *SpecStdLib) Refresh() error { url := fmt.Sprintf("https://cdn.cnbj1.fds.api.mi-img.com/res-conf/xiaomi-home/std_ex_%s.json", name) extData, err := s.fetchJSONMapMap(httpClient, url) if err != nil { - log.Printf("[spec] get external std lib failed, %s: %v", name, err) + s.lgr.Debugf("[spec] get external std lib failed, %s: %v", name, err) continue } if extData != nil { @@ -763,7 +776,7 @@ func (s *SpecStdLib) Refresh() error { if len(errs) > 0 { lastErr = fmt.Errorf("std lib refresh errors: %v", errs) - log.Printf("[spec] std lib refresh error: %v", lastErr) + s.lgr.Debugf("[spec] std lib refresh error: %v", lastErr) } } diff --git a/xiaomi/classify.go b/xiaomi/classify.go index b4534a3..d92108a 100644 --- a/xiaomi/classify.go +++ b/xiaomi/classify.go @@ -39,6 +39,12 @@ var deviceTypeToKind = map[string]Kind{ "heater": KindThermostat, "electric-blanket": KindThermostat, "thermostat": KindThermostat, + "speaker": KindSpeaker, + "bluetooth-speaker": KindSpeaker, + "alarm-clock": KindSpeaker, + "media-player": KindSpeaker, + "playback": KindSpeaker, + "music": KindSpeaker, "sensor": KindSensor, "sensor-ht": KindSensor, "sensor-occupy": KindSensor, diff --git a/xiaomi/client.go b/xiaomi/client.go index ccdde4a..2a5f305 100644 --- a/xiaomi/client.go +++ b/xiaomi/client.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "xiaomihome/logger" "xiaomihome/miot" ) @@ -17,6 +18,7 @@ type Client struct { mipsCloud *miot.MipsCloudClient specParser *miot.MIoTSpecParser uuid string + lgr logger.Logger deviceMu sync.RWMutex devices map[string]*miot.MIoTDevice @@ -70,35 +72,47 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { ) miotClient.SetMipsCloud(mipsCloud) + var ids []string + if len(cfg.HomeIDs) > 0 { + ids = make([]string, len(cfg.HomeIDs)) + for i, v := range cfg.HomeIDs { + ids[i] = fmt.Sprintf("%d", v) + } + miotClient.SetHomeIDs(ids) + } + if err := miotClient.Init(); err != nil { return nil, fmt.Errorf("xiaomi: init: %w", err) } - _ = miotClient.RefreshDevices() + // Use the same homeIDs slice from above (nil if not set) + _ = miotClient.RefreshDevices(ids) if err := mipsCloud.Connect(); err != nil { return nil, fmt.Errorf("xiaomi: mips connect: %w", err) } - // 启动后台 token 自动刷新 - if cfg.OnTokenRefreshed != nil { - miotClient.SetTokenRefreshCallback(cfg.OnTokenRefreshed) - } - miotClient.RequestRefreshAuthInfo(10) - - return &Client{ + c := &Client{ cfg: cfg, inner: miotClient, http: httpClient, mipsCloud: mipsCloud, uuid: uuid, + lgr: logger.Default(), specParser: miot.NewMIoTSpecParser("en", miot.NewMIoTStorage(cfg.DataDir), cfg.DataDir+"/specs"), devices: make(map[string]*miot.MIoTDevice), propSubs: make(map[string]string), eventSubs: make(map[string]string), - }, nil + } + return c, nil } +// SetLogger 设置日志记录器。 +func (c *Client) SetLogger(l logger.Logger) { c.lgr = l } + +// SetHomeIDs 动态设置 miot 层的家庭过滤,供后台定时 RefreshDevices 使用。 +func (c *Client) SetHomeIDs(homeIDs []string) { c.inner.SetHomeIDs(homeIDs) } + // Close 关闭客户端。 func (c *Client) Close() error { if c.mipsCloud != nil { @@ -119,7 +133,7 @@ func (c *Client) RefreshToken(ctx context.Context) (*TokenResult, error) { return &TokenResult{ AccessToken: strVal(result, "access_token"), RefreshToken: strVal(result, "refresh_token"), - ExpiresIn: intVal(result, "expires_in"), + ExpiresIn: int32(intVal(result, "expires_in")), ExpiresTS: int64(intVal(result, "expires_ts")), UID: strVal(result, "uid"), }, nil @@ -140,7 +154,7 @@ func (c *Client) getOrCreateDevice(did string, info map[string]interface{}) *mio if err == nil { spec = s } else { - fmt.Printf("[xiaomi] spec parse failed for %s (%s): %v\n", did, urn, err) + c.lgr.Warnf("[xiaomi] spec parse failed for %s (%s): %v", did, urn, err) } } diff --git a/xiaomi/config.go b/xiaomi/config.go index f528a16..2844a8b 100644 --- a/xiaomi/config.go +++ b/xiaomi/config.go @@ -4,7 +4,7 @@ package xiaomi type AuthInfo struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` // 有效期(秒) + ExpiresIn int32 `json:"expires_in"` // 有效期(秒) ExpiresTS int64 `json:"expires_ts"` // 过期时间戳(unix),0 则内部按 expires_in 计算 } @@ -15,6 +15,7 @@ type Config struct { UID string // 小米帐号 UID,OAuth 后从 API 获取 DataDir string // 存储根路径(token/设备/SPEC 缓存),多用户场景每个用户用不同目录,如 ./data/{uid} - // OnTokenRefreshed 后台自动刷新成功时回调,供调用方将新 token 持久化到数据库。 - OnTokenRefreshed func(accessToken, refreshToken string) + RedirectURL string // OAuth 授权回调 URL + + HomeIDs []int64 // 需要管理的家庭 ID 列表,nil/空=全部 } diff --git a/xiaomi/convert.go b/xiaomi/convert.go index 2dac617..04dba94 100644 --- a/xiaomi/convert.go +++ b/xiaomi/convert.go @@ -1,6 +1,9 @@ package xiaomi -import "fmt" +import ( + "fmt" + "strconv" +) // map → 类型安全提取工具函数 @@ -38,6 +41,9 @@ func boolVal(m map[string]interface{}, key string) bool { func strSliceVal(m map[string]interface{}, key string) []string { if v, ok := m[key]; ok { + if arr, ok := v.([]string); ok { + return arr + } if arr, ok := v.([]interface{}); ok { result := make([]string, 0, len(arr)) for _, item := range arr { @@ -69,3 +75,8 @@ func toFloat64(v interface{}) float64 { } return 0 } + +func int64FromStr(s string) int64 { + v, _ := strconv.ParseInt(s, 10, 64) + return v +} diff --git a/xiaomi/devices.go b/xiaomi/devices.go index d574d20..8ea30ae 100644 --- a/xiaomi/devices.go +++ b/xiaomi/devices.go @@ -83,9 +83,9 @@ func (c *Client) GetDevice(ctx context.Context, did string) (*DeviceInfo, error) return parseDeviceInfo(did, raw), nil } -// RefreshDevices 刷新设备列表 -func (c *Client) RefreshDevices(ctx context.Context) error { - return c.inner.RefreshDevices() +// RefreshDevices 刷新设备列表,homeIDs 为空则全量 +func (c *Client) RefreshDevices(ctx context.Context, homeIDs ...string) error { + return c.inner.RefreshDevices(homeIDs) } // GetMIoTDevice 获取底层 MIoTDevice 实例(供设备控制抽象使用) diff --git a/xiaomi/devices/air_conditioner.go b/xiaomi/devices/air_conditioner.go index 68bf9bd..bb843d1 100644 --- a/xiaomi/devices/air_conditioner.go +++ b/xiaomi/devices/air_conditioner.go @@ -2,89 +2,72 @@ package devices import ( "context" - "fmt" + "strings" + "xiaomihome/miot" "xiaomihome/xiaomi" ) +// valMapper maps between device raw values and semantic names. Built from SPEC ValueList. +// Different brands assign different raw values for the same mode name, +// so this must be per-device. Names are lowercased from SPEC description for consistency. +type valMapper struct { + val2name map[int]string + name2val map[string]int + names []string +} + +func newValMapper(vl miot.MIoTSpecValueList) *valMapper { + vm := &valMapper{ + val2name: make(map[int]string), + name2val: make(map[string]int), + } + for _, item := range vl { + v := toInt(item.Value) + name := strings.ToLower(item.Desc) + vm.val2name[v] = name + vm.name2val[name] = v + vm.names = append(vm.names, name) + } + return vm +} + +func (vm *valMapper) name(v int) string { + if vm == nil { return "" } + return vm.val2name[v] +} + +func (vm *valMapper) val(name string) (int, bool) { + if vm == nil { return 0, false } + v, ok := vm.name2val[name] + return v, ok +} + +func (vm *valMapper) allNames() []string { + if vm == nil { return nil } + return vm.names +} + // ========== 空调模式/风速/摆风常量 ========== -// ACMode 空调模式 -type ACMode string - const ( - ModeCool ACMode = "cool" - ModeHeat ACMode = "heat" - ModeFan ACMode = "fan" - ModeDry ACMode = "dry" - ModeAuto ACMode = "auto" + ACModeCool = "cool" + ACModeHeat = "heat" + ACModeFan = "fan" + ACModeDry = "dry" + ACModeAuto = "auto" + + ACFanLow = "low" + ACFanMedium = "medium" + ACFanHigh = "high" + ACFanAuto = "auto" + + ACSwingOff = "off" + ACSwingVertical = "vertical" + ACSwingHorizontal = "horizontal" + ACSwingBoth = "both" ) -// modeValueMap 模式值映射(uint8 → ACMode) -var modeValueMap = map[int]ACMode{ - 0: ModeCool, - 1: ModeHeat, - 2: ModeFan, - 3: ModeDry, - 4: ModeAuto, -} - -var modeReverseMap = map[ACMode]int{ - ModeCool: 0, - ModeHeat: 1, - ModeFan: 2, - ModeDry: 3, - ModeAuto: 4, -} - -// FanSpeed 风速 -type FanSpeed string - -const ( - SpeedLow FanSpeed = "low" - SpeedMedium FanSpeed = "medium" - SpeedHigh FanSpeed = "high" - SpeedAuto FanSpeed = "auto" -) - -var fanSpeedValueMap = map[int]FanSpeed{ - 0: SpeedLow, - 1: SpeedMedium, - 2: SpeedHigh, - 3: SpeedAuto, -} - -var fanSpeedReverseMap = map[FanSpeed]int{ - SpeedLow: 0, - SpeedMedium: 1, - SpeedHigh: 2, - SpeedAuto: 3, -} - -// SwingMode 摆风模式 -type SwingMode string - -const ( - SwingOff SwingMode = "off" - SwingVertical SwingMode = "vertical" - SwingHorizontal SwingMode = "horizontal" - SwingBoth SwingMode = "both" -) - -var swingValueMap = map[int]SwingMode{ - 0: SwingOff, - 1: SwingVertical, - 2: SwingHorizontal, - 3: SwingBoth, -} - -var swingReverseMap = map[SwingMode]int{ - SwingOff: 0, - SwingVertical: 1, - SwingHorizontal: 2, - SwingBoth: 3, -} - // ========== AirConditioner 接口 ========== // AirConditioner 空调统一控制接口 @@ -102,8 +85,8 @@ type AirConditioner interface { HasEco() bool // 模式控制 - SetMode(ctx context.Context, mode ACMode) error - GetMode(ctx context.Context) (ACMode, error) + SetMode(ctx context.Context, mode string) error + GetMode(ctx context.Context) (string, error) // 温度控制 SetTargetTemp(ctx context.Context, celsius float64) error @@ -111,12 +94,12 @@ type AirConditioner interface { GetCurrentTemp(ctx context.Context) (float64, error) // 室内温度 // 风速控制 - SetFanSpeed(ctx context.Context, speed FanSpeed) error - GetFanSpeed(ctx context.Context) (FanSpeed, error) + SetFanSpeed(ctx context.Context, speed string) error + GetFanSpeed(ctx context.Context) (string, error) // 摆风控制 - SetSwing(ctx context.Context, mode SwingMode) error - GetSwing(ctx context.Context) (SwingMode, error) + SetSwing(ctx context.Context, mode string) error + GetSwing(ctx context.Context) (string, error) // 湿度 SetTargetHumidity(ctx context.Context, pct int) error @@ -133,20 +116,22 @@ type AirConditioner interface { // 订阅 OnStateChanged(handler func(state ACState, changed string)) (string, error) + GetState() ACState + FetchState(ctx context.Context) (ACState, error) } // ACState 空调完整状态(字段名对齐 SPEC type) type ACState struct { - 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" + On bool `json:"on"` + Mode string `json:"mode"` // SPEC ValueList 解析后的名称:cool/heat/fan/dry/auto + TargetTemperature float64 `json:"targetTemperature"` + Temperature float64 `json:"temperature"` + TargetHumidity int `json:"targetHumidity"` + CurrentHumidity int `json:"currentHumidity"` + FanLevel string `json:"fanLevel"` // SPEC ValueList 解析后的名称:low/medium/high/auto + Swing string `json:"swing"` // SPEC ValueList 解析后的名称:off/vertical/horizontal/both + AuxHeat bool `json:"auxHeat"` + Eco bool `json:"eco"` } type acDevice struct { @@ -162,6 +147,10 @@ type acDevice struct { propAuxHeat siidPiid // "aux-heat" or "heater" propEco siidPiid // "eco" + modeMap *valMapper // SPEC value→name for mode + fanMap *valMapper // SPEC value→name for fan-level + swingMap *valMapper // SPEC value→name for swing + stateCache ACState // MQTT 推送维护的本地缓存 } @@ -171,11 +160,32 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi BaseDevice: NewBaseDevice(client, info), } + dev := d.getMIoTDevice() + if dev == nil { + return nil, xiaomi.ErrNoSPEC + } + spec := dev.SpecInstance() + if spec == nil { + return nil, xiaomi.ErrNoSPEC + } + resolver := d.getSpecResolver() if resolver == nil { return nil, xiaomi.ErrNoSPEC } + // Helper: find property ValueList from spec by type name + findValList := func(propType string) miot.MIoTSpecValueList { + for _, svc := range spec.Services { + for _, p := range svc.Properties { + if propertyShortName(p.Type) == propType { + return p.ValueList + } + } + } + return nil + } + // "on" is required siid, piid, ok := resolver.find("on") if !ok { @@ -186,6 +196,7 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi // Optional properties — each independently resolved if s, p, ok := resolver.find("mode"); ok { d.propMode = siidPiid{s, p, true} + d.modeMap = newValMapper(findValList("mode")) } if s, p, ok := resolver.find("target-temperature"); ok { d.propTargetTemp = siidPiid{s, p, true} @@ -195,14 +206,18 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi } if s, p, ok := resolver.find("fan-level"); ok { d.propFanSpeed = siidPiid{s, p, true} + d.fanMap = newValMapper(findValList("fan-level")) } // Try multiple swing mode type names if s, p, ok := resolver.find("swing-mode"); ok { d.propSwing = siidPiid{s, p, true} + d.swingMap = newValMapper(findValList("swing-mode")) } else if s, p, ok := resolver.find("horizontal-swing"); ok { d.propSwing = siidPiid{s, p, true} + d.swingMap = newValMapper(findValList("horizontal-swing")) } else if s, p, ok := resolver.find("vertical-swing"); ok { d.propSwing = siidPiid{s, p, true} + d.swingMap = newValMapper(findValList("vertical-swing")) } if s, p, ok := resolver.find("target-humidity"); ok { d.propTargetHumidity = siidPiid{s, p, true} @@ -256,30 +271,26 @@ func (d *acDevice) HasHumidity() bool { return d.propTargetHumidity.valid } func (d *acDevice) HasAuxHeat() bool { return d.propAuxHeat.valid } func (d *acDevice) HasEco() bool { return d.propEco.valid } -func (d *acDevice) SetMode(ctx context.Context, mode ACMode) error { +func (d *acDevice) SetMode(ctx context.Context, mode string) error { if !d.propMode.valid { return xiaomi.ErrNotSupported } - val, ok := modeReverseMap[mode] + v, ok := d.modeMap.val(mode) if !ok { - val = 4 // default auto + return xiaomi.ErrNotSupported } - return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, val) + return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, v) } -func (d *acDevice) GetMode(ctx context.Context) (ACMode, error) { +func (d *acDevice) GetMode(ctx context.Context) (string, error) { if !d.propMode.valid { - return ModeAuto, xiaomi.ErrNotSupported + return ACModeAuto, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid) if err != nil { - return ModeAuto, err + return ACModeAuto, err } - v := toInt(prop.Value) - if m, ok := modeValueMap[v]; ok { - return m, nil - } - return ModeAuto, nil + return d.modeMap.name(toInt(prop.Value)), nil } func (d *acDevice) SetTargetTemp(ctx context.Context, celsius float64) error { @@ -311,56 +322,48 @@ func (d *acDevice) GetCurrentTemp(ctx context.Context) (float64, error) { return toFloat(prop.Value), nil } -func (d *acDevice) SetFanSpeed(ctx context.Context, speed FanSpeed) error { +func (d *acDevice) SetFanSpeed(ctx context.Context, speed string) error { if !d.propFanSpeed.valid { return xiaomi.ErrNotSupported } - val, ok := fanSpeedReverseMap[speed] + v, ok := d.fanMap.val(speed) if !ok { - val = 3 // default auto + return xiaomi.ErrNotSupported } - return d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, val) + return d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, v) } -func (d *acDevice) GetFanSpeed(ctx context.Context) (FanSpeed, error) { +func (d *acDevice) GetFanSpeed(ctx context.Context) (string, error) { if !d.propFanSpeed.valid { - return SpeedAuto, xiaomi.ErrNotSupported + return ACFanAuto, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid) if err != nil { - return SpeedAuto, err + return ACFanAuto, err } - v := toInt(prop.Value) - if s, ok := fanSpeedValueMap[v]; ok { - return s, nil - } - return SpeedAuto, nil + return d.fanMap.name(toInt(prop.Value)), nil } -func (d *acDevice) SetSwing(ctx context.Context, mode SwingMode) error { +func (d *acDevice) SetSwing(ctx context.Context, mode string) error { if !d.propSwing.valid { return xiaomi.ErrNotSupported } - val, ok := swingReverseMap[mode] + v, ok := d.swingMap.val(mode) if !ok { - val = 0 + return xiaomi.ErrNotSupported } - return d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, val) + return d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, v) } -func (d *acDevice) GetSwing(ctx context.Context) (SwingMode, error) { +func (d *acDevice) GetSwing(ctx context.Context) (string, error) { if !d.propSwing.valid { - return SwingOff, xiaomi.ErrNotSupported + return ACSwingOff, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propSwing.siid, d.propSwing.piid) if err != nil { - return SwingOff, err + return ACSwingOff, err } - v := toInt(prop.Value) - if s, ok := swingValueMap[v]; ok { - return s, nil - } - return SwingOff, nil + return d.swingMap.name(toInt(prop.Value)), nil } func (d *acDevice) SetTargetHumidity(ctx context.Context, pct int) error { @@ -439,6 +442,7 @@ type propField struct { siid, piid int name string fill func(*ACState, interface{}) + valType byte // 'b'=bool, 'i'=int, 'f'=float, 's'=string } func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) (string, error) { @@ -446,46 +450,81 @@ func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) ( 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 + // 每个属性精确订阅 siid/piid,避免 lumi.acpartner.mcn02 等设备 + // siid/piid 冲突时把风速 int 路由到 on 槽位 + var firstID string + for i, f := range fields { + idx := i + subID, err := d.SubProp(f.siid, f.piid, func(did string, prop *xiaomi.PropertyValue) { + // lumi.acpartner.mcn02 固件 bug:on 通道会收到 int 值,静默丢弃 + switch f.valType { + case 'b': + if _, ok := prop.Value.(bool); !ok { + return + } + case 's': + if _, ok := prop.Value.(string); !ok { + return + } } + fields[idx].fill(&d.stateCache, prop.Value) + handler(d.stateCache, f.name) + }) + if err != nil { + return "", err } - }) + if firstID == "" { + firstID = subID + } + } + return firstID, nil } func (d *acDevice) propFields() []propField { + r := d.getSpecResolver() var fields []propField - add := func(sp siidPiid, name string, fill func(*ACState, interface{})) { + add := func(sp siidPiid, name string, specType string, fill func(*ACState, interface{})) { if sp.valid { - fields = append(fields, propField{sp.siid, sp.piid, name, fill}) + vt := byte(0) + switch specType { + case "bool": + vt = 'b' + case "uint8", "uint16", "uint32", "int8", "int16", "int32": + vt = 'i' + case "float": + vt = 'f' + case "string": + vt = 's' + } + fields = append(fields, propField{sp.siid, sp.piid, name, fill, vt}) } } - 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.propOn, "on", specFormat(r, "on"), func(s *ACState, v interface{}) { s.On, _ = v.(bool) }) + add(d.propMode, "mode", specFormat(r, "mode"), func(s *ACState, v interface{}) { + s.Mode = d.modeMap.name(toInt(v)) }) - 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.propTargetTemp, "target-temperature", specFormat(r, "target-temperature"), func(s *ACState, v interface{}) { s.TargetTemperature = toFloat(v) }) + add(d.propCurrentTemp, "temperature", specFormat(r, "temperature"), func(s *ACState, v interface{}) { s.Temperature = toFloat(v) }) + add(d.propFanSpeed, "fan-level", specFormat(r, "fan-level"), func(s *ACState, v interface{}) { + s.FanLevel = d.fanMap.name(toInt(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.propSwing, "swing", specFormat(r, "swing-mode"), func(s *ACState, v interface{}) { + s.Swing = d.swingMap.name(toInt(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) }) + add(d.propTargetHumidity, "target-humidity", specFormat(r, "target-humidity"), func(s *ACState, v interface{}) { s.TargetHumidity = int(toFloat(v)) }) + add(d.propCurrentHumidity, "relative-humidity", specFormat(r, "relative-humidity"), func(s *ACState, v interface{}) { s.CurrentHumidity = int(toFloat(v)) }) + add(d.propAuxHeat, "heater", specFormat(r, "heater"), func(s *ACState, v interface{}) { s.AuxHeat, _ = v.(bool) }) + add(d.propEco, "eco", specFormat(r, "eco"), func(s *ACState, v interface{}) { s.Eco, _ = v.(bool) }) return fields } +func specFormat(r *specResolver, propType string) string { + if r == nil { + return "" + } + return r.format(propType) +} + // toFloat converts an interface{} to float64. func toFloat(v interface{}) float64 { switch n := v.(type) { @@ -500,3 +539,28 @@ func toFloat(v interface{}) float64 { } return 0 } + +func (d *acDevice) GetState() ACState { return d.stateCache } + +func (d *acDevice) FetchState(ctx context.Context) (ACState, error) { + fields := d.propFields() + keys := make([]xiaomi.PropKey, len(fields)) + for i, f := range fields { + keys[i] = xiaomi.PropKey{DID: d.info.DID, SIID: f.siid, PIID: f.piid} + } + props, err := d.client.GetProps(ctx, keys) + if err != nil { + return d.stateCache, err + } + state := ACState{} + for _, p := range props { + for _, f := range fields { + if f.siid == p.SIID && f.piid == p.PIID { + f.fill(&state, p.Value) + break + } + } + } + d.stateCache = state + return state, nil +} diff --git a/xiaomi/devices/base.go b/xiaomi/devices/base.go index 4581066..c931b04 100644 --- a/xiaomi/devices/base.go +++ b/xiaomi/devices/base.go @@ -167,3 +167,14 @@ func (r *specResolver) find(propType string) (siid, piid int, found bool) { } return 0, 0, false } + +// format returns the SPEC Format for a property type ("bool", "uint8", "float", "string"). +func (r *specResolver) format(propType string) string { + if r == nil { + return "" + } + if prop, ok := r.byType[propType]; ok { + return prop.Format + } + return "" +} diff --git a/xiaomi/devices/cover.go b/xiaomi/devices/cover.go index cbf8e04..262e207 100644 --- a/xiaomi/devices/cover.go +++ b/xiaomi/devices/cover.go @@ -16,6 +16,8 @@ type Cover interface { GetPosition(ctx context.Context) (int, error) OnStateChanged(handler func(position int)) (string, error) + GetState() int + FetchState(ctx context.Context) (int, error) } type coverDevice struct { @@ -81,3 +83,11 @@ func (d *coverDevice) OnStateChanged(handler func(position int)) (string, error) } }) } + +func (d *coverDevice) GetState() int { return d.stateCache } +func (d *coverDevice) FetchState(ctx context.Context) (int, error) { + pos, err := d.GetPosition(ctx) + if err != nil { return d.stateCache, err } + d.stateCache = pos + return pos, nil +} diff --git a/xiaomi/devices/devices_test.go b/xiaomi/devices/devices_test.go index 9cdd6c0..b5cba24 100644 --- a/xiaomi/devices/devices_test.go +++ b/xiaomi/devices/devices_test.go @@ -53,34 +53,20 @@ func TestToFloat(t *testing.T) { } func TestAirConditionerModeConstants(t *testing.T) { - // Verify all mode values have reverse mappings - for _, m := range []ACMode{ModeCool, ModeHeat, ModeFan, ModeDry, ModeAuto} { - if _, ok := modeReverseMap[m]; !ok { - t.Errorf("missing reverse mapping for %s", m) - } - } - - // Verify all value entries map back correctly - for v, m := range modeValueMap { - if r, ok := modeReverseMap[m]; !ok || r != v { - t.Errorf("modeReverseMap[modeValueMap[%d]] = %v, want %d", v, r, v) - } + if ACModeCool != "cool" || ACModeHeat != "heat" || ACModeDry != "dry" || ACModeAuto != "auto" { + t.Error("AC mode constants mismatch") } } func TestFanSpeedConstants(t *testing.T) { - for _, s := range []FanSpeed{SpeedLow, SpeedMedium, SpeedHigh, SpeedAuto} { - if _, ok := fanSpeedReverseMap[s]; !ok { - t.Errorf("missing reverse mapping for %s", s) - } + if ACFanLow != "low" || ACFanAuto != "auto" { + t.Error("Fan speed constants mismatch") } } func TestSwingModeConstants(t *testing.T) { - for _, s := range []SwingMode{SwingOff, SwingVertical, SwingHorizontal, SwingBoth} { - if _, ok := swingReverseMap[s]; !ok { - t.Errorf("missing reverse mapping for %s", s) - } + if ACSwingOff != "off" || ACSwingVertical != "vertical" { + t.Error("Swing mode constants mismatch") } } diff --git a/xiaomi/devices/factory.go b/xiaomi/devices/factory.go index 7b4123f..4fe8e50 100644 --- a/xiaomi/devices/factory.go +++ b/xiaomi/devices/factory.go @@ -87,6 +87,8 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er ctrl, err = NewOccupancySensor(client, info) case matchURN(urn, "sensor-temp", "sensor-humidity", "sensor-ht", "sensor-smoke", "sensor-gasm", "illumination"): ctrl, err = NewTempHumiditySensor(client, info) + case matchURN(urn, "speaker", "bluetooth-speaker", "alarm-clock", "media-player", "playback", "music"): + ctrl, err = NewSpeaker(client, info) default: // Not a supported device type — not an error, just returns nil return nil, nil diff --git a/xiaomi/devices/fan.go b/xiaomi/devices/fan.go index cfe7b4a..65de7f1 100644 --- a/xiaomi/devices/fan.go +++ b/xiaomi/devices/fan.go @@ -22,6 +22,8 @@ type Fan interface { IsNaturalWind(ctx context.Context) (bool, error) OnStateChanged(handler func(state FanState, changed string)) (string, error) + GetState() FanState + FetchState(ctx context.Context) (FanState, error) } // FanState 风扇完整状态(MQTT 缓存) @@ -193,3 +195,25 @@ func (d *fanDevice) OnStateChanged(handler func(state FanState, changed string)) } }) } + +func (d *fanDevice) GetState() FanState { return d.stateCache } +func (d *fanDevice) FetchState(ctx context.Context) (FanState, error) { + keys := []xiaomi.PropKey{} + if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) } + if d.propFanLevel.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propFanLevel.siid, PIID: d.propFanLevel.piid}) } + if d.propOscillation.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOscillation.siid, PIID: d.propOscillation.piid}) } + if d.propNaturalWind.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propNaturalWind.siid, PIID: d.propNaturalWind.piid}) } + props, err := d.client.GetProps(ctx, keys) + if err != nil { return d.stateCache, err } + state := FanState{} + for _, p := range props { + switch { + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool) + case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid: state.FanLevel = toInt(p.Value) + case d.propOscillation.valid && p.SIID == d.propOscillation.siid && p.PIID == d.propOscillation.piid: state.Oscillation, _ = p.Value.(bool) + case d.propNaturalWind.valid && p.SIID == d.propNaturalWind.siid && p.PIID == d.propNaturalWind.piid: state.NaturalWind = toInt(p.Value) == 1 + } + } + d.stateCache = state + return state, nil +} diff --git a/xiaomi/devices/humidifier.go b/xiaomi/devices/humidifier.go index 37bb7e3..5e586a6 100644 --- a/xiaomi/devices/humidifier.go +++ b/xiaomi/devices/humidifier.go @@ -17,6 +17,8 @@ type Humidifier interface { GetFanLevel(ctx context.Context) (int, error) OnStateChanged(handler func(state HumidifierState, changed string)) (string, error) + GetState() HumidifierState + FetchState(ctx context.Context) (HumidifierState, error) } // HumidifierState 加湿器状态(MQTT 缓存) @@ -105,3 +107,23 @@ func (d *humidifierDevice) OnStateChanged(handler func(state HumidifierState, ch } }) } + +func (d *humidifierDevice) GetState() HumidifierState { return d.stateCache } +func (d *humidifierDevice) FetchState(ctx context.Context) (HumidifierState, error) { + keys := []xiaomi.PropKey{} + if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) } + if d.propTargetHumidity.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTargetHumidity.siid, PIID: d.propTargetHumidity.piid}) } + if d.propFanLevel.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propFanLevel.siid, PIID: d.propFanLevel.piid}) } + props, err := d.client.GetProps(ctx, keys) + if err != nil { return d.stateCache, err } + state := HumidifierState{} + for _, p := range props { + switch { + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool) + case d.propTargetHumidity.valid && p.SIID == d.propTargetHumidity.siid && p.PIID == d.propTargetHumidity.piid: state.TargetHumidity = toInt(p.Value) + case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid: state.FanLevel = toInt(p.Value) + } + } + d.stateCache = state + return state, nil +} diff --git a/xiaomi/devices/light.go b/xiaomi/devices/light.go index ca374b0..376c027 100644 --- a/xiaomi/devices/light.go +++ b/xiaomi/devices/light.go @@ -30,6 +30,8 @@ type Light interface { GetColor(ctx context.Context) (r, g, b int, err error) OnStateChanged(handler func(state LightState, changed string)) (string, error) + GetState() LightState + FetchState(ctx context.Context) (LightState, error) } // LightState 灯光完整状态(字段名对齐 SPEC type) @@ -241,3 +243,26 @@ func toInt(v interface{}) int { } return 0 } + +func (d *lightDevice) GetState() LightState { return d.stateCache } +func (d *lightDevice) FetchState(ctx context.Context) (LightState, error) { + keys := []xiaomi.PropKey{} + if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) } + if d.propBrightness.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propBrightness.siid, PIID: d.propBrightness.piid}) } + if d.propColorTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propColorTemp.siid, PIID: d.propColorTemp.piid}) } + if d.propColor.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propColor.siid, PIID: d.propColor.piid}) } + props, err := d.client.GetProps(ctx, keys) + if err != nil { return d.stateCache, err } + state := LightState{} + for _, p := range props { + switch { + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool) + case d.propBrightness.valid && p.SIID == d.propBrightness.siid && p.PIID == d.propBrightness.piid: state.Brightness = toInt(p.Value) + case d.propColorTemp.valid && p.SIID == d.propColorTemp.siid && p.PIID == d.propColorTemp.piid: state.ColorTemperature = toInt(p.Value) + case d.propColor.valid && p.SIID == d.propColor.siid && p.PIID == d.propColor.piid: + if v, ok := p.Value.(float64); ok { c := int(v); state.ColorRGB = [3]int{(c>>16)&0xFF, (c>>8)&0xFF, c&0xFF} } + } + } + d.stateCache = state + return state, nil +} diff --git a/xiaomi/devices/occupancy_sensor.go b/xiaomi/devices/occupancy_sensor.go index 529045f..c1a5b9b 100644 --- a/xiaomi/devices/occupancy_sensor.go +++ b/xiaomi/devices/occupancy_sensor.go @@ -14,6 +14,8 @@ type OccupancySensor interface { GetIlluminance(ctx context.Context) (float64, error) OnStateChanged(handler func(occupied bool, illuminance float64)) (string, error) + GetState() (bool, float64) + FetchState(ctx context.Context) (bool, float64, error) } type occupancyDevice struct { @@ -71,3 +73,32 @@ func (d *occupancyDevice) OnStateChanged(handler func(occupied bool, illuminance handler(occ, lux) }) } + +func (d *occupancyDevice) GetState() (bool, float64) { + b, _ := d.stateCache[0].(bool) + f, _ := d.stateCache[1].(float64) + return b, f +} +func (d *occupancyDevice) FetchState(ctx context.Context) (bool, float64, error) { + keys := []xiaomi.PropKey{} + if d.propOccupancy.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOccupancy.siid, PIID: d.propOccupancy.piid}) } + if d.propIlluminance.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propIlluminance.siid, PIID: d.propIlluminance.piid}) } + props, err := d.client.GetProps(ctx, keys) + if err != nil { + b, _ := d.stateCache[0].(bool) + f, _ := d.stateCache[1].(float64) + return b, f, err + } + var occupied bool + var illuminance float64 + for _, p := range props { + switch { + case d.propOccupancy.valid && p.SIID == d.propOccupancy.siid && p.PIID == d.propOccupancy.piid: + occupied, _ = p.Value.(bool) + case d.propIlluminance.valid && p.SIID == d.propIlluminance.siid && p.PIID == d.propIlluminance.piid: + illuminance = toFloat(p.Value) + } + } + d.stateCache = [2]interface{}{occupied, illuminance} + return occupied, illuminance, nil +} diff --git a/xiaomi/devices/properties.go b/xiaomi/devices/properties.go new file mode 100644 index 0000000..db14010 --- /dev/null +++ b/xiaomi/devices/properties.go @@ -0,0 +1,45 @@ +package devices + +// ── 设备属性名常量 ── +// 用于 OnStateChanged 回调的 changed 字段,switch 前先用 deviceType 区分设备类型。 + +const ( + // ── 通用 ── + PropOn = "on" // 所有设备 + PropMode = "mode" // AC / Fan / Vacuum + PropSwing = "swing" // AC / Fan + PropFanLevel = "fan-level" // AC / Fan / Humidifier / Vacuum + PropTargetTemperature = "target-temperature" // AC / Thermostat / WaterHeater + PropTemperature = "temperature" // AC / Thermostat / WaterHeater + PropTargetHumidity = "target-humidity" // AC / Humidifier + PropRelativeHumidity = "relative-humidity" // AC + + // ── 空调专属 ── + PropHeater = "heater" + PropEco = "eco" + + // ── 灯光 ── + PropBrightness = "brightness" + PropColorTemperature = "color-temperature" + PropColor = "color" + + // ── 扫地机 ── + PropBattery = "battery" + + // ── 窗帘 ── + PropPosition = "position" + + // ── 传感器 ── + PropTemperatureHumidity = "temperature_humidity" + PropOccupancy = "occupancy" + + // ── 音箱 ── + PropVolume = "volume" + PropMute = "mute" + PropPlayState = "play-state" +) + +const ( + PropValueTrue = "true" + PropValueFalse = "false" +) diff --git a/xiaomi/devices/speaker.go b/xiaomi/devices/speaker.go new file mode 100644 index 0000000..ebb4ef1 --- /dev/null +++ b/xiaomi/devices/speaker.go @@ -0,0 +1,434 @@ +package devices + +import ( + "context" + "strings" + + "xiaomihome/miot" + "xiaomihome/xiaomi" +) + +// Speaker 音箱通用控制接口 +type Speaker interface { + Device + TurnOn(ctx context.Context) error + TurnOff(ctx context.Context) error + Toggle(ctx context.Context) error + + // 能力查询 + HasVolume() bool + HasMute() bool + HasPlayControl() bool + + // 音量 (0-100) + SetVolume(ctx context.Context, pct int) error + GetVolume(ctx context.Context) (int, error) + + // 静音 + SetMute(ctx context.Context, mute bool) error + GetMute(ctx context.Context) (bool, error) + + // 播放控制 (0=停止, 1=播放, 2=暂停) + SetPlayState(ctx context.Context, state int) error + GetPlayState(ctx context.Context) (int, error) + + // ── 智能音箱 (intelligent-speaker) ── + + // 睡眠模式 + HasSleepMode() bool + SetSleepMode(ctx context.Context, sleep bool) error + GetSleepMode(ctx context.Context) (bool, error) + + // 音频 ID (只读/通知) + GetAudioID(ctx context.Context) (string, error) + + // 动作 + WakeUp(ctx context.Context) error + PlayRadio(ctx context.Context) error + PlayText(ctx context.Context, text string) error + ExecuteTextDirective(ctx context.Context, text string, silent bool) error + PlayMusic(ctx context.Context) error + + OnStateChanged(handler func(state SpeakerState, changed string)) (string, error) + GetState() SpeakerState + FetchState(ctx context.Context) (SpeakerState, error) +} + +// SpeakerState 音箱完整状态(字段名对齐 SPEC type) +type SpeakerState struct { + On bool `json:"on"` // SPEC: "on" + Volume int `json:"volume"` // SPEC: "volume" + Mute bool `json:"mute"` // SPEC: "mute" + PlayState int `json:"playState"` // SPEC: "tds-in" or "playing-state" (0=stop,1=play,2=pause) + SleepMode bool `json:"sleepMode"` // SPEC: "sleep-mode" (intelligent-speaker) + AudioID string `json:"audioId"` // SPEC: "audio-id" (intelligent-speaker, read-only) +} + +// isAction 智能音箱动作 siid/aiid 对 +type isAction struct{ siid, aiid int } + +type speakerDevice struct { + BaseDevice + propOn siidPiid // "on" + propVolume siidPiid // "volume" + propMute siidPiid // "mute" + propPlayState siidPiid // "playing-state" or "tds-in" + propSleepMode siidPiid // "sleep-mode" (intelligent-speaker) + propAudioID siidPiid // "audio-id" (intelligent-speaker) + + // intelligent-speaker 服务 siid + isSiid int + + // 动作 + actWakeUp isAction + actPlayRadio isAction + actPlayText isAction + actExecuteTextDirective isAction + actPlayMusic isAction + + stateCache SpeakerState +} + +// NewSpeaker creates a Speaker control for the given device. +func NewSpeaker(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Speaker, error) { + d := &speakerDevice{ + BaseDevice: NewBaseDevice(client, info), + } + + dev := d.getMIoTDevice() + if dev == nil { + return nil, xiaomi.ErrNoSPEC + } + spec := dev.SpecInstance() + if spec == nil { + return nil, xiaomi.ErrNoSPEC + } + + // 定位 intelligent-speaker 服务 + var isSvc *miot.MIoTSpecService + for _, svc := range spec.Services { + if shortName(svc.Type) == "intelligent-speaker" { + isSvc = svc + d.isSiid = svc.IID + break + } + } + + // 先用 resolver 解析通用属性 + resolver := d.getSpecResolver() + if resolver == nil { + return nil, xiaomi.ErrNoSPEC + } + + siid, piid, ok := resolver.find("on") + if !ok { + return nil, xiaomi.ErrNotSupported + } + d.propOn = siidPiid{siid, piid, true} + + if s, p, ok := resolver.find("volume"); ok { + d.propVolume = siidPiid{s, p, true} + } + if s, p, ok := resolver.find("mute"); ok { + d.propMute = siidPiid{s, p, true} + } + if s, p, ok := resolver.find("playing-state"); ok { + d.propPlayState = siidPiid{s, p, true} + } else if s, p, ok := resolver.find("tds-in"); ok { + d.propPlayState = siidPiid{s, p, true} + } + + // intelligent-speaker 属性 + if isSvc != nil { + for _, p := range isSvc.Properties { + switch shortName(p.Type) { + case "sleep-mode": + d.propSleepMode = siidPiid{d.isSiid, p.PIID, true} + case "audio-id": + d.propAudioID = siidPiid{d.isSiid, p.PIID, true} + } + } + // 动作 + for _, a := range isSvc.Actions { + switch shortName(a.Type) { + case "wake-up": + d.actWakeUp = isAction{d.isSiid, a.AIID} + case "play-radio": + d.actPlayRadio = isAction{d.isSiid, a.AIID} + case "play-text": + d.actPlayText = isAction{d.isSiid, a.AIID} + case "execute-text-directive": + d.actExecuteTextDirective = isAction{d.isSiid, a.AIID} + case "play-music": + d.actPlayMusic = isAction{d.isSiid, a.AIID} + } + } + } + + return d, nil +} + +func (d *speakerDevice) TurnOn(ctx context.Context) error { + if !d.propOn.valid { + return xiaomi.ErrNotSupported + } + return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true) +} + +func (d *speakerDevice) TurnOff(ctx context.Context) error { + if !d.propOn.valid { + return xiaomi.ErrNotSupported + } + return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false) +} + +func (d *speakerDevice) Toggle(ctx context.Context) error { + s := d.GetState() + if s.On { + return d.TurnOff(ctx) + } + return d.TurnOn(ctx) +} + +func (d *speakerDevice) HasVolume() bool { return d.propVolume.valid } +func (d *speakerDevice) HasMute() bool { return d.propMute.valid } +func (d *speakerDevice) HasPlayControl() bool { return d.propPlayState.valid } + +func (d *speakerDevice) SetVolume(ctx context.Context, pct int) error { + if !d.propVolume.valid { + return xiaomi.ErrNotSupported + } + return d.SetProp(ctx, d.propVolume.siid, d.propVolume.piid, pct) +} + +func (d *speakerDevice) GetVolume(ctx context.Context) (int, error) { + if !d.propVolume.valid { + return 0, xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propVolume.siid, d.propVolume.piid) + if err != nil { + return 0, err + } + return toInt(prop.Value), nil +} + +func (d *speakerDevice) SetMute(ctx context.Context, mute bool) error { + if !d.propMute.valid { + return xiaomi.ErrNotSupported + } + return d.SetProp(ctx, d.propMute.siid, d.propMute.piid, mute) +} + +func (d *speakerDevice) GetMute(ctx context.Context) (bool, error) { + if !d.propMute.valid { + return false, xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propMute.siid, d.propMute.piid) + if err != nil { + return false, err + } + if b, ok := prop.Value.(bool); ok { + return b, nil + } + return false, nil +} + +func (d *speakerDevice) SetPlayState(ctx context.Context, state int) error { + if !d.propPlayState.valid { + return xiaomi.ErrNotSupported + } + return d.SetProp(ctx, d.propPlayState.siid, d.propPlayState.piid, state) +} + +func (d *speakerDevice) GetPlayState(ctx context.Context) (int, error) { + if !d.propPlayState.valid { + return 0, xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propPlayState.siid, d.propPlayState.piid) + if err != nil { + return 0, err + } + return toInt(prop.Value), nil +} + +// ── 智能音箱属性 ── + +func (d *speakerDevice) HasSleepMode() bool { return d.propSleepMode.valid } + +func (d *speakerDevice) SetSleepMode(ctx context.Context, sleep bool) error { + if !d.propSleepMode.valid { + return xiaomi.ErrNotSupported + } + return d.SetProp(ctx, d.propSleepMode.siid, d.propSleepMode.piid, sleep) +} + +func (d *speakerDevice) GetSleepMode(ctx context.Context) (bool, error) { + if !d.propSleepMode.valid { + return false, xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propSleepMode.siid, d.propSleepMode.piid) + if err != nil { + return false, err + } + if b, ok := prop.Value.(bool); ok { + return b, nil + } + return false, nil +} + +func (d *speakerDevice) GetAudioID(ctx context.Context) (string, error) { + if !d.propAudioID.valid { + return "", xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propAudioID.siid, d.propAudioID.piid) + if err != nil { + return "", err + } + if s, ok := prop.Value.(string); ok { + return s, nil + } + return "", nil +} + +// ── 智能音箱动作 ── + +func (d *speakerDevice) WakeUp(ctx context.Context) error { + if d.actWakeUp.siid == 0 { + return xiaomi.ErrNotSupported + } + _, err := d.Action(ctx, d.actWakeUp.siid, d.actWakeUp.aiid, nil) + return err +} + +func (d *speakerDevice) PlayRadio(ctx context.Context) error { + if d.actPlayRadio.siid == 0 { + return xiaomi.ErrNotSupported + } + _, err := d.Action(ctx, d.actPlayRadio.siid, d.actPlayRadio.aiid, nil) + return err +} + +func (d *speakerDevice) PlayText(ctx context.Context, text string) error { + if d.actPlayText.siid == 0 { + return xiaomi.ErrNotSupported + } + _, err := d.Action(ctx, d.actPlayText.siid, d.actPlayText.aiid, []interface{}{ + map[string]interface{}{"piid": 1, "value": text}, + }) + return err +} + +func (d *speakerDevice) ExecuteTextDirective(ctx context.Context, text string, silent bool) error { + if d.actExecuteTextDirective.siid == 0 { + return xiaomi.ErrNotSupported + } + _, err := d.Action(ctx, d.actExecuteTextDirective.siid, d.actExecuteTextDirective.aiid, []interface{}{ + map[string]interface{}{"piid": 1, "value": text}, + map[string]interface{}{"piid": 2, "value": silent}, + }) + return err +} + +func (d *speakerDevice) PlayMusic(ctx context.Context) error { + if d.actPlayMusic.siid == 0 { + return xiaomi.ErrNotSupported + } + _, err := d.Action(ctx, d.actPlayMusic.siid, d.actPlayMusic.aiid, nil) + return err +} + +// ── 订阅 / 状态 ── + +func (d *speakerDevice) OnStateChanged(handler func(state SpeakerState, changed string)) (string, error) { + type field struct { + siid, piid int + name string + fill func(*SpeakerState, interface{}) + } + var fields []field + add := func(sp siidPiid, name string, fill func(*SpeakerState, interface{})) { + if sp.valid { + fields = append(fields, field{sp.siid, sp.piid, name, fill}) + } + } + add(d.propOn, "on", func(s *SpeakerState, v interface{}) { s.On, _ = v.(bool) }) + add(d.propVolume, "volume", func(s *SpeakerState, v interface{}) { s.Volume = toInt(v) }) + add(d.propMute, "mute", func(s *SpeakerState, v interface{}) { s.Mute, _ = v.(bool) }) + add(d.propPlayState, "play-state", func(s *SpeakerState, v interface{}) { s.PlayState = toInt(v) }) + add(d.propSleepMode, "sleep-mode", func(s *SpeakerState, v interface{}) { s.SleepMode, _ = v.(bool) }) + add(d.propAudioID, "audio-id", func(s *SpeakerState, v interface{}) { + if str, ok := v.(string); ok { + s.AudioID = str + } + }) + + 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 + } + } + }) +} + +func (d *speakerDevice) GetState() SpeakerState { return d.stateCache } + +func (d *speakerDevice) FetchState(ctx context.Context) (SpeakerState, 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.propVolume) + add(d.propMute) + add(d.propPlayState) + add(d.propSleepMode) + add(d.propAudioID) + + props, err := d.client.GetProps(ctx, keys) + if err != nil { + return d.stateCache, err + } + state := SpeakerState{} + for _, p := range props { + switch { + case match(d.propOn, p): + state.On, _ = p.Value.(bool) + case match(d.propVolume, p): + state.Volume = toInt(p.Value) + case match(d.propMute, p): + state.Mute, _ = p.Value.(bool) + case match(d.propPlayState, p): + state.PlayState = toInt(p.Value) + case match(d.propSleepMode, p): + state.SleepMode, _ = p.Value.(bool) + case match(d.propAudioID, p): + if s, ok := p.Value.(string); ok { + state.AudioID = s + } + } + } + d.stateCache = state + return state, nil +} + +func match(sp siidPiid, p *xiaomi.PropertyValue) bool { + return sp.valid && p.SIID == sp.siid && p.PIID == sp.piid +} + +// shortName extracts the short name from a SPEC URN. +// E.g., "urn:miot-spec-v2:action:wake-up:..." → "wake-up". +func shortName(urn string) string { + parts := strings.Split(urn, ":") + if len(parts) >= 4 { + return parts[3] + } + return urn +} diff --git a/xiaomi/devices/switch.go b/xiaomi/devices/switch.go index 011aadc..208cee0 100644 --- a/xiaomi/devices/switch.go +++ b/xiaomi/devices/switch.go @@ -24,6 +24,8 @@ type Switch interface { IsChannelOn(ctx context.Context, ch int) (bool, error) OnStateChanged(handler func(ch int, on bool)) (string, error) + GetState() []bool + FetchState(ctx context.Context) ([]bool, error) } // switchDevice is the concrete Switch implementation. @@ -161,3 +163,24 @@ func (d *switchDevice) channel(ch int) (siidPiid, bool) { } return d.channels[i], true } + +func (d *switchDevice) GetState() []bool { return d.stateCache } +func (d *switchDevice) FetchState(ctx context.Context) ([]bool, error) { + keys := make([]xiaomi.PropKey, len(d.channels)) + for i, c := range d.channels { + keys[i] = xiaomi.PropKey{DID: d.info.DID, SIID: c.siid, PIID: c.piid} + } + props, err := d.client.GetProps(ctx, keys) + if err != nil { return d.stateCache, err } + states := make([]bool, len(d.channels)) + for i, c := range d.channels { + for _, p := range props { + if p.SIID == c.siid && p.PIID == c.piid { + states[i], _ = p.Value.(bool) + break + } + } + } + d.stateCache = states + return states, nil +} diff --git a/xiaomi/devices/temp_humidity_sensor.go b/xiaomi/devices/temp_humidity_sensor.go index b49fb27..0965c68 100644 --- a/xiaomi/devices/temp_humidity_sensor.go +++ b/xiaomi/devices/temp_humidity_sensor.go @@ -14,6 +14,8 @@ type TempHumiditySensor interface { GetHumidity(ctx context.Context) (float64, error) OnStateChanged(handler func(temp, humidity float64)) (string, error) + GetState() (float64, float64) + FetchState(ctx context.Context) (float64, float64, error) } type tempHumidityDevice struct { @@ -71,3 +73,21 @@ func (d *tempHumidityDevice) OnStateChanged(handler func(temp, humidity float64) handler(d.stateCache[0], d.stateCache[1]) }) } + +func (d *tempHumidityDevice) GetState() (float64, float64) { return d.stateCache[0], d.stateCache[1] } +func (d *tempHumidityDevice) FetchState(ctx context.Context) (float64, float64, error) { + keys := []xiaomi.PropKey{} + if d.propTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTemp.siid, PIID: d.propTemp.piid}) } + if d.propHumidity.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propHumidity.siid, PIID: d.propHumidity.piid}) } + props, err := d.client.GetProps(ctx, keys) + if err != nil { return d.stateCache[0], d.stateCache[1], err } + state := [2]float64{d.stateCache[0], d.stateCache[1]} + for _, p := range props { + switch { + case d.propTemp.valid && p.SIID == d.propTemp.siid && p.PIID == d.propTemp.piid: state[0] = toFloat(p.Value) + case d.propHumidity.valid && p.SIID == d.propHumidity.siid && p.PIID == d.propHumidity.piid: state[1] = toFloat(p.Value) + } + } + d.stateCache = state + return state[0], state[1], nil +} diff --git a/xiaomi/devices/thermostat.go b/xiaomi/devices/thermostat.go index 2f72dc4..e731d84 100644 --- a/xiaomi/devices/thermostat.go +++ b/xiaomi/devices/thermostat.go @@ -18,6 +18,8 @@ type Thermostat interface { IsHeating(ctx context.Context) (bool, error) OnStateChanged(handler func(state HeaterState, changed string)) (string, error) + GetState() HeaterState + FetchState(ctx context.Context) (HeaterState, error) } type thermostatDevice struct { @@ -108,3 +110,23 @@ func (d *thermostatDevice) OnStateChanged(handler func(state HeaterState, change } }) } + +func (d *thermostatDevice) GetState() HeaterState { return d.stateCache } +func (d *thermostatDevice) FetchState(ctx context.Context) (HeaterState, error) { + keys := []xiaomi.PropKey{} + if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) } + if d.propTargetTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTargetTemp.siid, PIID: d.propTargetTemp.piid}) } + if d.propCurrentTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propCurrentTemp.siid, PIID: d.propCurrentTemp.piid}) } + props, err := d.client.GetProps(ctx, keys) + if err != nil { return d.stateCache, err } + state := HeaterState{} + for _, p := range props { + switch { + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool) + case d.propTargetTemp.valid && p.SIID == d.propTargetTemp.siid && p.PIID == d.propTargetTemp.piid: state.TargetTemp = toFloat(p.Value) + case d.propCurrentTemp.valid && p.SIID == d.propCurrentTemp.siid && p.PIID == d.propCurrentTemp.piid: state.CurrentTemp = toFloat(p.Value) + } + } + d.stateCache = state + return state, nil +} diff --git a/xiaomi/devices/vacuum.go b/xiaomi/devices/vacuum.go index 2499636..c8bcd31 100644 --- a/xiaomi/devices/vacuum.go +++ b/xiaomi/devices/vacuum.go @@ -16,6 +16,8 @@ type Vacuum interface { GetStatus(ctx context.Context) (VacuumStatus, error) OnStateChanged(handler func(state VacuumStatus, changed string)) (string, error) + GetState() VacuumStatus + FetchState(ctx context.Context) (VacuumStatus, error) } // VacuumStatus 扫地机运行状态(字段名对齐 SPEC type) @@ -121,3 +123,11 @@ func (d *vacuumDevice) OnStateChanged(handler func(state VacuumStatus, changed s } }) } + +func (d *vacuumDevice) GetState() VacuumStatus { return d.stateCache } +func (d *vacuumDevice) FetchState(ctx context.Context) (VacuumStatus, error) { + status, err := d.GetStatus(ctx) + if err != nil { return d.stateCache, err } + d.stateCache = status + return status, nil +} diff --git a/xiaomi/devices/water_heater.go b/xiaomi/devices/water_heater.go index 9590c43..b52643b 100644 --- a/xiaomi/devices/water_heater.go +++ b/xiaomi/devices/water_heater.go @@ -16,6 +16,8 @@ type WaterHeater interface { GetCurrentTemp(ctx context.Context) (float64, error) OnStateChanged(handler func(state HeaterState, changed string)) (string, error) + GetState() HeaterState + FetchState(ctx context.Context) (HeaterState, error) } // HeaterState 热水器状态(MQTT 缓存) @@ -100,3 +102,23 @@ func (d *waterHeaterDevice) OnStateChanged(handler func(state HeaterState, chang } }) } + +func (d *waterHeaterDevice) GetState() HeaterState { return d.stateCache } +func (d *waterHeaterDevice) FetchState(ctx context.Context) (HeaterState, error) { + keys := []xiaomi.PropKey{} + if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) } + if d.propTargetTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTargetTemp.siid, PIID: d.propTargetTemp.piid}) } + if d.propCurrentTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propCurrentTemp.siid, PIID: d.propCurrentTemp.piid}) } + props, err := d.client.GetProps(ctx, keys) + if err != nil { return d.stateCache, err } + state := HeaterState{} + for _, p := range props { + switch { + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool) + case d.propTargetTemp.valid && p.SIID == d.propTargetTemp.siid && p.PIID == d.propTargetTemp.piid: state.TargetTemp = toFloat(p.Value) + case d.propCurrentTemp.valid && p.SIID == d.propCurrentTemp.siid && p.PIID == d.propCurrentTemp.piid: state.CurrentTemp = toFloat(p.Value) + } + } + d.stateCache = state + return state, nil +} diff --git a/xiaomi/examples/01_user_home/main.go b/xiaomi/examples/01_user_home/main.go index 92b75af..ffa5ab1 100644 --- a/xiaomi/examples/01_user_home/main.go +++ b/xiaomi/examples/01_user_home/main.go @@ -24,7 +24,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", @@ -49,7 +49,7 @@ func main() { log.Printf("共 %d 个家庭", len(homes)) for _, home := range homes { - log.Printf(" 家庭: %s (ID: %s)", home.Name, home.ID) + log.Printf(" 家庭: %s (ID: %d)", home.Name, home.ID) log.Printf(" 位置: UID=%s GroupID=%s", home.UID, home.GroupID) log.Printf(" 分享状态: %v", home.ShareState) @@ -61,7 +61,7 @@ func main() { log.Printf(" 共 %d 个房间", len(rooms)) for _, room := range rooms { - log.Printf(" 房间: %s (ID: %s)", room.Name, room.ID) + log.Printf(" 房间: %s (ID: %d)", room.Name, room.ID) didList, err := client.GetRoomDevices(ctx, home.ID, room.ID) if err != nil { log.Printf(" 获取设备列表失败: %v", err) diff --git a/xiaomi/examples/02_device_list/main.go b/xiaomi/examples/02_device_list/main.go index 40c60d0..cafdf3b 100644 --- a/xiaomi/examples/02_device_list/main.go +++ b/xiaomi/examples/02_device_list/main.go @@ -24,7 +24,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/03_switch_control/main.go b/xiaomi/examples/03_switch_control/main.go index b83dfb5..c491406 100644 --- a/xiaomi/examples/03_switch_control/main.go +++ b/xiaomi/examples/03_switch_control/main.go @@ -25,7 +25,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/04_light_control/main.go b/xiaomi/examples/04_light_control/main.go index be5d080..2a509be 100644 --- a/xiaomi/examples/04_light_control/main.go +++ b/xiaomi/examples/04_light_control/main.go @@ -25,7 +25,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/05_ac_control/data/miot_devices/2897121707_cn.dict b/xiaomi/examples/05_ac_control/data/miot_devices/2897121707_cn.dict index 2d976b9..685b8a7 100644 --- a/xiaomi/examples/05_ac_control/data/miot_devices/2897121707_cn.dict +++ b/xiaomi/examples/05_ac_control/data/miot_devices/2897121707_cn.dict @@ -1 +1 @@ -{"1129268329":{"bssid":"","connect_type":16,"did":"1129268329","fw_version":"2.1.2_0001","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"ftd","model":"ftd.light.nomain","name":"智能节律无主灯","online":true,"order_time":1779508542,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"2fd915a1c31b918381a0999addc795f9","uid":2897121707,"urn":"urn:miot-spec-v2:device:light:0000A001:ftd-nomain:1:0000C802","voice_ctrl":null},"1159941441":{"bssid":"","connect_type":16,"did":"1159941441","fw_version":"2.1.2_0009","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lemesh","model":"lemesh.switch.sw0a04","name":"情景Mesh通断器V2S系列","online":true,"order_time":1780971541,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"0bf56af2755b1be9923113ed0e4aaf85","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:lemesh-sw0a04:1:0000C808","voice_ctrl":null},"1184737843":{"bssid":"B2:99:5F:16:5C:BC","connect_type":0,"did":"1184737843","fw_version":"3.5.0_0011","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.gateway.hub1","name":"Xiaomi 中枢网关","online":true,"order_time":1779421777,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866347","room_name":"吧台","rssi":null,"ssid":null,"token":"6c434376314c47453864544d63634f7a","uid":2897121707,"urn":"urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:3","voice_ctrl":null},"2007546048":{"bssid":"36:47:D4:7C:F9:56","connect_type":0,"did":"2007546048","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lxzn","model":"lxzn.switch.cbcsmj","name":"Wi-Fi智能通断器L1","online":true,"order_time":1780196382,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-58,"ssid":"ChinaNet-4edy-WiFi5","token":"d70f63f63fab5b1868477c1112a0811b","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:lxzn-cbcsmj:1:0000D00D","voice_ctrl":null},"2008856387":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2008856387","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lxzn","model":"lxzn.switch.cbcsmj","name":"Wi-Fi智能通断器L1 2","online":true,"order_time":1780474367,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-34,"ssid":"ChinaNet-a66D","token":"d515d5cc056978c0e23af87e39d10035","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:lxzn-cbcsmj:1:0000D00D","voice_ctrl":null},"2028696446":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2028696446","fw_version":"1.1.3","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"cuco","model":"cuco.plug.v3","name":"米家智能插座3 2","online":true,"order_time":1781878933,"owner":null,"parent_id":null,"pid":0,"room_id":"984001858994","room_name":"客厅","rssi":-37,"ssid":"ChinaNet-a66D","token":"ec40e0644421aab5766464913b9d9361","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:2","voice_ctrl":null},"2051212737":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"2051212737","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cuco","model":"cuco.plug.v3","name":"米家智能插座3","online":true,"order_time":1781847100,"owner":null,"parent_id":null,"pid":0,"room_id":"984001631362","room_name":"网吧1","rssi":-55,"ssid":"ChinaNet-4edy","token":"73b0bb97bf79592f4900b3d2eb40f0c3","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:2","voice_ctrl":null},"2053225728":{"bssid":"28:D1:27:56:2F:31","connect_type":0,"did":"2053225728","fw_version":"1.1.3","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"sixwgh","model":"sixwgh.acpartner.ym01","name":"文光汇空调伴侣(插座款)","online":true,"order_time":1781350176,"owner":null,"parent_id":null,"pid":0,"room_id":"984001863128","room_name":"客厅","rssi":-36,"ssid":"Redmi_3D9E","token":"ec9d5db36676b64c18dcba94bed5c181","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-condition-outlet:0000A045:sixwgh-ym01:1:0000D01F","voice_ctrl":null},"2055348330":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"2055348330","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cnhdm","model":"cnhdm.airrtc.wkq01","name":"CNHDM温控器168M(WIFI)","online":true,"order_time":1780195591,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-32,"ssid":"ChinaNet-4edy","token":"b0e1efae15d97f29dec443eceaf58171","uid":2897121707,"urn":"urn:miot-spec-v2:device:thermostat:0000A031:cnhdm-wkq01:1","voice_ctrl":null},"2079251478":{"bssid":"","connect_type":16,"did":"2079251478","fw_version":"3.0.2_0020","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lemesh","model":"lemesh.light.wy0d02","name":"情景Lemesh色温灯Ultra","online":true,"order_time":1780490114,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"7d184426d79c45dd40ef06c59618ba61","uid":2897121707,"urn":"urn:miot-spec-v2:device:light:0000A001:lemesh-wy0d02:1:0000C802","voice_ctrl":null},"2079798139":{"bssid":"","connect_type":16,"did":"2079798139","fw_version":"3.0.2_0021","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"tofan","model":"tofan.airc.air","name":"YOOMI空调控制器MESH2.0","online":true,"order_time":1780727424,"owner":null,"parent_id":null,"pid":16,"room_id":"984001863128","room_name":"客厅","rssi":null,"ssid":null,"token":"0d87313d4faa13b52a77134eaf537cf4","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:tofan-air:1:0000C81A","voice_ctrl":null},"2080795393":{"bssid":"","connect_type":16,"did":"2080795393","fw_version":"3.0.2_0142","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"智能三键开关S2-Mesh版","online":true,"order_time":1779535396,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"sub_devices":{"s2":{"bssid":"","connect_type":21,"did":"2080795393.s2","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"左键-智能三键开关S2-Mesh版","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001856695","room_name":"双1","rssi":null,"ssid":null,"token":"CHd3Iw09Zyyp1VxT","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null},"s3":{"bssid":"","connect_type":21,"did":"2080795393.s3","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"中键-智能三键开关S2-Mesh版","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001856695","room_name":"双1","rssi":null,"ssid":null,"token":"AqZReEqV4scSU1vE","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null},"s4":{"bssid":"","connect_type":21,"did":"2080795393.s4","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"右键-智能三键开关S2-Mesh版","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001856695","room_name":"双1","rssi":null,"ssid":null,"token":"PdQTMF0gZ8RrcRXx","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null}},"token":"9d32790415007a09f954b3c64333d4d4","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null},"2114993879":{"bssid":"","connect_type":16,"did":"2114993879","fw_version":"3.0.2_0004","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cddz","model":"cddz.plug.pc04m","name":"Jiuhao电脑开机卡Pro (Mesh2.0)","online":false,"order_time":1781077978,"owner":null,"parent_id":null,"pid":16,"room_id":"984001648465","room_name":"大厅","rssi":null,"ssid":null,"token":"01f2f084235a9cbed656928448a258c9","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cddz-pc04m:1:0000D030","voice_ctrl":null},"2116801639":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2116801639","fw_version":"1.0.7","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.wifispeaker.l05b","name":"小爱音箱Play 2","online":true,"order_time":1780972416,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-18,"ssid":"ChinaNet-a66D","token":"e7cce2e23f83068e269989320c8ebb8c","uid":2897121707,"urn":"urn:miot-spec-v2:device:speaker:0000A015:xiaomi-l05b:1","voice_ctrl":2},"2116896390":{"bssid":"28:D1:27:56:2F:31","connect_type":0,"did":"2116896390","fw_version":"1.0.7","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.wifispeaker.l05b","name":"小爱音箱Play","online":true,"order_time":1782378581,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866120","room_name":"电脑秀华","rssi":-27,"ssid":"Redmi_3D9E","token":"4201cd571bc3d241f77602c900fa3f40","uid":2897121707,"urn":"urn:miot-spec-v2:device:speaker:0000A015:xiaomi-l05b:1","voice_ctrl":2},"2143830512":{"bssid":"","connect_type":16,"did":"2143830512","fw_version":"3.0.2_0004","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cddz","model":"cddz.plug.pc04m","name":"Jiuhao电脑开机卡Pro (Mesh2.0) 2","online":true,"order_time":1781325304,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"85a2d96e9777a567e13fae551843991c","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cddz-pc04m:1:0000D030","voice_ctrl":null},"2151935809":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2151935809","fw_version":"1.0.9","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"iot","model":"iot.switch.padw2p","name":"浦安达单相智能保护开关","online":true,"order_time":1780020873,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-35,"ssid":"ChinaNet-a66D","token":"e7e425956216ba813eebf9351c82d2c1","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:iot-padw2p:2:0000D00D","voice_ctrl":null},"2158300521":{"bssid":"","connect_type":16,"did":"2158300521","fw_version":"3.0.2_0139","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"czmydz","model":"czmydz.curtain.mym1","name":"美百帘窗帘电机(mesh2.0)","online":true,"order_time":1780924552,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"f74b21ccceef624ef1bb09d01a991f7d","uid":2897121707,"urn":"urn:miot-spec-v2:device:curtain:0000A00C:czmydz-mym1:1:0000D031","voice_ctrl":null},"2169700407":{"bssid":"","connect_type":16,"did":"2169700407","fw_version":"3.0.2_0001","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"zhunen","model":"zhunen.light.wy0a01","name":"烛照智能灯带Ultra2.0","online":true,"order_time":1779422539,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"6a2669fdffc6b50e31f6e4859107fcfc","uid":2897121707,"urn":"urn:miot-spec-v2:device:light:0000A001:zhunen-wy0a01:1:0000C802","voice_ctrl":null},"2170429382":{"bssid":"","connect_type":16,"did":"2170429382","fw_version":"3.0.2_0010","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"树色四路通断器 (Mesh2.0)","online":true,"order_time":1780909772,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"sub_devices":{"s10":{"bssid":"","connect_type":21,"did":"2170429382.s10","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键4-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"xXHaF2Z5t1GOewIu","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"s2":{"bssid":"","connect_type":21,"did":"2170429382.s2","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键1-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"GNc48QK0H3VQw3VX","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"s3":{"bssid":"","connect_type":21,"did":"2170429382.s3","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键2-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"ZROacxVBMCM2SRcU","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"s4":{"bssid":"","connect_type":21,"did":"2170429382.s4","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键3-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"DIKVPuU395UZwSP8","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null}},"token":"7817f9f7bcd997721464b9499902f1d5","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"753812859":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"753812859","fw_version":"2.1.8_0016","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"lumi","model":"lumi.acpartner.mcn02","name":"米家空调伴侣2","online":true,"order_time":1726037868,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866119","room_name":"卧室","rssi":-38,"ssid":"ChinaNet-4edy","token":"12cc118d45006bcfdced60ca0881de32","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:lumi-mcn02:1","voice_ctrl":null},"820867763":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"820867763","fw_version":"1.0.9","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"jipin","model":"jipin.fan.f003","name":"即品智能互联空气循环扇 2","online":true,"order_time":1726884077,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866120","room_name":"电脑秀华","rssi":-60,"ssid":"ChinaNet-4edy","token":"05c2ff96fc9ca1b6cd59b0d251687129","uid":2897121707,"urn":"urn:miot-spec-v2:device:fan:0000A005:jipin-f003:1:0000D062","voice_ctrl":null},"820891929":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"820891929","fw_version":"1.0.9","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"jipin","model":"jipin.fan.f003","name":"即品智能互联空气循环扇","online":true,"order_time":1725951110,"owner":null,"parent_id":null,"pid":0,"room_id":"984001858995","room_name":"电脑溶溶","rssi":-41,"ssid":"ChinaNet-4edy","token":"470326b636759a483f51b994f4ee8caa","uid":2897121707,"urn":"urn:miot-spec-v2:device:fan:0000A005:jipin-f003:1:0000D062","voice_ctrl":null},"824262964":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"824262964","fw_version":"1.0.9","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"jipin","model":"jipin.fan.f003","name":"即品智能互联空气循环扇 3","online":true,"order_time":1752060407,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866119","room_name":"卧室","rssi":-40,"ssid":"ChinaNet-4edy","token":"4df831319bc4373d739c44d762c71f2a","uid":2897121707,"urn":"urn:miot-spec-v2:device:fan:0000A005:jipin-f003:1:0000D062","voice_ctrl":null},"920488072":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"920488072","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.gateway.of32","name":"OF VRF中央空调网关(Ultra版)","online":true,"order_time":1780303441,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866347","room_name":"吧台","rssi":-27,"ssid":"ChinaNet-a66D","token":"3bf8c4824d8b27d4e21871028034d776","uid":2897121707,"urn":"urn:miot-spec-v2:device:gateway:0000A019:090615-of32:1","voice_ctrl":null},"921620007":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"921620007","fw_version":"2.2.9","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"qdhkl","model":"qdhkl.aircondition.b27","name":"中弘空调室内机控制器(Wi-Fi版)","online":true,"order_time":1780925803,"owner":null,"parent_id":null,"pid":0,"room_id":"984001863128","room_name":"客厅","rssi":-31,"ssid":"ChinaNet-a66D","token":"948ce1bac2979066f3d1f50117400281","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:qdhkl-b27:1","voice_ctrl":null},"977094960":{"bssid":"28:D1:27:56:2F:31","connect_type":0,"did":"977094960","fw_version":"2.1.8_0016","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lumi","model":"lumi.acpartner.mcn02","name":"空调插座","online":true,"order_time":1779334210,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861943","room_name":"双2","rssi":-39,"ssid":"Redmi_3D9E","token":"ac0238a3d12be06cb87e471f5ad77778","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:lumi-mcn02:1","voice_ctrl":null},"blt.3.1onoenueccc02":{"bssid":"","connect_type":6,"did":"blt.3.1onoenueccc02","fw_version":"2.1.1_0017","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.airm.ss006","name":"树色空气检测仪(BLE)","online":true,"order_time":1779507685,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"b1f7f0c505493b0e2d818c1a","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-monitor:0000A008:shuse-ss006:1","voice_ctrl":null},"blt.3.1p3ii8ovh0k00":{"bssid":"","connect_type":6,"did":"blt.3.1p3ii8ovh0k00","fw_version":"2.1.1_0009","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_ht.mini","name":"米家智能温湿度计3 mini","online":true,"order_time":1779422339,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866120","room_name":"电脑秀华","rssi":null,"ssid":null,"token":"5232a733493f0bfceff00b00","uid":2897121707,"urn":"urn:miot-spec-v2:device:temperature-humidity-sensor:0000A00A:xiaomi-mini:1:0000D063","voice_ctrl":null},"blt.3.1p3ijnm784000":{"bssid":"","connect_type":6,"did":"blt.3.1p3ijnm784000","fw_version":"2.0.1_0104","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器","online":true,"order_time":1779422723,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"6cedc03deb6dbfe3326823f9","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1p3ikilns4803":{"bssid":"","connect_type":6,"did":"blt.3.1p3ikilns4803","fw_version":"1.1.1_0010","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lumi","model":"lumi.sensor_smoke.mcn02","name":"小米烟感卫士","online":true,"order_time":1779422944,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"c1c217e008673657e85d0084","uid":2897121707,"urn":"urn:miot-spec-v2:device:smoke-sensor:0000A023:lumi-mcn02:1","voice_ctrl":null},"blt.3.1p3iusa9h0g00":{"bssid":"","connect_type":6,"did":"blt.3.1p3iusa9h0g00","fw_version":"2.1.1_0009","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_ht.mini","name":"米家智能温湿度计3 mini 2","online":true,"order_time":1779425644,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866119","room_name":"卧室","rssi":null,"ssid":null,"token":"06d872869aed96b4006945df","uid":2897121707,"urn":"urn:miot-spec-v2:device:temperature-humidity-sensor:0000A00A:xiaomi-mini:1:0000D063","voice_ctrl":null},"blt.3.1p3ivcecp0800":{"bssid":"","connect_type":6,"did":"blt.3.1p3ivcecp0800","fw_version":"2.1.1_0009","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_ht.mini","name":"米家智能温湿度计3 mini 3","online":true,"order_time":1779425776,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"9c8a9f67ee1e72563b297e5b","uid":2897121707,"urn":"urn:miot-spec-v2:device:temperature-humidity-sensor:0000A00A:xiaomi-mini:1:0000D063","voice_ctrl":null},"blt.3.1p7qnaapp0k00":{"bssid":"","connect_type":6,"did":"blt.3.1p7qnaapp0k00","fw_version":"","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器 2","online":true,"order_time":1780564513,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866120","room_name":"电脑秀华","rssi":null,"ssid":null,"token":"4d98d18fb0e902cf577ceefa","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1p7qnto9t0o00":{"bssid":"","connect_type":6,"did":"blt.3.1p7qnto9t0o00","fw_version":"","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器 3","online":true,"order_time":1780564672,"owner":null,"parent_id":null,"pid":6,"room_id":"984001858995","room_name":"电脑溶溶","rssi":null,"ssid":null,"token":"0ee9e6c46605e119de42f7a7","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1p7qqkcj44o05":{"bssid":"","connect_type":6,"did":"blt.3.1p7qqkcj44o05","fw_version":"","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器 4","online":true,"order_time":1780565382,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866119","room_name":"卧室","rssi":null,"ssid":null,"token":"b715e79e2dcfce5fb74620a9","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1pavgk0bgcc01":{"bssid":"","connect_type":6,"did":"blt.3.1pavgk0bgcc01","fw_version":"2.1.1_0031","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"linp","model":"linp.motion.hs1bb2","name":"领普人体传感器3","online":true,"order_time":1781410006,"owner":null,"parent_id":null,"pid":6,"room_id":"984001858994","room_name":"客厅","rssi":null,"ssid":null,"token":"02c829768a051a55cd21313f","uid":2897121707,"urn":"urn:miot-spec-v2:device:motion-sensor:0000A014:linp-hs1bb2:1","voice_ctrl":null},"proxy.920488072.00001":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00001","fw_version":"2.2.6_0003","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机","online":true,"order_time":1780303459,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001863128","room_name":"客厅","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00002":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00002","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 2","online":true,"order_time":1780303459,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00003":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00003","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 3","online":true,"order_time":1780303460,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00004":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00004","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 4","online":true,"order_time":1780303460,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00005":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00005","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 6","online":true,"order_time":1780303461,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00006":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00006","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 5","online":true,"order_time":1780303461,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null}}"’Jr7A˜MNYp%+;ëľìs±›ööwffÃË \ No newline at end of file +{"1129268329":{"bssid":"","connect_type":16,"did":"1129268329","fw_version":"2.1.2_0001","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"ftd","model":"ftd.light.nomain","name":"智能节律无主灯","online":true,"order_time":1779508542,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"2fd915a1c31b918381a0999addc795f9","uid":2897121707,"urn":"urn:miot-spec-v2:device:light:0000A001:ftd-nomain:1:0000C802","voice_ctrl":null},"1159941441":{"bssid":"","connect_type":16,"did":"1159941441","fw_version":"2.1.2_0009","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lemesh","model":"lemesh.switch.sw0a04","name":"情景Mesh通断器V2S系列","online":true,"order_time":1780971541,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"0bf56af2755b1be9923113ed0e4aaf85","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:lemesh-sw0a04:1:0000C808","voice_ctrl":null},"1184737843":{"bssid":"B2:99:5F:16:5C:BC","connect_type":0,"did":"1184737843","fw_version":"3.5.0_0011","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.gateway.hub1","name":"Xiaomi 中枢网关","online":true,"order_time":1779421777,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866347","room_name":"吧台","rssi":null,"ssid":null,"token":"6c434376314c47453864544d63634f7a","uid":2897121707,"urn":"urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:3","voice_ctrl":null},"2007546048":{"bssid":"36:47:D4:7C:F9:56","connect_type":0,"did":"2007546048","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lxzn","model":"lxzn.switch.cbcsmj","name":"Wi-Fi智能通断器L1","online":true,"order_time":1780196382,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-58,"ssid":"ChinaNet-4edy-WiFi5","token":"d70f63f63fab5b1868477c1112a0811b","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:lxzn-cbcsmj:1:0000D00D","voice_ctrl":null},"2008856387":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2008856387","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lxzn","model":"lxzn.switch.cbcsmj","name":"Wi-Fi智能通断器L1 2","online":true,"order_time":1780474367,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-34,"ssid":"ChinaNet-a66D","token":"d515d5cc056978c0e23af87e39d10035","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:lxzn-cbcsmj:1:0000D00D","voice_ctrl":null},"2028696446":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2028696446","fw_version":"1.1.3","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"cuco","model":"cuco.plug.v3","name":"米家智能插座3 2","online":true,"order_time":1781878933,"owner":null,"parent_id":null,"pid":0,"room_id":"984001858994","room_name":"客厅","rssi":-37,"ssid":"ChinaNet-a66D","token":"ec40e0644421aab5766464913b9d9361","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:2","voice_ctrl":null},"2051212737":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"2051212737","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cuco","model":"cuco.plug.v3","name":"米家智能插座3","online":true,"order_time":1781847100,"owner":null,"parent_id":null,"pid":0,"room_id":"984001631362","room_name":"网吧1","rssi":-55,"ssid":"ChinaNet-4edy","token":"73b0bb97bf79592f4900b3d2eb40f0c3","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:2","voice_ctrl":null},"2053225728":{"bssid":"28:D1:27:56:2F:31","connect_type":0,"did":"2053225728","fw_version":"1.1.3","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"sixwgh","model":"sixwgh.acpartner.ym01","name":"文光汇空调伴侣(插座款)","online":true,"order_time":1781350176,"owner":null,"parent_id":null,"pid":0,"room_id":"984001863128","room_name":"客厅","rssi":-36,"ssid":"Redmi_3D9E","token":"ec9d5db36676b64c18dcba94bed5c181","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-condition-outlet:0000A045:sixwgh-ym01:1:0000D01F","voice_ctrl":null},"2055348330":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"2055348330","fw_version":"1.1.3","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cnhdm","model":"cnhdm.airrtc.wkq01","name":"CNHDM温控器168M(WIFI)","online":true,"order_time":1780195591,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-32,"ssid":"ChinaNet-4edy","token":"b0e1efae15d97f29dec443eceaf58171","uid":2897121707,"urn":"urn:miot-spec-v2:device:thermostat:0000A031:cnhdm-wkq01:1","voice_ctrl":null},"2079251478":{"bssid":"","connect_type":16,"did":"2079251478","fw_version":"3.0.2_0020","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lemesh","model":"lemesh.light.wy0d02","name":"情景Lemesh色温灯Ultra","online":true,"order_time":1780490114,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"7d184426d79c45dd40ef06c59618ba61","uid":2897121707,"urn":"urn:miot-spec-v2:device:light:0000A001:lemesh-wy0d02:1:0000C802","voice_ctrl":null},"2079798139":{"bssid":"","connect_type":16,"did":"2079798139","fw_version":"3.0.2_0021","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"tofan","model":"tofan.airc.air","name":"YOOMI空调控制器MESH2.0","online":true,"order_time":1780727424,"owner":null,"parent_id":null,"pid":16,"room_id":"984001863128","room_name":"客厅","rssi":null,"ssid":null,"token":"0d87313d4faa13b52a77134eaf537cf4","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:tofan-air:1:0000C81A","voice_ctrl":null},"2080795393":{"bssid":"","connect_type":16,"did":"2080795393","fw_version":"3.0.2_0142","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"智能三键开关S2-Mesh版","online":true,"order_time":1779535396,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"sub_devices":{"s2":{"bssid":"","connect_type":21,"did":"2080795393.s2","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"左键-智能三键开关S2-Mesh版","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001856695","room_name":"双1","rssi":null,"ssid":null,"token":"CHd3Iw09Zyyp1VxT","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null},"s3":{"bssid":"","connect_type":21,"did":"2080795393.s3","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"中键-智能三键开关S2-Mesh版","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001856695","room_name":"双1","rssi":null,"ssid":null,"token":"AqZReEqV4scSU1vE","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null},"s4":{"bssid":"","connect_type":21,"did":"2080795393.s4","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"bean","model":"bean.switch.bln33","name":"右键-智能三键开关S2-Mesh版","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001856695","room_name":"双1","rssi":null,"ssid":null,"token":"PdQTMF0gZ8RrcRXx","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null}},"token":"9d32790415007a09f954b3c64333d4d4","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:bean-bln33:1:0000C810","voice_ctrl":null},"2114993879":{"bssid":"","connect_type":16,"did":"2114993879","fw_version":"3.0.2_0004","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cddz","model":"cddz.plug.pc04m","name":"Jiuhao电脑开机卡Pro (Mesh2.0)","online":false,"order_time":1781077978,"owner":null,"parent_id":null,"pid":16,"room_id":"984001648465","room_name":"大厅","rssi":null,"ssid":null,"token":"01f2f084235a9cbed656928448a258c9","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cddz-pc04m:1:0000D030","voice_ctrl":null},"2116801639":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2116801639","fw_version":"1.0.7","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.wifispeaker.l05b","name":"小爱音箱Play 2","online":true,"order_time":1780972416,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-18,"ssid":"ChinaNet-a66D","token":"e7cce2e23f83068e269989320c8ebb8c","uid":2897121707,"urn":"urn:miot-spec-v2:device:speaker:0000A015:xiaomi-l05b:1","voice_ctrl":2},"2116896390":{"bssid":"28:D1:27:56:2F:31","connect_type":0,"did":"2116896390","fw_version":"1.0.7","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.wifispeaker.l05b","name":"小爱音箱Play","online":true,"order_time":1782378581,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866120","room_name":"电脑秀华","rssi":-27,"ssid":"Redmi_3D9E","token":"4201cd571bc3d241f77602c900fa3f40","uid":2897121707,"urn":"urn:miot-spec-v2:device:speaker:0000A015:xiaomi-l05b:1","voice_ctrl":2},"2143830512":{"bssid":"","connect_type":16,"did":"2143830512","fw_version":"3.0.2_0004","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"cddz","model":"cddz.plug.pc04m","name":"Jiuhao电脑开机卡Pro (Mesh2.0) 2","online":true,"order_time":1781325304,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"85a2d96e9777a567e13fae551843991c","uid":2897121707,"urn":"urn:miot-spec-v2:device:outlet:0000A002:cddz-pc04m:1:0000D030","voice_ctrl":null},"2151935809":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"2151935809","fw_version":"1.0.9","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"iot","model":"iot.switch.padw2p","name":"浦安达单相智能保护开关","online":true,"order_time":1780020873,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861945","room_name":"单1","rssi":-35,"ssid":"ChinaNet-a66D","token":"e7e425956216ba813eebf9351c82d2c1","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:iot-padw2p:2:0000D00D","voice_ctrl":null},"2158300521":{"bssid":"","connect_type":16,"did":"2158300521","fw_version":"3.0.2_0139","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"czmydz","model":"czmydz.curtain.mym1","name":"美百帘窗帘电机(mesh2.0)","online":true,"order_time":1780924552,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"f74b21ccceef624ef1bb09d01a991f7d","uid":2897121707,"urn":"urn:miot-spec-v2:device:curtain:0000A00C:czmydz-mym1:1:0000D031","voice_ctrl":null},"2169700407":{"bssid":"","connect_type":16,"did":"2169700407","fw_version":"3.0.2_0001","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"zhunen","model":"zhunen.light.wy0a01","name":"烛照智能灯带Ultra2.0","online":true,"order_time":1779422539,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"6a2669fdffc6b50e31f6e4859107fcfc","uid":2897121707,"urn":"urn:miot-spec-v2:device:light:0000A001:zhunen-wy0a01:1:0000C802","voice_ctrl":null},"2170429382":{"bssid":"","connect_type":16,"did":"2170429382","fw_version":"3.0.2_0010","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"树色四路通断器 (Mesh2.0)","online":true,"order_time":1780909772,"owner":null,"parent_id":null,"pid":16,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"sub_devices":{"s10":{"bssid":"","connect_type":21,"did":"2170429382.s10","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键4-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"xXHaF2Z5t1GOewIu","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"s2":{"bssid":"","connect_type":21,"did":"2170429382.s2","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键1-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"GNc48QK0H3VQw3VX","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"s3":{"bssid":"","connect_type":21,"did":"2170429382.s3","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键2-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"ZROacxVBMCM2SRcU","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"s4":{"bssid":"","connect_type":21,"did":"2170429382.s4","fw_version":"","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.switch.sskg6","name":"按键3-树色四路通断器 (Mesh2.0)","online":true,"order_time":1780986208,"owner":null,"parent_id":null,"pid":21,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"DIKVPuU395UZwSP8","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null}},"token":"7817f9f7bcd997721464b9499902f1d5","uid":2897121707,"urn":"urn:miot-spec-v2:device:switch:0000A003:shuse-sskg6:1:0000D042","voice_ctrl":null},"753812859":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"753812859","fw_version":"2.1.8_0016","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"lumi","model":"lumi.acpartner.mcn02","name":"米家空调伴侣2","online":true,"order_time":1726037868,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866119","room_name":"卧室","rssi":-38,"ssid":"ChinaNet-4edy","token":"12cc118d45006bcfdced60ca0881de32","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:lumi-mcn02:1","voice_ctrl":null},"820867763":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"820867763","fw_version":"1.0.9","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"jipin","model":"jipin.fan.f003","name":"即品智能互联空气循环扇 2","online":true,"order_time":1726884077,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866120","room_name":"电脑秀华","rssi":-36,"ssid":"ChinaNet-4edy","token":"05c2ff96fc9ca1b6cd59b0d251687129","uid":2897121707,"urn":"urn:miot-spec-v2:device:fan:0000A005:jipin-f003:1:0000D062","voice_ctrl":null},"820891929":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"820891929","fw_version":"1.0.9","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"jipin","model":"jipin.fan.f003","name":"即品智能互联空气循环扇","online":true,"order_time":1725951110,"owner":null,"parent_id":null,"pid":0,"room_id":"984001858995","room_name":"电脑溶溶","rssi":-41,"ssid":"ChinaNet-4edy","token":"470326b636759a483f51b994f4ee8caa","uid":2897121707,"urn":"urn:miot-spec-v2:device:fan:0000A005:jipin-f003:1:0000D062","voice_ctrl":null},"824262964":{"bssid":"34:47:D4:DC:F9:56","connect_type":0,"did":"824262964","fw_version":"1.0.9","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"jipin","model":"jipin.fan.f003","name":"即品智能互联空气循环扇 3","online":true,"order_time":1752060407,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866119","room_name":"卧室","rssi":-40,"ssid":"ChinaNet-4edy","token":"4df831319bc4373d739c44d762c71f2a","uid":2897121707,"urn":"urn:miot-spec-v2:device:fan:0000A005:jipin-f003:1:0000D062","voice_ctrl":null},"920488072":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"920488072","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.gateway.of32","name":"OF VRF中央空调网关(Ultra版)","online":true,"order_time":1780303441,"owner":null,"parent_id":null,"pid":0,"room_id":"984001866347","room_name":"吧台","rssi":-27,"ssid":"ChinaNet-a66D","token":"3bf8c4824d8b27d4e21871028034d776","uid":2897121707,"urn":"urn:miot-spec-v2:device:gateway:0000A019:090615-of32:1","voice_ctrl":null},"921620007":{"bssid":"34:47:D4:DC:F9:AE","connect_type":0,"did":"921620007","fw_version":"2.2.9","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"qdhkl","model":"qdhkl.aircondition.b27","name":"中弘空调室内机控制器(Wi-Fi版)","online":true,"order_time":1780925803,"owner":null,"parent_id":null,"pid":0,"room_id":"984001863128","room_name":"客厅","rssi":-31,"ssid":"ChinaNet-a66D","token":"948ce1bac2979066f3d1f50117400281","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:qdhkl-b27:1","voice_ctrl":null},"977094960":{"bssid":"28:D1:27:56:2F:31","connect_type":0,"did":"977094960","fw_version":"2.1.8_0016","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lumi","model":"lumi.acpartner.mcn02","name":"空调插座","online":true,"order_time":1779334210,"owner":null,"parent_id":null,"pid":0,"room_id":"984001861943","room_name":"双2","rssi":-39,"ssid":"Redmi_3D9E","token":"ac0238a3d12be06cb87e471f5ad77778","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:lumi-mcn02:1","voice_ctrl":null},"blt.3.1onoenueccc02":{"bssid":"","connect_type":6,"did":"blt.3.1onoenueccc02","fw_version":"2.1.1_0017","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"shuse","model":"shuse.airm.ss006","name":"树色空气检测仪(BLE)","online":true,"order_time":1779507685,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"b1f7f0c505493b0e2d818c1a","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-monitor:0000A008:shuse-ss006:1","voice_ctrl":null},"blt.3.1p3ii8ovh0k00":{"bssid":"","connect_type":6,"did":"blt.3.1p3ii8ovh0k00","fw_version":"2.1.1_0009","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_ht.mini","name":"米家智能温湿度计3 mini","online":true,"order_time":1779422339,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866120","room_name":"电脑秀华","rssi":null,"ssid":null,"token":"5232a733493f0bfceff00b00","uid":2897121707,"urn":"urn:miot-spec-v2:device:temperature-humidity-sensor:0000A00A:xiaomi-mini:1:0000D063","voice_ctrl":null},"blt.3.1p3ijnm784000":{"bssid":"","connect_type":6,"did":"blt.3.1p3ijnm784000","fw_version":"2.0.1_0104","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器","online":true,"order_time":1779422723,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"6cedc03deb6dbfe3326823f9","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1p3ikilns4803":{"bssid":"","connect_type":6,"did":"blt.3.1p3ikilns4803","fw_version":"1.1.1_0010","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"lumi","model":"lumi.sensor_smoke.mcn02","name":"小米烟感卫士","online":true,"order_time":1779422944,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"c1c217e008673657e85d0084","uid":2897121707,"urn":"urn:miot-spec-v2:device:smoke-sensor:0000A023:lumi-mcn02:1","voice_ctrl":null},"blt.3.1p3iusa9h0g00":{"bssid":"","connect_type":6,"did":"blt.3.1p3iusa9h0g00","fw_version":"2.1.1_0009","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_ht.mini","name":"米家智能温湿度计3 mini 2","online":true,"order_time":1779425644,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866119","room_name":"卧室","rssi":null,"ssid":null,"token":"06d872869aed96b4006945df","uid":2897121707,"urn":"urn:miot-spec-v2:device:temperature-humidity-sensor:0000A00A:xiaomi-mini:1:0000D063","voice_ctrl":null},"blt.3.1p3ivcecp0800":{"bssid":"","connect_type":6,"did":"blt.3.1p3ivcecp0800","fw_version":"2.1.1_0009","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_ht.mini","name":"米家智能温湿度计3 mini 3","online":true,"order_time":1779425776,"owner":null,"parent_id":null,"pid":6,"room_id":"984001861945","room_name":"单1","rssi":null,"ssid":null,"token":"9c8a9f67ee1e72563b297e5b","uid":2897121707,"urn":"urn:miot-spec-v2:device:temperature-humidity-sensor:0000A00A:xiaomi-mini:1:0000D063","voice_ctrl":null},"blt.3.1p7qnaapp0k00":{"bssid":"","connect_type":6,"did":"blt.3.1p7qnaapp0k00","fw_version":"","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器 2","online":true,"order_time":1780564513,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866120","room_name":"电脑秀华","rssi":null,"ssid":null,"token":"4d98d18fb0e902cf577ceefa","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1p7qnto9t0o00":{"bssid":"","connect_type":6,"did":"blt.3.1p7qnto9t0o00","fw_version":"","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器 3","online":true,"order_time":1780564672,"owner":null,"parent_id":null,"pid":6,"room_id":"984001858995","room_name":"电脑溶溶","rssi":null,"ssid":null,"token":"0ee9e6c46605e119de42f7a7","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1p7qqkcj44o05":{"bssid":"","connect_type":6,"did":"blt.3.1p7qqkcj44o05","fw_version":"","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"xiaomi","model":"xiaomi.sensor_occupy.03","name":"小米人在传感器 4","online":true,"order_time":1780565382,"owner":null,"parent_id":null,"pid":6,"room_id":"984001866119","room_name":"卧室","rssi":null,"ssid":null,"token":"b715e79e2dcfce5fb74620a9","uid":2897121707,"urn":"urn:miot-spec-v2:device:occupancy-sensor:0000A0BF:xiaomi-03:3","voice_ctrl":null},"blt.3.1pavgk0bgcc01":{"bssid":"","connect_type":6,"did":"blt.3.1pavgk0bgcc01","fw_version":"2.1.1_0031","group_id":"b898cfd72b943b3a","home_id":"984001858993","home_name":"家里","icon":null,"local_ip":null,"manufacturer":"linp","model":"linp.motion.hs1bb2","name":"领普人体传感器3","online":true,"order_time":1781410006,"owner":null,"parent_id":null,"pid":6,"room_id":"984001858994","room_name":"客厅","rssi":null,"ssid":null,"token":"02c829768a051a55cd21313f","uid":2897121707,"urn":"urn:miot-spec-v2:device:motion-sensor:0000A014:linp-hs1bb2:1","voice_ctrl":null},"proxy.920488072.00001":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00001","fw_version":"2.2.6_0003","group_id":"5ec6bb34209671ab","home_id":"984001863127","home_name":"网吧2","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机","online":true,"order_time":1780303459,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001863128","room_name":"客厅","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00002":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00002","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 2","online":true,"order_time":1780303459,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00003":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00003","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 3","online":true,"order_time":1780303460,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00004":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00004","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 4","online":true,"order_time":1780303460,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00005":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00005","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 6","online":true,"order_time":1780303461,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null},"proxy.920488072.00006":{"bssid":"34:47:D4:DC:F9:AE","connect_type":18,"did":"proxy.920488072.00006","fw_version":"2.2.6_0003","group_id":"5a50f2fa548585c9","home_id":"984001631362","home_name":"网吧1","icon":null,"local_ip":null,"manufacturer":"090615","model":"090615.airc.k20","name":"OF VRF空调内机 5","online":true,"order_time":1780303461,"owner":null,"parent_id":"920488072","pid":18,"room_id":"984001631362","room_name":"网吧1","rssi":null,"ssid":"ChinaNet-a66D","token":"","uid":2897121707,"urn":"urn:miot-spec-v2:device:air-conditioner:0000A004:090615-k20:1","voice_ctrl":null}}…ޏbxûìñˆa¢³{NÚYZ.u¢±ü¦X.”þW!^ \ No newline at end of file diff --git a/xiaomi/examples/05_ac_control/main.go b/xiaomi/examples/05_ac_control/main.go index c4b30df..2402990 100644 --- a/xiaomi/examples/05_ac_control/main.go +++ b/xiaomi/examples/05_ac_control/main.go @@ -25,7 +25,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", @@ -52,13 +52,13 @@ func main() { log.Printf("空调: %s (DID: %s, 型号: %s)", info.Name, info.DID, info.Model) on, _ := ac.IsOn(ctx) - mode, _ := ac.GetMode(ctx) + modeName, _ := ac.GetMode(ctx) targetTemp, _ := ac.GetTargetTemp(ctx) currentTemp, _ := ac.GetCurrentTemp(ctx) - fanSpeed, _ := ac.GetFanSpeed(ctx) - swing, _ := ac.GetSwing(ctx) + fanName, _ := ac.GetFanSpeed(ctx) + swingName, _ := ac.GetSwing(ctx) log.Printf("当前状态: on=%v mode=%s target=%.1f current=%.1f fan=%s swing=%s", - on, mode, targetTemp, currentTemp, fanSpeed, swing) + on, modeName, targetTemp, currentTemp, fanName, swingName) log.Println("=== 执行控制: 开 -> 制冷 -> 26度 -> 自动风速 -> 垂直摆风 ===") @@ -69,23 +69,23 @@ func main() { } log.Println(" 设置制冷模式...") - ac.SetMode(ctx, devices.ModeCool) + ac.SetMode(ctx, devices.ACModeCool) log.Println(" 设置目标温度 26度...") ac.SetTargetTemp(ctx, 26.0) log.Println(" 设置自动风速...") - ac.SetFanSpeed(ctx, devices.SpeedAuto) + ac.SetFanSpeed(ctx, devices.ACFanAuto) log.Println(" 设置垂直摆风...") - ac.SetSwing(ctx, devices.SwingVertical) + ac.SetSwing(ctx, devices.ACSwingVertical) time.Sleep(1 * time.Second) - mode, _ = ac.GetMode(ctx) + modeName, _ = ac.GetMode(ctx) targetTemp, _ = ac.GetTargetTemp(ctx) currentTemp, _ = ac.GetCurrentTemp(ctx) - fanSpeed, _ = ac.GetFanSpeed(ctx) - swing, _ = ac.GetSwing(ctx) + fanName, _ = ac.GetFanSpeed(ctx) + swingName, _ = ac.GetSwing(ctx) log.Printf("更新后状态: mode=%s target=%.1f current=%.1f fan=%s swing=%s", - mode, targetTemp, currentTemp, fanSpeed, swing) + modeName, targetTemp, currentTemp, fanName, swingName) log.Println("设置目标湿度 50...") if err := ac.SetTargetHumidity(ctx, 50); err != nil { diff --git a/xiaomi/examples/06_fan_control/main.go b/xiaomi/examples/06_fan_control/main.go index e6e2d2e..d3af679 100644 --- a/xiaomi/examples/06_fan_control/main.go +++ b/xiaomi/examples/06_fan_control/main.go @@ -25,7 +25,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/07_prop_subscribe/main.go b/xiaomi/examples/07_prop_subscribe/main.go index f62fa71..bfc30ee 100644 --- a/xiaomi/examples/07_prop_subscribe/main.go +++ b/xiaomi/examples/07_prop_subscribe/main.go @@ -24,7 +24,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/08_adv_filter_classify/main.go b/xiaomi/examples/08_adv_filter_classify/main.go index f453030..f1726e5 100644 --- a/xiaomi/examples/08_adv_filter_classify/main.go +++ b/xiaomi/examples/08_adv_filter_classify/main.go @@ -29,7 +29,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", @@ -120,7 +120,7 @@ func main() { } mode := "N/A" if m, err := d.GetMode(ctx); err == nil { - mode = string(m) + mode = m } log.Printf("[%d] %-20s %-25s %-18s %-8s 温度=%s 模式=%s", i, info.Name, info.Model, kind, status, temp, mode) diff --git a/xiaomi/examples/09_spec_parser/main.go b/xiaomi/examples/09_spec_parser/main.go index a165dc0..5290dff 100644 --- a/xiaomi/examples/09_spec_parser/main.go +++ b/xiaomi/examples/09_spec_parser/main.go @@ -30,7 +30,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/10_cover_control/main.go b/xiaomi/examples/10_cover_control/main.go index 448ee16..5ef3ce0 100644 --- a/xiaomi/examples/10_cover_control/main.go +++ b/xiaomi/examples/10_cover_control/main.go @@ -25,7 +25,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/11_sensor_control/main.go b/xiaomi/examples/11_sensor_control/main.go index 4a18955..54a189b 100644 --- a/xiaomi/examples/11_sensor_control/main.go +++ b/xiaomi/examples/11_sensor_control/main.go @@ -24,7 +24,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/12_ac_subscribe/main.go b/xiaomi/examples/12_ac_subscribe/main.go index 07e2c30..5d0b23e 100644 --- a/xiaomi/examples/12_ac_subscribe/main.go +++ b/xiaomi/examples/12_ac_subscribe/main.go @@ -23,7 +23,7 @@ func main() { AuthInfo: xiaomi.AuthInfo{ AccessToken: cfg.AuthInfo.AccessToken, RefreshToken: cfg.AuthInfo.RefreshToken, - ExpiresIn: cfg.AuthInfo.ExpiresIn, + ExpiresIn: int32(cfg.AuthInfo.ExpiresIn), ExpiresTS: cfg.AuthInfo.ExpiresTS, }, DataDir: "./data", diff --git a/xiaomi/examples/13_multi_user/main.go b/xiaomi/examples/13_multi_user/main.go new file mode 100644 index 0000000..feed36d --- /dev/null +++ b/xiaomi/examples/13_multi_user/main.go @@ -0,0 +1,109 @@ +// 示例 13:多用户模式(bridge 包) +// +// 演示 Pool + UserBridge 门面,外部只需 import bridge 一个包。 +// 不直接依赖 xiaomi / devices / miot。 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/signal" + + "xiaomihome/bridge" + "xiaomihome/xiaomi" +) + +type UserCfg struct { + UID int64 `json:"uid,string"` + UUID string `json:"uuid"` + Auth xiaomi.AuthInfo `json:"auth"` + DataDir string `json:"data_dir"` + HomeIDs []int64 `json:"home_ids,omitempty"` +} + +func loadUsers(path string) ([]UserCfg, error) { + data, _ := os.ReadFile(path) + var users []UserCfg + if err := json.Unmarshal(data, &users); err != nil { + return nil, err + } + return users, nil +} + +func main() { + allUsers, err := loadUsers("users.json") + if err != nil || len(allUsers) == 0 { + log.Fatalf("请创建 users.json: %v", err) + } + + ctx := context.Background() + pool := bridge.NewPool() + + for _, u := range allUsers { + b, err := pool.Add(ctx, u.UID, u.UUID, u.Auth, u.DataDir, u.HomeIDs) + if err != nil { + log.Printf("用户 %d 初始化失败: %v", u.UID, err) + continue + } + + // 注册全局回调 → 推送到 NATS / WebSocket / 数据库 + b.OnStateChanged(func(id int64, did, deviceType, changed string, state interface{}) { + log.Printf("[%d] %s/%s changed=%s → %+v", id, did, deviceType, changed, state) + }) + + // 下单 MQTT 订阅 + b.StartReceiving() + + // 云端拉初始状态 + b.FetchAllStates(ctx) + } + + defer pool.Close() + + log.Printf("\n%d 个用户就绪", len(allUsers)) + + // 演示:操作某台空调 + demoControl(ctx, pool, 2897121707, "2053225728") + + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt) + <-ch + log.Println("退出") +} + +func demoControl(ctx context.Context, pool *bridge.Pool, id int64, did string) { + b, ok := pool.Get(id) + if !ok { + log.Printf("id %d 不存在", id) + return + } + + ac, err := b.AC(did) + if err != nil { + log.Printf("设备 %s 不是空调: %v", did, err) + return + } + + // 读缓存 + state := ac.GetState() + fmt.Printf("[demo] 缓存: on=%v mode=%s target=%.1f current=%.1f\n", + state.On, state.Mode, state.TargetTemperature, state.Temperature) + + // 云端刷新 + state2, err := ac.FetchState(ctx) + if err != nil { + log.Printf("[demo] 云端刷新失败: %v", err) + return + } + fmt.Printf("[demo] 云端: on=%v mode=%s target=%.1f current=%.1f\n", + state2.On, state2.Mode, state2.TargetTemperature, state2.Temperature) + + // 控制 + ac.TurnOn(ctx) + ac.SetMode(ctx, "cool") + ac.SetTargetTemp(ctx, 26) +} diff --git a/xiaomi/examples/13_multi_user/users_example.json b/xiaomi/examples/13_multi_user/users_example.json new file mode 100644 index 0000000..79181cc --- /dev/null +++ b/xiaomi/examples/13_multi_user/users_example.json @@ -0,0 +1,24 @@ +[ + { + "uid": "2897121707", + "uuid": "0684e27d49b7a234ff1a4d82f8fca234", + "data_dir": "./data/2897121707", + "auth": { + "access_token": "这里填你的token", + "refresh_token": "这里填你的refresh_token", + "expires_in": 86400, + "expires_ts": 1751289600 + } + }, + { + "uid": "another_uid", + "uuid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data_dir": "./data/another_uid", + "auth": { + "access_token": "", + "refresh_token": "", + "expires_in": 0, + "expires_ts": 0 + } + } +] diff --git a/xiaomi/examples/14_business_scenario/main.go b/xiaomi/examples/14_business_scenario/main.go new file mode 100644 index 0000000..1b99e8b --- /dev/null +++ b/xiaomi/examples/14_business_scenario/main.go @@ -0,0 +1,185 @@ +// 示例 14:多用户业务场景 +// +// 场景:管理后台控制空调 → 状态变化 → 存数据库 → 推 WebSocket +// 核心展示 bridge.Pool 用法,DB/WS 只是桩方法。 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/signal" + "time" + + "xiaomihome/bridge" + "xiaomihome/xiaomi" + "xiaomihome/xiaomi/devices" +) + +// ── 业务桩方法:type-switch 拿到类型化字段(类型来自 bridge 包) ── + +func saveToDB(id int64, did, deviceType, changed string, state interface{}) { + // 用 devices 包的常量判断变更字段,deviceType 区分设备类型 + var propLabel string + switch changed { + case devices.PropTargetTemperature: + propLabel = "目标温度" + case devices.PropTemperature: + propLabel = "当前温度" + case devices.PropBrightness: + propLabel = "亮度" + default: + propLabel = changed + } + if propLabel != "" { + log.Printf("[DB] %s changed=%s(%s)", did, propLabel, changed) + } + + // 同类化 state 拿到完整 State 结构体 + switch s := state.(type) { + case bridge.ACState: + log.Printf("[DB] 空调 %d/%s changed=%s: on=%v mode=%s target=%.1f current=%.1f", + id, did, changed, s.On, s.Mode, s.TargetTemperature, s.Temperature) + case bridge.LightState: + log.Printf("[DB] 灯 %d/%s changed=%s: on=%v brightness=%d colorTemp=%d", + id, did, changed, s.On, s.Brightness, s.ColorTemperature) + default: + log.Printf("[DB] %d/%s changed=%s: %+v", id, did, changed, s) + } +} + +func pushWS(id int64, did, deviceType, changed string, state interface{}) { + switch s := state.(type) { + case bridge.ACState: + log.Printf("[WS→%d] 空调 %s changed=%s: on=%v mode=%s target=%.1f current=%.1f", + id, did, changed, s.On, s.Mode, s.TargetTemperature, s.Temperature) + case bridge.LightState: + log.Printf("[WS→%d] 灯 %s changed=%s: on=%v brightness=%d", + id, did, changed, s.On, s.Brightness) + default: + log.Printf("[WS→%d] %s changed=%s: %+v", id, did, changed, s) + } +} + +// ── 用户配置 ── + +type UserCfg struct { + UID int64 `json:"uid,string"` + UUID string `json:"uuid"` + Auth xiaomi.AuthInfo `json:"auth"` + DataDir string `json:"data_dir"` + HomeIDs []int64 `json:"home_ids,omitempty"` +} + +func loadUsers(path string) ([]UserCfg, error) { + data, _ := os.ReadFile(path) + var users []UserCfg + if err := json.Unmarshal(data, &users); err != nil { + return nil, err + } + return users, nil +} + +// ── 业务初始化 ── + +func initBridge(ctx context.Context, pool *bridge.Pool, cfg UserCfg) error { + b, err := pool.Add(ctx, cfg.UID, cfg.UUID, cfg.Auth, cfg.DataDir, cfg.HomeIDs) + if err != nil { + return err + } + + // 注册回调:MQTT 推送 → DB + WS + b.OnStateChanged(func(id int64, did, deviceType, changed string, state interface{}) { + saveToDB(id, did, deviceType, changed, state) + pushWS(id, did, deviceType, changed, state) + }) + + b.StartReceiving() + log.Printf("[Init] %d: %d 台设备", cfg.UID, len(b.Devices())) + return nil +} + +// ── 业务操作 ── + +func controlAC(ctx context.Context, pool *bridge.Pool, id int64, did string) error { + b, ok := pool.Get(id) + if !ok { + return fmt.Errorf("id %d 不存在", id) + } + + ac, err := b.AC(did) + if err != nil { + return err + } + + // 读缓存 + state := ac.GetState() + fmt.Printf("\n空调 %s:", did) + fmt.Printf(" on=%v mode=%s target=%.1f current=%.1f\n", + state.On, state.Mode, state.TargetTemperature, state.Temperature) + + // 云端刷新 + if s, err := ac.FetchState(ctx); err == nil { + fmt.Printf(" 云端: on=%v mode=%s target=%.1f current=%.1f\n", + s.On, s.Mode, s.TargetTemperature, s.Temperature) + } + + // 控制 + fmt.Println("\n管理后台操作:开机制冷 26°C") + ac.TurnOn(ctx) + time.Sleep(300 * time.Millisecond) + ac.SetMode(ctx, devices.ACModeCool) + ac.SetTargetTemp(ctx, 26) + time.Sleep(1 * time.Second) + + // 最终状态 + final := ac.GetState() + fmt.Printf("操作后: on=%v mode=%s target=%.1f current=%.1f\n", + final.On, final.Mode, final.TargetTemperature, final.Temperature) + return nil +} + +// ── 主流程 ── + +func main() { + allUsers, err := loadUsers("users.json") + if err != nil || len(allUsers) == 0 { + log.Fatalf("请创建 users.json: %v", err) + } + + ctx := context.Background() + pool := bridge.NewPool() + + for _, u := range allUsers { + if err := initBridge(ctx, pool, u); err != nil { + log.Printf("id %d 失败: %v", u.UID, err) + } + } + + log.Printf("\n%d 个就绪\n", len(allUsers)) + + // 找第一台空调演示 + id := allUsers[0].UID + b, ok := pool.Get(id) + if !ok || b == nil { + log.Fatal("没有设备初始化成功") + } + var targetDID string + for did, typ := range b.Devices() { + if typ == "air_conditioner" { + targetDID = did + break + } + } + if targetDID != "" { + controlAC(ctx, pool, id, targetDID) + } + + fmt.Println("\nCtrl+C 退出...") + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt) + <-ch +} diff --git a/xiaomi/examples/14_business_scenario/users.json b/xiaomi/examples/14_business_scenario/users.json new file mode 100644 index 0000000..66022ae --- /dev/null +++ b/xiaomi/examples/14_business_scenario/users.json @@ -0,0 +1,13 @@ +[ + { + "uid": "2897121707", + "uuid": "2882aa541cfc47b78ed178bb42b43302", + "data_dir": "./data/2897121707", + "auth": { + "access_token": "V3_iTf6riwKt698wtjY8tvu1o6kX1Izsid-XHKCJK1RNZXscm3htdWXCV6lUj8nwncsk58pjy3Vmnu93Ggfa1mYUNe-839wtyCAGyND-Q-diRcC4LbVO_tUDyvNXPAN2VknYKfY7tzUGYzEzOzSFq_Feo_vhZsPEJ6oHnY58s2xtqQOxPAJRa8B3mZrggi2TNkm", + "refresh_token": "R3_9Z2sYiFbiOWSRTTUBmVOTlSXnyqOpIpY2IJ0oadNpJqrcrPPKKbvGrADaadudLjaF3VIYJTY2mM9HgXlnr3eoRQCbNETOoEsAS04zxJa9K8YH8pkJT9voDDTPEuhpXdATv5SOrzyZgDxn-gJKMUHzxotwAyfaTlxuA6Dtv80uoyYZuHresy-zqfiiABVCwUVY0saSa1fayXCeUYzRfEgng", + "expires_ts": 1783080922, + "expires_in": 259200 + } + } +] diff --git a/xiaomi/examples/15_oauth_flow/main.go b/xiaomi/examples/15_oauth_flow/main.go new file mode 100644 index 0000000..23518d9 --- /dev/null +++ b/xiaomi/examples/15_oauth_flow/main.go @@ -0,0 +1,97 @@ +// 示例 15:OAuth 授权流程(无需 import miot) +// +// go run . 首次运行自动创建 user.json,打开授权链接完成授权后粘贴 code。 + +package main + +import ( + "bufio" + "crypto/rand" + "context" + "encoding/json" + "fmt" + "log" + "os" + + "xiaomihome/xiaomi" +) + +type userFile struct { + UID string `json:"uid"` + UUID string `json:"uuid"` + Auth struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresTS int64 `json:"expires_ts"` + } `json:"auth"` + DataDir string `json:"data_dir"` +} + +const filePath = "user.json" + +func load() *userFile { + data, _ := os.ReadFile(filePath) + var u userFile + json.Unmarshal(data, &u) + if u.UUID == "" { + u.UUID = newUUID() + save(&u) + } + return &u +} + +func save(u *userFile) { + data, _ := json.MarshalIndent(u, "", " ") + os.WriteFile(filePath, data, 0600) +} + +func newUUID() string { + b := make([]byte, 16) + rand.Read(b) + return fmt.Sprintf("%x", b) +} + +func main() { + ctx := context.Background() + u := load() + + if u.Auth.AccessToken != "" { + fmt.Println("已完成授权") + fmt.Printf("UID: %s\n", u.UID) + fmt.Printf("AccessToken: %s...\n", u.Auth.AccessToken[:min(20, len(u.Auth.AccessToken))]) + fmt.Printf("RefreshToken: %s...\n", u.Auth.RefreshToken[:min(20, len(u.Auth.RefreshToken))]) + return + } + + authURL := xiaomi.AuthURL(xiaomi.Config{UUID: u.UUID}) + fmt.Println("========== OAuth 授权 ==========") + fmt.Println("请打开以下链接完成授权,然后把地址栏中的 code 粘贴回来:") + fmt.Println() + fmt.Println(" ", authURL) + fmt.Println() + fmt.Print("输入 code: ") + + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + code := scanner.Text() + if code == "" { + log.Fatal("未输入 code") + } + + result, err := xiaomi.ExchangeCode(ctx, xiaomi.Config{UUID: u.UUID}, code) + if err != nil { + log.Fatal("ExchangeCode 失败:", err) + } + + u.UID = result.UID + u.Auth.AccessToken = result.AccessToken + u.Auth.RefreshToken = result.RefreshToken + u.Auth.ExpiresTS = result.ExpiresTS + u.DataDir = "./data/" + u.UID + save(u) + + fmt.Println("\n=== Token 已保存 ===") + fmt.Printf("UID: %s\n", u.UID) + fmt.Printf("AccessToken: %s...\n", u.Auth.AccessToken[:min(20, len(u.Auth.AccessToken))]) + fmt.Printf("RefreshToken: %s...\n", u.Auth.RefreshToken[:min(20, len(u.Auth.RefreshToken))]) +} diff --git a/xiaomi/examples/15_oauth_flow/user.json b/xiaomi/examples/15_oauth_flow/user.json new file mode 100644 index 0000000..25af387 --- /dev/null +++ b/xiaomi/examples/15_oauth_flow/user.json @@ -0,0 +1,10 @@ +{ + "uid": "", + "uuid": "2882aa541cfc47b78ed178bb42b43302", + "auth": { + "access_token": "V3_iTf6riwKt698wtjY8tvu1o6kX1Izsid-XHKCJK1RNZXscm3htdWXCV6lUj8nwncsk58pjy3Vmnu93Ggfa1mYUNe-839wtyCAGyND-Q-diRcC4LbVO_tUDyvNXPAN2VknYKfY7tzUGYzEzOzSFq_Feo_vhZsPEJ6oHnY58s2xtqQOxPAJRa8B3mZrggi2TNkm", + "refresh_token": "R3_9Z2sYiFbiOWSRTTUBmVOTlSXnyqOpIpY2IJ0oadNpJqrcrPPKKbvGrADaadudLjaF3VIYJTY2mM9HgXlnr3eoRQCbNETOoEsAS04zxJa9K8YH8pkJT9voDDTPEuhpXdATv5SOrzyZgDxn-gJKMUHzxotwAyfaTlxuA6Dtv80uoyYZuHresy-zqfiiABVCwUVY0saSa1fayXCeUYzRfEgng", + "expires_ts": 1783080922 + }, + "data_dir": "./data/" +} \ No newline at end of file diff --git a/xiaomi/homes.go b/xiaomi/homes.go index 20233fd..76b6b3b 100644 --- a/xiaomi/homes.go +++ b/xiaomi/homes.go @@ -1,6 +1,9 @@ package xiaomi -import "context" +import ( + "context" + "strconv" +) // GetHomeList 获取所有家庭列表 // 合并自有家庭 (home_list) 和共享家庭 (share_home_list) @@ -34,7 +37,7 @@ func (c *Client) GetHomeList(ctx context.Context) ([]*HomeInfo, error) { } // GetHome 获取指定家庭详情(含房间) -func (c *Client) GetHome(ctx context.Context, homeID string) (*HomeInfo, error) { +func (c *Client) GetHome(ctx context.Context, homeID int64) (*HomeInfo, error) { homes, err := c.GetHomeList(ctx) if err != nil { return nil, err @@ -50,7 +53,7 @@ func (c *Client) GetHome(ctx context.Context, homeID string) (*HomeInfo, error) } // GetRooms 获取指定家庭的所有房间 -func (c *Client) GetRooms(ctx context.Context, homeID string) ([]*RoomInfo, error) { +func (c *Client) GetRooms(ctx context.Context, homeID int64) ([]*RoomInfo, error) { home, err := c.GetHome(ctx, homeID) if err != nil { return nil, err @@ -64,7 +67,7 @@ func (c *Client) GetRooms(ctx context.Context, homeID string) ([]*RoomInfo, erro // GetRoomDevices 获取指定房间的设备 DID 列表。 // 优先使用 homeroom API 返回的 dids,为空时从设备列表按 room_id 过滤。 -func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID string) ([]string, error) { +func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID int64) ([]string, error) { rooms, err := c.GetRooms(ctx, homeID) if err != nil { return nil, err @@ -80,7 +83,7 @@ func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID string) ([]s } // 退化:从设备列表按 room_id 过滤 - devices, err := c.GetDevices(ctx, FilterByRoom(roomID)) + devices, err := c.GetDevices(ctx, FilterByRoom(strconv.FormatInt(roomID, 10))) if err != nil { return nil, err } @@ -94,7 +97,7 @@ func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID string) ([]s // parseHomeInfo 从 miot 返回的 map 解析 HomeInfo(含房间列表) func parseHomeInfo(m map[string]interface{}, shared bool) *HomeInfo { h := &HomeInfo{ - ID: strVal(m, "home_id"), + ID: int64FromStr(strVal(m, "home_id")), Name: strVal(m, "home_name"), UID: strVal(m, "uid"), GroupID: strVal(m, "group_id"), @@ -118,8 +121,9 @@ func parseHomeInfo(m map[string]interface{}, shared bool) *HomeInfo { if roomMap, ok := m["room_info"].(map[string]interface{}); ok { for _, v := range roomMap { if rm, ok := v.(map[string]interface{}); ok { + roomID := int64FromStr(strVal(rm, "room_id")) h.Rooms = append(h.Rooms, RoomInfo{ - ID: strVal(rm, "room_id"), + ID: roomID, Name: strVal(rm, "room_name"), HomeID: h.ID, DidList: strSliceVal(rm, "dids"), diff --git a/xiaomi/migration/TOKEN_REFACTOR.md b/xiaomi/migration/TOKEN_REFACTOR.md new file mode 100644 index 0000000..232c1fc --- /dev/null +++ b/xiaomi/migration/TOKEN_REFACTOR.md @@ -0,0 +1,103 @@ +# token 管理重构方案 + +> 2026-06-29 + +## 原则 + +- mloT 层方法签名不变(`Init()` / `NewMIoTClient` 等) +- xiaomi 层只改 `NewClient` 签名(架构必须改),其他全部不动 + +--- + +## 修改清单 + +### 1. miot: `Init()` 内部逻辑修复(签名不变) + +当前 Init 从 entryData 取 token,storage 白读。改为优先读 storage,首次没数据才回退 entryData 并写入 storage: + +```go +func (c *MIoTClient) Init() error { + // 尝试从 storage 取 token + if c.storage != nil { + authInfo := c.storage.LoadUserConfig(c.uid, c.cloudServer, nil) + if authInfo == nil { + // 首次:从 entryData 取,写入 storage + c.storage.UpdateUserConfig(c.uid, c.cloudServer, map[string]interface{}{ + "access_token": c.entryData["access_token"], + "refresh_token": c.entryData["refresh_token"], + "expires_ts": c.entryData["expires_ts"], + }, true) + } else if t, ok := authInfo["access_token"].(string); ok && t != "" { + c.entryData["access_token"] = t // 覆盖给后续 HTTP/MIPS 创建用 + } + } + // 后续现有逻辑不变... +} +``` + +### 2. miot: 新增 `RefreshAuthInfo()`(不破坏旧签名) + +```go +// RefreshAuthInfo 手动触发 token 刷新(对齐 Python refresh_oauth_info_async) +// 返回 {access_token, refresh_token, expires_ts} +func (c *MIoTClient) RefreshAuthInfo() (map[string]interface{}, error) +``` + +内部复用现有的 `refreshAuthInfo` 逻辑,改为同步返回结果。 + +### 3. xiaomi: Config 改签名(唯一需要调用方改的地方) + +```go +type Config struct { + AccessToken string + RefreshToken string // 可选,用于自动/手动刷新 +} +``` + +`NewClient(ctx, Config)` 替代 `NewClient(inner, httpClient)`。内部把 `AccessToken` / `RefreshToken` 放进 entryData,Init 自动写入 storage。 + +### 4. xiaomi: 新增 `RefreshToken()`(其他方法不动) + +```go +// RefreshToken 手动刷新 token,返回新值让调用方存库 +func (c *Client) RefreshToken(ctx context.Context) (*TokenResult, error) + +type TokenResult struct { + AccessToken string + RefreshToken string + ExpiresTS int64 +} +``` + +### 5. 清理 entryData 无用字段 + +miot 中去掉 `country_code`,xiaomi 中不再出现。 + +--- + +## 影响范围 + +| 层 | 方法 | 改动 | 调用方影响 | +|----|------|------|------------| +| miot | `Init()` | 内部逻辑 | 无(签名不变) | +| miot | `RefreshAuthInfo()` | 新增 | 无(新增方法) | +| xiaomi | `NewClient` | 签名改为 `(ctx, Config)` | **调用方需改** | +| xiaomi | `Close()` | 新增 | 无 | +| xiaomi | `RefreshToken()` | 新增 | 无 | +| xiaomi | 其他所有方法 | 不动 | 无 | + +--- + +## 使用示例 + +```go +client, err := xiaomi.NewClient(ctx, xiaomi.Config{ + AccessToken: token, + RefreshToken: refreshToken, +}) +defer client.Close() + +// 手动刷新 +result, err := client.RefreshToken(ctx) +db.Save(userID, result.AccessToken, result.RefreshToken) +``` diff --git a/xiaomi/oauth.go b/xiaomi/oauth.go index 6f98bd6..b2fb294 100644 --- a/xiaomi/oauth.go +++ b/xiaomi/oauth.go @@ -14,20 +14,28 @@ import ( type TokenResult struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` + ExpiresIn int32 `json:"expires_in"` ExpiresTS int64 `json:"expires_ts"` UID string `json:"uid"` } // AuthURL 生成小米 OAuth 授权页面 URL。 func AuthURL(cfg Config) string { - oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, "", "cn", generateUUID()) + uuid := cfg.UUID + if uuid == "" { uuid = generateUUID() } + redirectURL := cfg.RedirectURL + if redirectURL == "" { redirectURL = miot.OAUTH_REDIRECT_URL } + oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, redirectURL, "cn", uuid) return oa.GenAuthURL("", "", nil, false) } // ExchangeCode 用 authorization code 交换 access token。 func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, error) { - oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, "", "cn", generateUUID()) + uuid := cfg.UUID + if uuid == "" { uuid = generateUUID() } + redirectURL := cfg.RedirectURL + if redirectURL == "" { redirectURL = miot.OAUTH_REDIRECT_URL } + oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, redirectURL, "cn", uuid) result, err := oa.GetAccessToken(code) if err != nil { return nil, fmt.Errorf("xiaomi: exchange token: %w", err) @@ -36,7 +44,7 @@ func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, e return &TokenResult{ AccessToken: strVal(result, "access_token"), RefreshToken: strVal(result, "refresh_token"), - ExpiresIn: expiresIn, + ExpiresIn: int32(expiresIn), ExpiresTS: time.Now().Unix() + int64(expiresIn), UID: strVal(result, "uid"), }, nil @@ -45,15 +53,5 @@ func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, e func generateUUID() string { b := make([]byte, 16) rand.Read(b) - buf := make([]byte, 36) - hex.Encode(buf[:8], b[:4]) - buf[8] = '-' - hex.Encode(buf[9:13], b[4:6]) - buf[13] = '-' - hex.Encode(buf[14:18], b[6:8]) - buf[18] = '-' - hex.Encode(buf[19:23], b[8:10]) - buf[23] = '-' - hex.Encode(buf[24:], b[10:16]) - return string(buf) + return hex.EncodeToString(b) } diff --git a/xiaomi/types.go b/xiaomi/types.go index ec189ba..d4c983c 100644 --- a/xiaomi/types.go +++ b/xiaomi/types.go @@ -14,7 +14,7 @@ type UserInfo struct { // HomeInfo 家庭信息 type HomeInfo struct { - ID string `json:"id"` + ID int64 `json:"id,string"` Name string `json:"name"` UID string `json:"uid"` GroupID string `json:"group_id"` @@ -29,10 +29,10 @@ type HomeInfo struct { // RoomInfo 房间信息 type RoomInfo struct { - ID string `json:"id"` + ID int64 `json:"id,string"` Name string `json:"name"` - HomeID string `json:"home_id"` - DidList []string `json:"did_list"` // 房间内设备 DID + HomeID int64 `json:"home_id,string"` + DidList []string `json:"did_list"` } // DeviceInfo 设备信息(替代 map[string]interface{}) @@ -155,6 +155,7 @@ const ( KindVacuum Kind = "vacuum" KindWaterHeater Kind = "water_heater" KindThermostat Kind = "thermostat" + KindSpeaker Kind = "speaker" KindSensor Kind = "sensor" KindUnknown Kind = "unknown" ) diff --git a/xiaomi/types_test.go b/xiaomi/types_test.go index cea72b7..8d7805c 100644 --- a/xiaomi/types_test.go +++ b/xiaomi/types_test.go @@ -73,19 +73,19 @@ func TestDeviceInfoEmpty(t *testing.T) { func TestHomeInfoParsing(t *testing.T) { data := map[string]interface{}{ - "home_id": "home001", + "home_id": "123", "home_name": "我的家", "uid": "user001", "group_id": "group001", "dids": []interface{}{"device1", "device2"}, "room_info": map[string]interface{}{ - "room001": map[string]interface{}{ - "room_id": "room001", + "1": map[string]interface{}{ + "room_id": "1", "room_name": "客厅", "dids": []interface{}{"device1"}, }, - "room002": map[string]interface{}{ - "room_id": "room002", + "2": map[string]interface{}{ + "room_id": "2", "room_name": "卧室", "dids": []interface{}{"device2"}, }, @@ -94,8 +94,8 @@ func TestHomeInfoParsing(t *testing.T) { home := parseHomeInfo(data, true) - if home.ID != "home001" { - t.Errorf("ID = %q", home.ID) + if home.ID != 123 { + t.Errorf("ID = %d", home.ID) } if home.Name != "我的家" { t.Errorf("Name = %q", home.Name) @@ -109,8 +109,8 @@ func TestHomeInfoParsing(t *testing.T) { names := map[string]bool{"客厅": false, "卧室": false} for _, r := range home.Rooms { names[r.Name] = true - if r.HomeID != "home001" { - t.Errorf("Room %s HomeID = %q, want home001", r.Name, r.HomeID) + if r.HomeID != 123 { + t.Errorf("Room %s HomeID = %d, want 123", r.Name, r.HomeID) } } if !names["客厅"] || !names["卧室"] {