From 04333750abacf01dd5e44d76ddad470a87af7088 Mon Sep 17 00:00:00 2001 From: 4566704 <4566704@qq.com> Date: Wed, 5 Aug 2026 17:54:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20State=E2=86=92Props=20=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=BB=9F=E4=B8=80=20+=20=E6=96=B0=E5=A2=9E=20Plug/Gat?= =?UTF-8?q?eway=20=E8=AE=BE=E5=A4=87=20+=20=E5=88=86=E7=B1=BB=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **命名统一(State → Props)** - 所有设备类型:XxxState → XxxProps,GetState() → GetProps(),FetchState() → FetchProps() - 回调接口:OnStateChanged → OnPropsChanged - Bridge 层类型别名、示例代码、文档同步更名 **新增设备类型** - KindPlug + Plug 驱动:智能插座/插头(TurnOn/TurnOff/IsOn/OnPropsChanged) - KindGateway + Gateway 驱动:网关设备类型识别 - KindOccupancySensor 从 KindSensor 独立,添加 OccupancyProps **设备分类系统改进** - classifyByModel 改为按 key 长度降序匹配(防止 sensor 先于 sensor-occupy) - 新增下划线变体识别(sensor_occupy、sensor_temp 等) - outlet 重新归类为 Plug(非 Switch) - 新增 Or() 组合过滤器 **连接桥增强** - 设备创建成功后自动订阅属性变更 - 新增 nil 防护检查 - 支持 Plug/Speaker/Gateway/Occupancy 类型的 FetchProps 刷新 - 日志级别 Info → Debug **AC 驱动增强** - 新增 HasPower()、GetElectricPower()、GetPowerConsumption() 功率接口 - ACProps 新增 ElectricPower、PowerConsumption 字段 - valMapper:Desc 为空时 fallback 用 Name,key 统一小写 **MIoT 核心** - 设备列表刷新改用 reflect.DeepEqual 全字段变更检测 - 移除 BLE/代理设备默认在线 hack - RefreshDeviceAllProps 增加离线检查 - 新增 miot_errors.go:MIoT 错误码定义 - 全局日志级别 Info→Debug/Warn(降低噪音) **示例与文档** - 所有示例代码同步 API 变更 - 传感器示例改用 Or 组合过滤器 - ARCH_PLAN 等迁移文档同步更新 --- bridge/account.go | 6 +- bridge/bridge.go | 58 ++++-- bridge/subscribe.go | 200 +++++++++++-------- miot/migration/ARCH_PLAN.md | 8 +- miot/migration/ARCH_PLAN_REVIEW.md | 8 +- miot/miot_client_device.go | 78 +++++--- miot/miot_client_prop.go | 30 +-- miot/miot_client_sub.go | 20 +- miot/miot_lan.go | 58 +++--- miot/miot_mdns.go | 4 +- miot/miot_network.go | 2 +- xiaomi/README.md | 6 +- xiaomi/classify.go | 54 ++++- xiaomi/devices.go | 5 + xiaomi/devices/air_conditioner.go | 127 +++++++++--- xiaomi/devices/base.go | 26 +++ xiaomi/devices/cover.go | 22 +- xiaomi/devices/factory.go | 10 +- xiaomi/devices/fan.go | 189 ++++++++++++++---- xiaomi/devices/humidifier.go | 44 ++-- xiaomi/devices/light.go | 53 ++--- xiaomi/devices/occupancy_sensor.go | 84 +++++--- xiaomi/devices/properties.go | 23 ++- xiaomi/devices/speaker.go | 60 +++--- xiaomi/devices/switch.go | 56 +++--- xiaomi/devices/temp_humidity_sensor.go | 26 +-- xiaomi/devices/thermostat.go | 40 ++-- xiaomi/devices/vacuum.go | 44 ++-- xiaomi/devices/water_heater.go | 44 ++-- xiaomi/examples/04_light_control/main.go | 6 +- xiaomi/examples/05_ac_control/main.go | 6 +- xiaomi/examples/11_sensor_control/main.go | 6 +- xiaomi/examples/12_ac_subscribe/main.go | 8 +- xiaomi/examples/13_multi_user/main.go | 6 +- xiaomi/examples/14_business_scenario/main.go | 16 +- xiaomi/migration/EXAMPLES_PLAN.md | 12 +- xiaomi/types.go | 7 +- 37 files changed, 916 insertions(+), 536 deletions(-) diff --git a/bridge/account.go b/bridge/account.go index 8230683..371ae3a 100644 --- a/bridge/account.go +++ b/bridge/account.go @@ -63,8 +63,9 @@ func (b *Bridge) RefreshDevices(ctx context.Context, homeIDs ...int64) error { } for _, info := range devs { if _, exists := b.dv[info.DID]; !exists { - if d, createErr := devices.Create(b.client, info); createErr == nil { + if d, createErr := devices.Create(b.client, info); createErr == nil && d != nil { b.dv[info.DID] = d + b.subscribeDevice(info.DID, d) } } } @@ -79,8 +80,9 @@ func (b *Bridge) RefreshDevices(ctx context.Context, homeIDs ...int64) error { } for _, info := range devList { if _, exists := b.dv[info.DID]; !exists { - if d, createErr := devices.Create(b.client, info); createErr == nil { + if d, createErr := devices.Create(b.client, info); createErr == nil && d != nil { b.dv[info.DID] = d + b.subscribeDevice(info.DID, d) } } } diff --git a/bridge/bridge.go b/bridge/bridge.go index c531c90..f2b789b 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -9,7 +9,7 @@ // b, _ := pool.Get(id) // ac, _ := b.AC("did") // ac.TurnOn(ctx) -// state := ac.GetState() +// state := ac.GetProps() package bridge import ( @@ -25,14 +25,22 @@ import ( // ── 类型别名:业务层不需要 import devices ── type ( - ACState = devices.ACState - LightState = devices.LightState - FanState = devices.FanState - HumidifierState = devices.HumidifierState - HeaterState = devices.HeaterState - VacuumStatus = devices.VacuumStatus + ACProps = devices.ACProps + LightProps = devices.LightProps + FanProps = devices.FanProps + HumidifierProps = devices.HumidifierProps + HeaterProps = devices.HeaterProps + VacuumProps = devices.VacuumProps + SwitchProps = devices.SwitchProps + SpeakerProps = devices.SpeakerProps + PlugProps = devices.PlugProps ) +// OccupancyProps 人体传感器状态(有人/无人)。 +type OccupancyProps struct { + Occupied bool `json:"occupied"` +} + // ── Pool ── // Pool 全局路由:id → Bridge。 @@ -103,7 +111,8 @@ type Bridge struct { mu sync.RWMutex dv map[string]interface{} // DID → devices.Xxx interface - onStateChanged func(id int64, did, deviceType, changed string, state interface{}) + onPropsChanged func(id int64, did, deviceType, changed string, props interface{}) + onOnlineChanged func(id int64, did string, online bool) } func newBridge(ctx context.Context, id int64, uuid string, auth xiaomi.AuthInfo, dataDir string, homeIDs []int64) (*Bridge, error) { @@ -162,18 +171,20 @@ func (b *Bridge) Devices() map[string]string { 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" + case devices.AirConditioner: return devices.TypeAirConditioner + case devices.Light: return devices.TypeLight + case devices.Switch: return devices.TypeSwitch + case devices.Fan: return devices.TypeFan + case devices.Cover: return devices.TypeCover + case devices.Humidifier: return devices.TypeHumidifier + case devices.Vacuum: return devices.TypeVacuum + case devices.WaterHeater: return devices.TypeWaterHeater + case devices.Thermostat: return devices.TypeThermostat + case devices.TempHumiditySensor: return devices.TypeTempHumidSensor + case devices.OccupancySensor: return devices.TypeOccupancySensor + case devices.Speaker: return devices.TypeSpeaker + case devices.Gateway: return devices.TypeGateway + case devices.Plug: return devices.TypePlug default: return "unknown" } } @@ -257,6 +268,13 @@ func (b *Bridge) Occupancy(did string) (devices.OccupancySensor, error) { if !ok { return nil, fmt.Errorf("bridge: device %s is not an OccupancySensor", did) } return s, nil } +func (b *Bridge) Plug(did string) (devices.Plug, error) { + d, ok := b.dv[did] + if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } + pg, ok := d.(devices.Plug) + if !ok { return nil, fmt.Errorf("bridge: device %s is not a Plug", did) } + return pg, 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) } diff --git a/bridge/subscribe.go b/bridge/subscribe.go index ab185e2..a679b8b 100644 --- a/bridge/subscribe.go +++ b/bridge/subscribe.go @@ -4,16 +4,24 @@ import ( "context" "fmt" + "xiaomihome/xiaomi" "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{})) { +// OnPropsChanged 注册全局属性变化回调。props 是 devices 包的类型化 Props 结构体, +// 调用方自行 switch 类型(ACProps / LightProps / FanProps ...)。 +func (b *Bridge) OnPropsChanged(handler func(id int64, did, deviceType, changed string, props interface{})) { b.mu.Lock() - b.onStateChanged = handler + b.onPropsChanged = handler + b.mu.Unlock() +} + +// OnOnlineChanged 注册设备在线/离线变化回调。 +func (b *Bridge) OnOnlineChanged(handler func(id int64, did string, online bool)) { + b.mu.Lock() + b.onOnlineChanged = handler b.mu.Unlock() } @@ -22,66 +30,96 @@ 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) - }) + b.subscribeDevice(did, d) + } +} + +// subscribeDevice 为单个设备注册 MQTT 属性和在线状态订阅。 +func (b *Bridge) subscribeDevice(did string, d interface{}) { + devType := deviceTypeName(d) + switch ctrl := d.(type) { + case devices.AirConditioner: + ctrl.OnPropsChanged(func(props devices.ACProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.Light: + ctrl.OnPropsChanged(func(props devices.LightProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.Switch: + ctrl.OnPropsChanged(func(ch int, on bool) { + b.fire(did, devType, fmt.Sprintf("ch%d", ch), ctrl.GetProps()) + }) + case devices.Fan: + ctrl.OnPropsChanged(func(props devices.FanProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.Cover: + ctrl.OnPropsChanged(func(pos int) { + b.fire(did, devType, devices.PropPosition, pos) + }) + case devices.Humidifier: + ctrl.OnPropsChanged(func(props devices.HumidifierProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.Vacuum: + ctrl.OnPropsChanged(func(props devices.VacuumProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.WaterHeater: + ctrl.OnPropsChanged(func(props devices.HeaterProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.Thermostat: + ctrl.OnPropsChanged(func(props devices.HeaterProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.TempHumiditySensor: + ctrl.OnPropsChanged(func(temp, hum float64) { + b.fire(did, devType, "temperature_humidity", map[string]float64{"temperature": temp, "humidity": hum}) + }) + case devices.OccupancySensor: + ctrl.OnPropsChanged(func(occ bool, lux float64) { + b.fire(did, devType, "occupancy", map[string]interface{}{"occupied": occ, "illuminance": lux}) + }) + case devices.Speaker: + ctrl.OnPropsChanged(func(props devices.SpeakerProps, ch string) { + b.fire(did, devType, ch, props) + }) + case devices.Plug: + ctrl.OnPropsChanged(func(on bool) { + b.fire(did, devType, devices.PropOn, on) + }) + } + // 订阅设备在线/离线 + if bd, ok := d.(interface{ SubDeviceState(xiaomi.DeviceStateHandler) error }); ok { + if err := bd.SubDeviceState(func(did string, state xiaomi.DeviceState) { + online := state == xiaomi.StateOnline + b.fireOnline(did, online) + if online { + go b.client.RefreshDeviceAllProps(did) + } + }); err != nil { + b.lgr.Warnf("[bridge] %d SubDeviceState %s: %v", b.id, did, err) } } } -func (b *Bridge) fire(did, devType, changed string, state interface{}) { +func (b *Bridge) fireOnline(did string, online bool) { b.mu.RLock() - cb := b.onStateChanged + cb := b.onOnlineChanged b.mu.RUnlock() if cb != nil { - cb(b.id, did, devType, changed, state) + cb(b.id, did, online) + } +} + +func (b *Bridge) fire(did, devType, changed string, props interface{}) { + b.mu.RLock() + cb := b.onPropsChanged + b.mu.RUnlock() + if cb != nil { + cb(b.id, did, devType, changed, props) } } @@ -99,48 +137,52 @@ func (b *Bridge) FetchAllStates(ctx context.Context) { 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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if pos, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if t, h, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[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) + if o, l, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[bridge] %d Occupancy: occ=%v lux=%.0f", b.id, o, l) + } + case devices.Plug: + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[bridge] %d Plug state: on=%v", b.id, s.On) } } } diff --git a/miot/migration/ARCH_PLAN.md b/miot/migration/ARCH_PLAN.md index 1fc27ea..01029f8 100644 --- a/miot/migration/ARCH_PLAN.md +++ b/miot/migration/ARCH_PLAN.md @@ -503,7 +503,7 @@ type AirConditioner interface { GetEco(ctx context.Context) (bool, error) // 订阅(不需要 ctx,订阅是本地注册操作) - OnStateChanged(handler func(state ACState)) (subID string, err error) + OnPropsChanged(handler func(state ACState)) (subID string, err error) } // ACMode 空调模式 @@ -581,7 +581,7 @@ type Light interface { SetColor(ctx context.Context, r, g, b int) error // 0-255 GetColor(ctx context.Context) (r, g, b int, error) - OnStateChanged(handler func(state LightState)) (subID string, err error) + OnPropsChanged(handler func(state LightState)) (subID string, err error) } type LightState struct { @@ -606,7 +606,7 @@ type Switch interface { Toggle(ctx context.Context) error IsOn(ctx context.Context) (bool, error) - OnStateChanged(handler func(on bool)) (subID string, err error) + OnPropsChanged(handler func(on bool)) (subID string, err error) } ``` @@ -1059,7 +1059,7 @@ func main() { d.SetTargetTemp(ctx, 26) d.SetFanSpeed(ctx, devices.SpeedAuto) // 订阅状态变化 - d.OnStateChanged(func(state devices.ACState) { + d.OnPropsChanged(func(state devices.ACState) { fmt.Printf("空调 %s: 模式=%s 温度=%.1f°C\n", dev.Name(), state.Mode, state.TargetTemp) }) diff --git a/miot/migration/ARCH_PLAN_REVIEW.md b/miot/migration/ARCH_PLAN_REVIEW.md index 538cfe3..5aa69ba 100644 --- a/miot/migration/ARCH_PLAN_REVIEW.md +++ b/miot/migration/ARCH_PLAN_REVIEW.md @@ -32,7 +32,7 @@ **不修改的方法**(纯本地操作): - SubProp / UnsubProp / SubEvent / UnsubEvent / SubDeviceState / UnsubDeviceState -- OnStateChanged +- OnPropsChanged - NewClient / NewDevice 等工厂方法 --- @@ -229,9 +229,9 @@ func (m *PropertyMapper) ToRaw(enum string) (interface{}, bool) --- -### #9 各设备接口 OnStateChanged 返回值不统一 +### #9 各设备接口 OnPropsChanged 返回值不统一 -**问题**:OnStateChanged 返回 `(string, error)`,但 SubProp/SubEvent 返回 `(string, error)` — 这里它们是统一的。但缺少对 subID 语义的说明。 +**问题**:OnPropsChanged 返回 `(string, error)`,但 SubProp/SubEvent 返回 `(string, error)` — 这里它们是统一的。但缺少对 subID 语义的说明。 **修复方案**:注释统一说明 subID 是订阅标识符,可用于取消。 @@ -276,7 +276,7 @@ func (m *PropertyMapper) ToRaw(enum string) (interface{}, bool) | 5 | P1 | types.go | HomeInfo 加 CityID/Longitude/Latitude/Address | | 6 | P1 | client.go | Client 去掉 homes 缓存字段 | | 7 | P1 | specs/mapper.go(新) | PropertyMapper 双向值映射 | -| 9 | P2 | devices/*.go | OnStateChanged 统一命名返回值 subID | +| 9 | P2 | devices/*.go | OnPropsChanged 统一命名返回值 subID | | 11 | P2 | ARCH_PLAN.md 使用示例 | 更新为 ctx + factory + type switch 模式 | --- diff --git a/miot/miot_client_device.go b/miot/miot_client_device.go index 59d1a08..2ffbfbc 100644 --- a/miot/miot_client_device.go +++ b/miot/miot_client_device.go @@ -4,6 +4,7 @@ package miot import ( "fmt" + "reflect" "regexp" "strings" "time" @@ -103,12 +104,6 @@ func (c *MIoTClient) RefreshDevices(homeIDs []string) error { info[k] = v } - // BLE/proxy devices default online - connectType := getConnectType(info) - if connectType == 23 || connectType == 8 || connectType == 12 || connectType == 0 { - info["online"] = true - } - // Handle sub-devices: .s\d+ suffix match := subDevPattern.FindString(did) if match != "" { @@ -165,20 +160,21 @@ func (c *MIoTClient) RefreshDevices(homeIDs []string) error { } else { c.deviceListCache[did] = cloneMap(info) } - c.lgr.Infof("[MIoTClient] Device added: %s (%v)\n", did, info["name"]) + c.lgr.Debugf("[MIoTClient] Device added: %s (%v)\n", did, info["name"]) changed = true } else { - // Check if online status changed - newOnline := getOnline(info) - oldOnline := getOnline(oldInfo) - if newOnline != oldOnline { - oldInfo["online"] = newOnline - c.deviceListCloud[did] = oldInfo - if cacheInfo, ok := c.deviceListCache[did]; ok { - cacheInfo["online"] = newOnline + // 检测并同步所有变更字段(name/model/nickname 等) + for k, v := range info { + if oldV, ok := oldInfo[k]; !ok || !reflect.DeepEqual(oldV, v) { + oldInfo[k] = v + changed = true + } + } + c.deviceListCloud[did] = oldInfo + if cacheInfo, ok := c.deviceListCache[did]; ok { + for k, v := range info { + cacheInfo[k] = v } - c.lgr.Infof("[MIoTClient] Device online changed: %s online=%v\n", did, newOnline) - changed = true } } } @@ -277,27 +273,43 @@ func (c *MIoTClient) aggregateDeviceState(did string) { return } - // Aggregate from three sources + // OR 聚合:任一来源显式 online=true → 在线; + // 否则任一来源显式 online=false → 离线; + // 否则(无来源提供 online 字段)→ 保留缓存旧值,缓存也没有则默认在线。 online := false + explicitTrue := false + explicitFalse := false - if cloudInfo, exists := c.deviceListCloud[did]; exists { - if o, ok := cloudInfo["online"].(bool); ok && o { - online = true + sources := []map[string]interface{}{ + c.deviceListCloud[did], + c.deviceListGateway[did], + c.deviceListLan[did], + } + for _, info := range sources { + if o, ok := info["online"].(bool); ok { + if o { + explicitTrue = true + } else { + explicitFalse = true + } + } else if _, exists := info["online"]; exists { + c.lgr.Debugf("[MIoTClient] %s online type mismatch: %v (%T)\n", did, info["online"], info["online"]) } } - if !online { - if gwInfo, exists := c.deviceListGateway[did]; exists { - if o, ok := gwInfo["online"].(bool); ok && o { - online = true - } - } - } - if !online { - if lanInfo, exists := c.deviceListLan[did]; exists { - if o, ok := lanInfo["online"].(bool); ok && o { - online = true - } + + switch { + case explicitTrue: + online = true + case explicitFalse: + online = false + default: + if oldOnline, ok := cacheInfo["online"].(bool); ok { + online = oldOnline + } else { + online = true // 云列表里有但没 online 字段,假设在线 } + c.lgr.Debugf("[MIoTClient] %s no online field from cloud/gw/lan, fallback online=%v (cache=%v)\n", + did, online, cacheInfo["online"]) } cacheInfo["online"] = online diff --git a/miot/miot_client_prop.go b/miot/miot_client_prop.go index e11516d..0d82dc9 100644 --- a/miot/miot_client_prop.go +++ b/miot/miot_client_prop.go @@ -21,6 +21,9 @@ func (c *MIoTClient) RefreshDeviceAllProps(did string) error { if !exists { return fmt.Errorf("device %s not found in cache", did) } + if !getOnline(info) { + return fmt.Errorf("device %s is offline", did) + } device := c.getOrCreateDevice(did, info) if device == nil { @@ -41,7 +44,7 @@ func (c *MIoTClient) RefreshDeviceAllProps(did string) error { } } - c.lgr.Infof("[MIoTClient] RefreshDeviceAllProps: %s → %d props enqueued\n", did, count) + c.lgr.Debugf("[MIoTClient] RefreshDeviceAllProps: %s → %d props enqueued\n", did, count) return nil } @@ -59,7 +62,7 @@ func (c *MIoTClient) RequestRefreshProp(did string, siid, piid int) { // Check if already in refresh list if _, exists := c.refreshPropsList[key]; exists { - c.lgr.Infof("[MIoTClient] RequestRefreshProp: SKIPPED (already queued) %s\n", key) + c.lgr.Debugf("[MIoTClient] RequestRefreshProp: SKIPPED (already queued) %s\n", key) return } @@ -71,7 +74,7 @@ func (c *MIoTClient) RequestRefreshProp(did string, siid, piid int) { // Schedule refresh if not already scheduled if c.refreshPropsTimer == nil { - c.lgr.Infof("[MIoTClient] RequestRefreshProp: Scheduling refresh timer for %s\n", key) + c.lgr.Debugf("[MIoTClient] RequestRefreshProp: Scheduling refresh timer for %s\n", key) // Reset retry count for new batch c.refreshRetryCount = 0 @@ -80,14 +83,14 @@ func (c *MIoTClient) RequestRefreshProp(did string, siid, piid int) { c.refreshPropsTimer = nil c.mu.Unlock() - c.lgr.Infof("[MIoTClient] RequestRefreshProp: Timer fired, calling refreshPropsHandler()\n") + c.lgr.Debugf("[MIoTClient] RequestRefreshProp: Timer fired, calling refreshPropsHandler()\n") c.refreshPropsHandler() }) } else { - c.lgr.Infof("[MIoTClient] RequestRefreshProp: Timer already scheduled, added to queue: %s\n", key) + c.lgr.Debugf("[MIoTClient] RequestRefreshProp: Timer already scheduled, added to queue: %s\n", key) } - c.lgr.Infof("[MIoTClient] RequestRefreshProp: QUEUED %s (queue size: %d)\n", key, len(c.refreshPropsList)) + c.lgr.Debugf("[MIoTClient] RequestRefreshProp: QUEUED %s (queue size: %d)\n", key, len(c.refreshPropsList)) } // refreshPropsHandler handles property refresh from cloud. @@ -107,7 +110,7 @@ func (c *MIoTClient) refreshPropsHandler() { c.refreshPropsList = make(map[string]map[string]interface{}) c.mu.Unlock() - c.lgr.Infof("[MIoTClient] refreshPropsHandler: START, %d props to refresh\n", len(propsToRefresh)) + c.lgr.Debugf("[MIoTClient] refreshPropsHandler: START, %d props to refresh\n", len(propsToRefresh)) // Group props by source (cloud / gateway / lan) cloudParams := make([]PropParam, 0) @@ -192,7 +195,7 @@ func (c *MIoTClient) refreshPropsHandler() { return } allResults = append(allResults, results...) - c.lgr.Infof("[MIoTClient] refreshPropsHandler: cloud batch %d returned %d results\n", i/150+1, len(results)) + c.lgr.Debugf("[MIoTClient] refreshPropsHandler: cloud batch %d returned %d results\n", i/150+1, len(results)) } } @@ -223,7 +226,7 @@ func (c *MIoTClient) refreshPropsHandler() { continue } allResults = append(allResults, results...) - c.lgr.Infof("[MIoTClient] refreshPropsHandler: gateway %s returned %d results\n", groupID, len(results)) + c.lgr.Debugf("[MIoTClient] refreshPropsHandler: gateway %s returned %d results\n", groupID, len(results)) } // LAN source @@ -242,7 +245,7 @@ func (c *MIoTClient) refreshPropsHandler() { } } else { allResults = append(allResults, results...) - c.lgr.Infof("[MIoTClient] refreshPropsHandler: LAN returned %d results\n", len(results)) + c.lgr.Debugf("[MIoTClient] refreshPropsHandler: LAN returned %d results\n", len(results)) } } } @@ -252,16 +255,17 @@ func (c *MIoTClient) refreshPropsHandler() { c.refreshRetryCount = 0 c.mu.Unlock() - c.lgr.Infof("[MIoTClient] refreshPropsHandler: total %d results\n", len(allResults)) + c.lgr.Debugf("[MIoTClient] refreshPropsHandler: total %d results\n", len(allResults)) // Dispatch results to subscribers for _, result := range allResults { if result.Code != 0 { - c.lgr.Errorf("[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 (%s)\n", + result.DID, result.Siid, result.Piid, result.Code, ErrDesc(result.Code)) continue } - c.lgr.Infof("[MIoTClient] refreshPropsHandler: SUCCESS %s/%d/%d = %v\n", result.DID, result.Siid, result.Piid, result.Value) + c.lgr.Debugf("[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, diff --git a/miot/miot_client_sub.go b/miot/miot_client_sub.go index fdf14ee..a134b53 100644 --- a/miot/miot_client_sub.go +++ b/miot/miot_client_sub.go @@ -25,7 +25,7 @@ func (c *MIoTClient) SubProp(did string, handler func(map[string]interface{}, in subID := fmt.Sprintf("%p", handler) c.subTree.Sub(topic, handler, nil) - c.lgr.Infof("[MIoTClient] Subscribed to property: %s (topic=%s, subID=%s)\n", did, topic, subID) + c.lgr.Debugf("[MIoTClient] Subscribed to property: %s (topic=%s, subID=%s)\n", did, topic, subID) c.mu.Unlock() @@ -52,7 +52,7 @@ func (c *MIoTClient) UnsubProp(did string, subID string) { // TODO: Implement proper tracking of per-device subscription count // For now, just log - c.lgr.Infof("[MIoTClient] Unsubscribed from property: %s (subID=%s)\n", did, subID) + c.lgr.Debugf("[MIoTClient] Unsubscribed from property: %s (subID=%s)\n", did, subID) } // SubEvent subscribes to device events. @@ -69,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) - c.lgr.Infof("[MIoTClient] Subscribed to event: %s (topic=%s, subID=%s)\n", did, topic, subID) + c.lgr.Debugf("[MIoTClient] Subscribed to event: %s (topic=%s, subID=%s)\n", did, topic, subID) return subID } @@ -83,7 +83,7 @@ func (c *MIoTClient) UnsubEvent(did string, subID string) { // Remove from subTree by subID c.subTree.Unsub(subID) - c.lgr.Infof("[MIoTClient] Unsubscribed from event: %s (subID=%s)\n", did, subID) + c.lgr.Debugf("[MIoTClient] Unsubscribed from event: %s (subID=%s)\n", did, subID) } // updateDeviceMsgSub updates device message subscription. @@ -260,7 +260,7 @@ func (c *MIoTClient) handleMIPSMessage(did string, payload []byte) { // Fallback: binary MIPS protocol (used by gateway mipsLocal) msg := UnpackMipsMessage(payload) if msg == nil || msg.Payload == "" { - c.lgr.Infof("[MIoTClient] handleMIPSMessage: empty or invalid payload for %s (%d bytes)\n", did, len(payload)) + c.lgr.Debugf("[MIoTClient] handleMIPSMessage: empty or invalid payload for %s (%d bytes)\n", did, len(payload)) return } @@ -289,7 +289,7 @@ func (c *MIoTClient) dispatchMIPSMessage(did string, params map[string]interface } else if _, ok := params["eiid"]; ok { c.OnEventMsg(params, nil) } else { - c.lgr.Infof("[MIoTClient] handleMIPSMessage: unknown message format for %s: %v\n", did, params) + c.lgr.Debugf("[MIoTClient] handleMIPSMessage: unknown message format for %s: %v\n", did, params) } } @@ -301,7 +301,7 @@ func (c *MIoTClient) handleMIPSStateMessage(did string, payload []byte) { // Fallback: binary MIPS protocol msg := UnpackMipsMessage(payload) if msg == nil || msg.Payload == "" { - c.lgr.Infof("[MIoTClient] handleMIPSStateMessage: empty payload for %s (%d bytes)\n", did, len(payload)) + c.lgr.Debugf("[MIoTClient] handleMIPSStateMessage: empty payload for %s (%d bytes)\n", did, len(payload)) return } if err := json.Unmarshal([]byte(msg.Payload), ¶ms); err != nil { @@ -326,7 +326,7 @@ func (c *MIoTClient) handleMIPSStateMessage(did string, payload []byte) { } if len(state) == 0 { - c.lgr.Infof("[MIoTClient] handleMIPSStateMessage: no state info in payload for %s: %v\n", did, params) + c.lgr.Debugf("[MIoTClient] handleMIPSStateMessage: no state info in payload for %s: %v\n", did, params) return } @@ -372,7 +372,7 @@ func (c *MIoTClient) OnPropMsg(params map[string]interface{}, ctx interface{}) { } if did == "" { - c.lgr.Infof("[MIoTClient] OnPropMsg: missing did in params=%v\n", params) + c.lgr.Debugf("[MIoTClient] OnPropMsg: missing did in params=%v\n", params) return } @@ -424,7 +424,7 @@ func (c *MIoTClient) OnEventMsg(params map[string]interface{}, ctx interface{}) } if did == "" { - c.lgr.Infof("[MIoTClient] OnEventMsg: missing did in params=%v\n", params) + c.lgr.Debugf("[MIoTClient] OnEventMsg: missing did in params=%v\n", params) return } diff --git a/miot/miot_lan.go b/miot/miot_lan.go index e4b3037..f9a35f1 100644 --- a/miot/miot_lan.go +++ b/miot/miot_lan.go @@ -156,7 +156,7 @@ func newLanDevice(mgr *MIoTLan, did, tokenHex, ip string) (*LanDevice, error) { d.updateKeepAlive(LanDeviceDead) }) - mgr.lgr.Infof("[lan] device added: did=%s", d.DID) + mgr.lgr.Debugf("[lan] device added: did=%s", d.DID) return d, nil } @@ -165,7 +165,7 @@ func (d *LanDevice) KeepAlive(ip, ifName string) { d.IP = ip if d.IfName != ifName { d.IfName = ifName - d.manager.lgr.Infof("[lan] device if_name changed: %s, did=%s", d.IfName, d.DID) + d.manager.lgr.Debugf("[lan] device if_name changed: %s, did=%s", d.IfName, d.DID) } d.updateKeepAlive(LanDeviceFresh) } @@ -316,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 { - d.manager.lgr.Infof("[lan] subscribe error: no result, did=%s, msg=%v", d.DID, msg) + d.manager.lgr.Warnf("[lan] subscribe error: no result, did=%s, msg=%v", d.DID, msg) return } code, _ := result["code"].(float64) if code != 0 { - d.manager.lgr.Infof("[lan] subscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) + d.manager.lgr.Warnf("[lan] subscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) return } d.subscribed = true @@ -330,7 +330,7 @@ func (d *LanDevice) subscribeHandler(msg map[string]interface{}, subTS int) { "online": d.online, "push_available": d.subscribed, }) - d.manager.lgr.Infof("[lan] subscribe success: if=%s, did=%s", d.IfName, d.DID) + d.manager.lgr.Debugf("[lan] subscribe success: if=%s, did=%s", d.IfName, d.DID) } // Unsubscribe sends a miIO.unsub request to the device. @@ -351,11 +351,11 @@ func (d *LanDevice) Unsubscribe() { result, _ := msg["result"].(map[string]interface{}) if result != nil { if code, _ := result["code"].(float64); code != 0 { - d.manager.lgr.Infof("[lan] unsubscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) + d.manager.lgr.Warnf("[lan] unsubscribe error: code=%v, did=%s, msg=%v", code, d.DID, msg) return } } - d.manager.lgr.Infof("[lan] unsubscribe success: if=%s, did=%s", d.IfName, d.DID) + d.manager.lgr.Debugf("[lan] unsubscribe success: if=%s, did=%s", d.IfName, d.DID) }, nil, 5000) d.subscribed = false d.manager.broadcastDeviceState(d.DID, map[string]interface{}{ @@ -374,7 +374,7 @@ func (d *LanDevice) OnDelete() { d.unstableTimer.Stop() d.unstableTimer = nil } - d.manager.lgr.Infof("[lan] device deleted: did=%s", d.DID) + d.manager.lgr.Debugf("[lan] device deleted: did=%s", d.DID) } // UpdateInfo updates the device token if it has changed. @@ -396,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 { - d.manager.lgr.Infof("[lan] update token cipher error: %v, did=%s", err, d.DID) + d.manager.lgr.Warnf("[lan] update token cipher error: %v, did=%s", err, d.DID) return } d.cipher = block d.aesIV = aesIV[:] - d.manager.lgr.Infof("[lan] token updated: did=%s", d.DID) + d.manager.lgr.Debugf("[lan] token updated: did=%s", d.DID) } // ============================================================================ @@ -413,7 +413,7 @@ func (d *LanDevice) updateKeepAlive(state LanDeviceState) { d.state = state if d.state != LanDeviceFresh { - d.manager.lgr.Infof("[lan] device status: did=%s, state=%s", d.DID, d.state) + d.manager.lgr.Debugf("[lan] device status: did=%s, state=%s", d.DID, d.state) } // Cancel existing timer @@ -441,11 +441,11 @@ func (d *LanDevice) updateKeepAlive(state LanDeviceState) { }) // Fast ping if d.IfName == "" { - d.manager.lgr.Infof("[lan] if_name not set for device: did=%s", d.DID) + d.manager.lgr.Debugf("[lan] if_name not set for device: did=%s", d.DID) return } if d.IP == "" { - d.manager.lgr.Infof("[lan] ip not set for device: did=%s", d.DID) + d.manager.lgr.Debugf("[lan] ip not set for device: did=%s", d.DID) return } d.manager.ping(d.IfName, d.IP) @@ -464,7 +464,7 @@ func (d *LanDevice) nextKATimeout() float64 { } func (d *LanDevice) changeOnline(online bool) { - d.manager.lgr.Infof("[lan] change online: did=%s, online=%v", d.DID, online) + d.manager.lgr.Debugf("[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 { @@ -483,9 +483,9 @@ func (d *LanDevice) changeOnline(online bool) { float64(tsNow-d.onlineHistory[0].ts) > networkUnstableTimeTh { d.setOnline(true) } else { - d.manager.lgr.Infof("[lan] unstable device detected: did=%s", d.DID) + d.manager.lgr.Debugf("[lan] unstable device detected: did=%s", d.DID) d.unstableTimer = time.AfterFunc(time.Duration(networkUnstableResumeTh*float64(time.Second)), func() { - d.manager.lgr.Infof("[lan] unstable resume threshold passed: did=%s", d.DID) + d.manager.lgr.Debugf("[lan] unstable resume threshold passed: did=%s", d.DID) d.setOnline(true) }) } @@ -872,7 +872,7 @@ func (m *MIoTLan) createSocketLocked(ifName string) { conn, err := createBoundSocket(info.IP, m.localPort) if err != nil { - m.lgr.Infof("[lan] create socket error: if=%s, err=%v", ifName, err) + m.lgr.Warnf("[lan] create socket error: if=%s, err=%v", ifName, err) return } @@ -944,7 +944,7 @@ func (m *MIoTLan) send(ifName, targetIP string, data []byte) { } else { conn, ok := m.socks[ifName] if !ok { - m.lgr.Infof("[lan] invalid socket: if=%s", ifName) + m.lgr.Debugf("[lan] invalid socket: if=%s", ifName) return } conn.WriteToUDP(data, &net.UDPAddr{IP: net.ParseIP(targetIP), Port: otPort}) @@ -1118,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 { - m.lgr.Infof("[lan] decrypt error: did=%s, err=%v", did, err) + m.lgr.Debugf("[lan] decrypt error: did=%s, err=%v", did, err) return } m.handleMessage(did, msg) @@ -1128,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 { - m.lgr.Infof("[lan] message without id: did=%s, msg=%v", did, msg) + m.lgr.Debugf("[lan] message without id: did=%s, msg=%v", did, msg) return } id, ok := toInt(msgID) @@ -1169,7 +1169,7 @@ func (m *MIoTLan) handleMessage(did string, msg map[string]interface{}) { return } - m.lgr.Infof("[lan] message: did=%s, method=%s", did, method) + m.lgr.Debugf("[lan] message: did=%s, method=%s", did, method) switch method { case "properties_changed": @@ -1264,7 +1264,7 @@ func (m *MIoTLan) scanDevices() { scanTime := m.nextScanTime() m.scanTimer = time.AfterFunc(time.Duration(scanTime*float64(time.Second)), m.scanDevices) - m.lgr.Infof("[lan] next scan in %.0fs", scanTime) + m.lgr.Debugf("[lan] next scan in %.0fs", scanTime) } func (m *MIoTLan) nextScanTime() float64 { @@ -1411,7 +1411,7 @@ func (m *MIoTLan) UpdateDevices(devices map[string]map[string]interface{}) { } model, _ := info["model"].(string) if model != "" && m.profileModels[model] { - m.lgr.Infof("[lan] model not supported for LAN ctrl: did=%s, model=%s", did, model) + m.lgr.Debugf("[lan] model not supported for LAN ctrl: did=%s, model=%s", did, model) continue } if existing, ok := m.devices[did]; ok { @@ -1419,13 +1419,13 @@ func (m *MIoTLan) UpdateDevices(devices map[string]map[string]interface{}) { } else { tokenStr, _ := info["token"].(string) if len(tokenStr) != 32 { - m.lgr.Infof("[lan] invalid token for device: did=%s", did) + m.lgr.Debugf("[lan] invalid token for device: did=%s", did) continue } ip, _ := info["ip"].(string) dev, err := newLanDevice(m, did, tokenStr, ip) if err != nil { - m.lgr.Infof("[lan] create device error: did=%s, err=%v", did, err) + m.lgr.Warnf("[lan] create device error: did=%s, err=%v", did, err) continue } m.devices[did] = dev @@ -1491,7 +1491,7 @@ func (m *MIoTLan) SubProp(did string, handler func(map[string]interface{}, inter } m.cmdCh <- func() { m.matcher.Sub(key, handler, ctx) - m.lgr.Infof("[lan] sub prop: key=%s", key) + m.lgr.Debugf("[lan] sub prop: key=%s", key) } } @@ -1503,7 +1503,7 @@ func (m *MIoTLan) UnsubProp(did string, siid, piid int) { } m.cmdCh <- func() { m.matcher.Unsub(key) - m.lgr.Infof("[lan] unsub prop: key=%s", key) + m.lgr.Debugf("[lan] unsub prop: key=%s", key) } } @@ -1515,7 +1515,7 @@ func (m *MIoTLan) SubEvent(did string, handler func(map[string]interface{}, inte } m.cmdCh <- func() { m.matcher.Sub(key, handler, ctx) - m.lgr.Infof("[lan] sub event: key=%s", key) + m.lgr.Debugf("[lan] sub event: key=%s", key) } } @@ -1527,7 +1527,7 @@ func (m *MIoTLan) UnsubEvent(did string, siid, eiid int) { } m.cmdCh <- func() { m.matcher.Unsub(key) - m.lgr.Infof("[lan] unsub event: key=%s", key) + m.lgr.Debugf("[lan] unsub event: key=%s", key) } } diff --git a/miot/miot_mdns.go b/miot/miot_mdns.go index 1a6ef33..e40f14e 100644 --- a/miot/miot_mdns.go +++ b/miot/miot_mdns.go @@ -289,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 { - s.lgr.Infof("[mdns] query error: %v", err) + s.lgr.Debugf("[mdns] query error: %v", err) } close(entriesCh) }() @@ -311,7 +311,7 @@ func (s *MipsService) runBrowser() { func (s *MipsService) handleEntry(entry *mdns.ServiceEntry) { svc, err := entryToServiceData(entry) if err != nil { - s.lgr.Infof("[mdns] skip entry: %v", err) + s.lgr.Debugf("[mdns] skip entry: %v", err) return } if !svc.validService() { diff --git a/miot/miot_network.go b/miot/miot_network.go index 781f580..74129f7 100644 --- a/miot/miot_network.go +++ b/miot/miot_network.go @@ -530,7 +530,7 @@ func (n *MIoTNetwork) getNetworkInterfaces() map[string]*NetworkInfo { ifaces, err := net.Interfaces() if err != nil { - n.lgr.Infof("[network] get interfaces error: %v", err) + n.lgr.Warnf("[network] get interfaces error: %v", err) return results } diff --git a/xiaomi/README.md b/xiaomi/README.md index d7f70b9..dbcc0e6 100644 --- a/xiaomi/README.md +++ b/xiaomi/README.md @@ -161,7 +161,7 @@ sw.Toggle() on, _ := sw.IsOn() // 订阅状态变化 -sw.OnStateChanged(func(on bool) { ... }) +sw.OnPropsChanged(func(on bool) { ... }) ``` #### 灯光 (`Light`) @@ -180,7 +180,7 @@ k, _ := light.GetColorTemp() light.SetColor(255, 0, 0) // RGB 0-255 r, g, b, _ := light.GetColor() -light.OnStateChanged(func(state LightState) { ... }) +light.OnPropsChanged(func(state LightState) { ... }) ``` #### 空调 (`AirConditioner`) @@ -218,7 +218,7 @@ ac.GetCurrentHumidity() ac.SetAuxHeat(true) ac.SetEco(true) -ac.OnStateChanged(func(state ACState) { ... }) +ac.OnPropsChanged(func(state ACState) { ... }) ``` #### 风扇 (`Fan`) diff --git a/xiaomi/classify.go b/xiaomi/classify.go index d92108a..af126f8 100644 --- a/xiaomi/classify.go +++ b/xiaomi/classify.go @@ -1,6 +1,9 @@ package xiaomi -import "strings" +import ( + "sort" + "strings" +) // deviceTypeToKind maps SPEC URN device types to Kind. // URN format: urn:miot-spec-v2:device:{TYPE}:... @@ -15,7 +18,6 @@ var deviceTypeToKind = map[string]Kind{ "night-light": KindLight, "ceiling-light": KindLight, "switch": KindSwitch, - "outlet": KindSwitch, "powerstrip": KindSwitch, "toggle-switch": KindSwitch, "wireless-switch": KindSwitch, @@ -45,18 +47,39 @@ var deviceTypeToKind = map[string]Kind{ "media-player": KindSpeaker, "playback": KindSpeaker, "music": KindSpeaker, + "gateway": KindGateway, + "plug": KindPlug, + "outlet": KindPlug, + "sensor-occupy": KindOccupancySensor, + "sensor_occupy": KindOccupancySensor, + "occupancy-sensor": KindOccupancySensor, + "occupancy_sensor": KindOccupancySensor, + "motion-sensor": KindOccupancySensor, + "motion_sensor": KindOccupancySensor, + "magnet": KindOccupancySensor, "sensor": KindSensor, "sensor-ht": KindSensor, - "sensor-occupy": KindSensor, - "motion-sensor": KindSensor, - "magnet": KindSensor, - "sensor-smoke": KindSensor, - "sensor-gasm": KindSensor, + "sensor_ht": KindSensor, "sensor-temp": KindSensor, + "sensor_temp": KindSensor, "sensor-humidity": KindSensor, + "sensor_humidity": KindSensor, "illumination": KindSensor, + "sensor-smoke": KindSensor, + "sensor_gasm": KindSensor, + "sensor-gasm": KindSensor, } +// classifyKeys 按长度降序排列的 key,保证长/具体 key 先匹配(如 sensor-occupy 先于 sensor)。 +var classifyKeys = func() []string { + keys := make([]string, 0, len(deviceTypeToKind)) + for k := range deviceTypeToKind { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) }) + return keys +}() + // Classify 从 DeviceInfo 判断设备种类。 // 优先从 URN 的 device type 段精确提取,降至 model 子串匹配。 func Classify(info *DeviceInfo) Kind { @@ -89,11 +112,12 @@ func classifyByURN(urn string) Kind { } // classifyByModel 对 model 字符串做子串匹配(用于无 URN 或 URN 不标准时)。 +// 按 key 长度降序匹配,保证 sensor-occupy 先于 sensor。 func classifyByModel(model string) Kind { model = strings.ToLower(model) - for t, k := range deviceTypeToKind { + for _, t := range classifyKeys { if strings.Contains(model, t) { - return k + return deviceTypeToKind[t] } } return KindUnknown @@ -103,3 +127,15 @@ func classifyByModel(model string) Kind { func FilterByKind(kind Kind) DeviceFilter { return func(d *DeviceInfo) bool { return d.DeviceKind() == kind } } + +// Or 返回任意一个 filter 满足即通过的组合过滤器。 +func Or(filters ...DeviceFilter) DeviceFilter { + return func(d *DeviceInfo) bool { + for _, f := range filters { + if f(d) { + return true + } + } + return false + } +} diff --git a/xiaomi/devices.go b/xiaomi/devices.go index 8ea30ae..5c06f6d 100644 --- a/xiaomi/devices.go +++ b/xiaomi/devices.go @@ -88,6 +88,11 @@ func (c *Client) RefreshDevices(ctx context.Context, homeIDs ...string) error { return c.inner.RefreshDevices(homeIDs) } +// RefreshDeviceAllProps 触发设备全量属性刷新(设备上线后同步状态)。 +func (c *Client) RefreshDeviceAllProps(did string) error { + return c.inner.RefreshDeviceAllProps(did) +} + // GetMIoTDevice 获取底层 MIoTDevice 实例(供设备控制抽象使用) func (c *Client) GetMIoTDevice(did string) *miot.MIoTDevice { // 先尝试缓存 diff --git a/xiaomi/devices/air_conditioner.go b/xiaomi/devices/air_conditioner.go index bb843d1..7265a4e 100644 --- a/xiaomi/devices/air_conditioner.go +++ b/xiaomi/devices/air_conditioner.go @@ -24,10 +24,14 @@ func newValMapper(vl miot.MIoTSpecValueList) *valMapper { } 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) + display := item.Desc + if display == "" { + display = item.Name + } + key := strings.ToLower(display) + vm.val2name[v] = display // 原始描述做展示 + vm.name2val[key] = v // 小写 key 做匹配 + vm.names = append(vm.names, display) } return vm } @@ -39,7 +43,7 @@ func (vm *valMapper) name(v int) string { func (vm *valMapper) val(name string) (int, bool) { if vm == nil { return 0, false } - v, ok := vm.name2val[name] + v, ok := vm.name2val[strings.ToLower(name)] return v, ok } @@ -114,14 +118,19 @@ type AirConditioner interface { SetEco(ctx context.Context, on bool) error GetEco(ctx context.Context) (bool, error) + // 功率 + HasPower() bool + GetElectricPower(ctx context.Context) (float64, error) // 当前电功率(W) + GetPowerConsumption(ctx context.Context) (float64, error) // 累计耗电量(kWh) + // 订阅 - OnStateChanged(handler func(state ACState, changed string)) (string, error) - GetState() ACState - FetchState(ctx context.Context) (ACState, error) + OnPropsChanged(handler func(props ACProps, changed string)) (string, error) + GetProps() ACProps + FetchProps(ctx context.Context) (ACProps, error) } -// ACState 空调完整状态(字段名对齐 SPEC type) -type ACState struct { +// ACProps 空调完整状态(字段名对齐 SPEC type) +type ACProps struct { On bool `json:"on"` Mode string `json:"mode"` // SPEC ValueList 解析后的名称:cool/heat/fan/dry/auto TargetTemperature float64 `json:"targetTemperature"` @@ -132,6 +141,8 @@ type ACState struct { Swing string `json:"swing"` // SPEC ValueList 解析后的名称:off/vertical/horizontal/both AuxHeat bool `json:"auxHeat"` Eco bool `json:"eco"` + ElectricPower float64 `json:"electricPower"` // 当前电功率(W),来自 electric-power + PowerConsumption float64 `json:"powerConsumption"` // 累计耗电量(kWh),来自 power-consumption } type acDevice struct { @@ -146,12 +157,14 @@ type acDevice struct { propCurrentHumidity siidPiid // "relative-humidity" propAuxHeat siidPiid // "aux-heat" or "heater" propEco siidPiid // "eco" + propElectricPower siidPiid // "electric-power" + propPowerConsumption siidPiid // "power-consumption" 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 推送维护的本地缓存 + propsCache ACProps // MQTT 推送维护的本地缓存 } // NewAirConditioner creates an AirConditioner control for the given device. @@ -233,6 +246,12 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi if s, p, ok := resolver.find("eco"); ok { d.propEco = siidPiid{s, p, true} } + if s, p, ok := resolver.find("electric-power"); ok { + d.propElectricPower = siidPiid{s, p, true} + } + if s, p, ok := resolver.find("power-consumption"); ok { + d.propPowerConsumption = siidPiid{s, p, true} + } return d, nil } @@ -270,6 +289,7 @@ func (d *acDevice) HasSwing() bool { return d.propSwing.valid } 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) HasPower() bool { return d.propElectricPower.valid || d.propPowerConsumption.valid } func (d *acDevice) SetMode(ctx context.Context, mode string) error { if !d.propMode.valid { @@ -437,15 +457,37 @@ func (d *acDevice) GetEco(ctx context.Context) (bool, error) { return false, nil } +func (d *acDevice) GetElectricPower(ctx context.Context) (float64, error) { + if !d.propElectricPower.valid { + return 0, xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propElectricPower.siid, d.propElectricPower.piid) + if err != nil { + return 0, err + } + return toFloat(prop.Value), nil +} + +func (d *acDevice) GetPowerConsumption(ctx context.Context) (float64, error) { + if !d.propPowerConsumption.valid { + return 0, xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propPowerConsumption.siid, d.propPowerConsumption.piid) + if err != nil { + return 0, err + } + return toFloat(prop.Value), nil +} + // propField maps a SPEC short name to a siid/piid pair and a cache-update function. type propField struct { siid, piid int name string - fill func(*ACState, interface{}) + fill func(*ACProps, interface{}) valType byte // 'b'=bool, 'i'=int, 'f'=float, 's'=string } -func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) (string, error) { +func (d *acDevice) OnPropsChanged(handler func(props ACProps, changed string)) (string, error) { fields := d.propFields() if len(fields) == 0 { return "", xiaomi.ErrNotSupported @@ -467,8 +509,8 @@ func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) ( return } } - fields[idx].fill(&d.stateCache, prop.Value) - handler(d.stateCache, f.name) + fields[idx].fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) }) if err != nil { return "", err @@ -483,7 +525,7 @@ func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) ( func (d *acDevice) propFields() []propField { r := d.getSpecResolver() var fields []propField - add := func(sp siidPiid, name string, specType string, fill func(*ACState, interface{})) { + add := func(sp siidPiid, name string, specType string, fill func(*ACProps, interface{})) { if sp.valid { vt := byte(0) switch specType { @@ -499,22 +541,24 @@ func (d *acDevice) propFields() []propField { fields = append(fields, propField{sp.siid, sp.piid, name, fill, vt}) } } - 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{}) { + add(d.propOn, "on", specFormat(r, "on"), func(s *ACProps, v interface{}) { s.On, _ = v.(bool) }) + add(d.propMode, "mode", specFormat(r, "mode"), func(s *ACProps, v interface{}) { s.Mode = d.modeMap.name(toInt(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{}) { + add(d.propTargetTemp, "target-temperature", specFormat(r, "target-temperature"), func(s *ACProps, v interface{}) { s.TargetTemperature = toFloat(v) }) + add(d.propCurrentTemp, "temperature", specFormat(r, "temperature"), func(s *ACProps, v interface{}) { s.Temperature = toFloat(v) }) + add(d.propFanSpeed, "fan-level", specFormat(r, "fan-level"), func(s *ACProps, v interface{}) { s.FanLevel = d.fanMap.name(toInt(v)) }) - add(d.propSwing, "swing", specFormat(r, "swing-mode"), func(s *ACState, v interface{}) { + add(d.propSwing, "swing", specFormat(r, "swing-mode"), func(s *ACProps, v interface{}) { s.Swing = d.swingMap.name(toInt(v)) }) - 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) }) + add(d.propTargetHumidity, "target-humidity", specFormat(r, "target-humidity"), func(s *ACProps, v interface{}) { s.TargetHumidity = int(toFloat(v)) }) + add(d.propCurrentHumidity, "relative-humidity", specFormat(r, "relative-humidity"), func(s *ACProps, v interface{}) { s.CurrentHumidity = int(toFloat(v)) }) + add(d.propAuxHeat, "heater", specFormat(r, "heater"), func(s *ACProps, v interface{}) { s.AuxHeat, _ = v.(bool) }) + add(d.propEco, "eco", specFormat(r, "eco"), func(s *ACProps, v interface{}) { s.Eco, _ = v.(bool) }) + add(d.propElectricPower, "electric-power", specFormat(r, "electric-power"), func(s *ACProps, v interface{}) { s.ElectricPower = toFloat(v) }) + add(d.propPowerConsumption, "power-consumption", specFormat(r, "power-consumption"), func(s *ACProps, v interface{}) { s.PowerConsumption = toFloat(v) }) return fields } @@ -540,9 +584,26 @@ func toFloat(v interface{}) float64 { return 0 } -func (d *acDevice) GetState() ACState { return d.stateCache } +// toBool converts an interface{} to bool. 支持 bool 及数值(0/1)类型。 +func toBool(v interface{}) bool { + switch n := v.(type) { + case bool: + return n + case float64: + return n != 0 + case float32: + return n != 0 + case int: + return n != 0 + case int64: + return n != 0 + } + return false +} -func (d *acDevice) FetchState(ctx context.Context) (ACState, error) { +func (d *acDevice) GetProps() ACProps { return d.propsCache } + +func (d *acDevice) FetchProps(ctx context.Context) (ACProps, error) { fields := d.propFields() keys := make([]xiaomi.PropKey, len(fields)) for i, f := range fields { @@ -550,17 +611,17 @@ func (d *acDevice) FetchState(ctx context.Context) (ACState, error) { } props, err := d.client.GetProps(ctx, keys) if err != nil { - return d.stateCache, err + return d.propsCache, err } - state := ACState{} + result := ACProps{} for _, p := range props { for _, f := range fields { if f.siid == p.SIID && f.piid == p.PIID { - f.fill(&state, p.Value) + f.fill(&result, p.Value) break } } } - d.stateCache = state - return state, nil + d.propsCache = result + return result, nil } diff --git a/xiaomi/devices/base.go b/xiaomi/devices/base.go index c931b04..b15efd7 100644 --- a/xiaomi/devices/base.go +++ b/xiaomi/devices/base.go @@ -55,19 +55,31 @@ func (d *BaseDevice) DID() string { // Name returns the device's display name. func (d *BaseDevice) Name() string { + d.refreshInfo() return d.info.Name } // Model returns the device's model identifier. func (d *BaseDevice) Model() string { + d.refreshInfo() return d.info.Model } // Online returns whether the device is currently online. func (d *BaseDevice) Online() bool { + d.refreshInfo() return d.info.Online } +// refreshInfo 从客户端缓存拉取最新设备信息,有变化时原地更新。 +func (d *BaseDevice) refreshInfo() { + info, err := d.client.GetDevice(context.Background(), d.info.DID) + if err != nil || info == nil { + return + } + d.info = info +} + // Info returns the full device information. func (d *BaseDevice) Info() *xiaomi.DeviceInfo { return d.info @@ -83,6 +95,12 @@ func (d *BaseDevice) SetProp(ctx context.Context, siid, piid int, value interfac return d.client.SetProp(ctx, d.info.DID, siid, piid, value) } +// Ping 通过实际读属性验证设备是否在线,不走缓存。 +func (d *BaseDevice) Ping(ctx context.Context, siid, piid int) bool { + _, err := d.GetProp(ctx, siid, piid) + return err == nil +} + // Action invokes a device action. func (d *BaseDevice) Action(ctx context.Context, siid, aiid int, params []interface{}) (*xiaomi.ActionResult, error) { return d.client.Action(ctx, d.info.DID, siid, aiid, params) @@ -168,6 +186,14 @@ func (r *specResolver) find(propType string) (siid, piid int, found bool) { return 0, 0, false } +// prop returns the full SPEC property for the given type name. +func (r *specResolver) prop(propType string) *miot.MIoTSpecProperty { + if r == nil { + return nil + } + return r.byType[propType] +} + // format returns the SPEC Format for a property type ("bool", "uint8", "float", "string"). func (r *specResolver) format(propType string) string { if r == nil { diff --git a/xiaomi/devices/cover.go b/xiaomi/devices/cover.go index 262e207..e10cbe8 100644 --- a/xiaomi/devices/cover.go +++ b/xiaomi/devices/cover.go @@ -15,9 +15,9 @@ type Cover interface { SetPosition(ctx context.Context, pct int) error GetPosition(ctx context.Context) (int, error) - OnStateChanged(handler func(position int)) (string, error) - GetState() int - FetchState(ctx context.Context) (int, error) + OnPropsChanged(handler func(position int)) (string, error) + GetProps() int + FetchProps(ctx context.Context) (int, error) } type coverDevice struct { @@ -25,7 +25,7 @@ type coverDevice struct { propMotor siidPiid propPosition siidPiid - stateCache int + propsCache int } const ( @@ -74,20 +74,20 @@ func (d *coverDevice) GetPosition(ctx context.Context) (int, error) { return toInt(prop.Value), nil } -func (d *coverDevice) OnStateChanged(handler func(position int)) (string, error) { +func (d *coverDevice) OnPropsChanged(handler func(position int)) (string, error) { if !d.propPosition.valid { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { if prop.SIID == d.propPosition.siid && prop.PIID == d.propPosition.piid { - d.stateCache = toInt(prop.Value) - handler(d.stateCache) + d.propsCache = toInt(prop.Value) + handler(d.propsCache) } }) } -func (d *coverDevice) GetState() int { return d.stateCache } -func (d *coverDevice) FetchState(ctx context.Context) (int, error) { +func (d *coverDevice) GetProps() int { return d.propsCache } +func (d *coverDevice) FetchProps(ctx context.Context) (int, error) { pos, err := d.GetPosition(ctx) - if err != nil { return d.stateCache, err } - d.stateCache = pos + if err != nil { return d.propsCache, err } + d.propsCache = pos return pos, nil } diff --git a/xiaomi/devices/factory.go b/xiaomi/devices/factory.go index 4fe8e50..72c3906 100644 --- a/xiaomi/devices/factory.go +++ b/xiaomi/devices/factory.go @@ -69,8 +69,10 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er ctrl, err = NewAirConditioner(client, info) case matchURN(urn, "light", "lightbulb", "night-light", "ceiling-light"): ctrl, err = NewLight(client, info) - case matchURN(urn, "switch", "outlet", "powerstrip", "toggle-switch", "wireless-switch", "curtain-switch"): + case matchURN(urn, "switch", "powerstrip", "toggle-switch", "wireless-switch", "curtain-switch"): ctrl, err = NewSwitch(client, info) + case matchURN(urn, "plug", "outlet"): + ctrl, err = NewPlug(client, info) case matchURN(urn, "fan", "ceiling-fan", "ventilator", "air-fresh", "air-purifier"): ctrl, err = NewFan(client, info) case matchURN(urn, "curtain", "airer-rack", "airer"): @@ -83,12 +85,14 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er ctrl, err = NewWaterHeater(client, info) case matchURN(urn, "heater", "electric-blanket", "thermostat"): ctrl, err = NewThermostat(client, info) - case matchURN(urn, "sensor-occupy", "motion-sensor", "magnet"): + case matchURN(urn, "sensor-occupy", "sensor_occupy", "occupancy-sensor", "occupancy_sensor", "motion-sensor", "motion_sensor", "magnet"): ctrl, err = NewOccupancySensor(client, info) - case matchURN(urn, "sensor-temp", "sensor-humidity", "sensor-ht", "sensor-smoke", "sensor-gasm", "illumination"): + case matchURN(urn, "sensor-temp", "sensor_temp", "sensor-humidity", "sensor_humidity", "sensor-ht", "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) + case matchURN(urn, "gateway"): + ctrl, err = NewGateway(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 65de7f1..03856b3 100644 --- a/xiaomi/devices/fan.go +++ b/xiaomi/devices/fan.go @@ -2,34 +2,47 @@ package devices import ( "context" + + "xiaomihome/miot" "xiaomihome/xiaomi" ) -// Fan 风扇统一控制接口 +// Fan 风扇/新风机统一控制接口。 +// 风扇和新风都有 mode 和 fan-level,但部分新风机没有 fan-level,此时用 mode 控制。 type Fan interface { Device TurnOn(ctx context.Context) error TurnOff(ctx context.Context) error IsOn(ctx context.Context) (bool, error) - SetFanLevel(ctx context.Context, level int) error // 0=off, 1-4 档位 + // 风速控制(有 fan-level 时可用) + HasFanLevel() bool + FanLevelValues() []int + SetFanLevel(ctx context.Context, level int) error GetFanLevel(ctx context.Context) (int, error) + // 模式控制(有 mode+ValueList 时可用,返回原始 uint8 值) + HasMode() bool + ModeValues() []int + SetMode(ctx context.Context, mode int) error + GetMode(ctx context.Context) (int, error) + SetOscillation(ctx context.Context, on bool) error IsOscillating(ctx context.Context) (bool, error) SetNaturalWind(ctx context.Context, on bool) error IsNaturalWind(ctx context.Context) (bool, error) - OnStateChanged(handler func(state FanState, changed string)) (string, error) - GetState() FanState - FetchState(ctx context.Context) (FanState, error) + OnPropsChanged(handler func(props FanProps, changed string)) (string, error) + GetProps() FanProps + FetchProps(ctx context.Context) (FanProps, error) } -// FanState 风扇完整状态(MQTT 缓存) -type FanState struct { +// FanProps 风扇/新风机完整状态(MQTT 缓存) +type FanProps struct { On bool `json:"on"` FanLevel int `json:"fanLevel"` + Mode int `json:"mode"` Oscillation bool `json:"oscillation"` NaturalWind bool `json:"naturalWind"` } @@ -38,10 +51,14 @@ type fanDevice struct { BaseDevice propOn siidPiid // "on" propFanLevel siidPiid // "fan-level" + propMode siidPiid // "mode" — 有 ValueList 时为模式选择器 + propNaturalWind siidPiid // "mode" — 无 ValueList 时为自然风开关 propOscillation siidPiid // "horizontal-swing" or "oscillation" - propNaturalWind siidPiid // "mode" (natural wind mode) - stateCache FanState + modeValues []int // 可用模式值列表(来自 SPEC value-list) + fanLevelValues []int // 可用风速档位值列表 + + propsCache FanProps } // NewFan creates a Fan control for the given device. @@ -63,12 +80,36 @@ func NewFan(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Fan, error) { if s, p, ok := resolver.find("fan-level"); ok { d.propFanLevel = siidPiid{s, p, true} + if prop := resolver.prop("fan-level"); prop != nil { + if len(prop.ValueList) > 0 { + for _, item := range prop.ValueList { + d.fanLevelValues = append(d.fanLevelValues, toInt(item.Value)) + } + } else if prop.ValueRange != nil { + d.fanLevelValues = rangeValues(prop.ValueRange) + } + } } if s, p, ok := resolver.find("horizontal-swing"); ok { d.propOscillation = siidPiid{s, p, true} } + + // mode 属性:有 ValueList → 模式选择器,有 ValueRange → 连续值,否则 → 自然风开关 if s, p, ok := resolver.find("mode"); ok { - d.propNaturalWind = siidPiid{s, p, true} + if prop := resolver.prop("mode"); prop != nil { + if len(prop.ValueList) > 0 { + d.propMode = siidPiid{s, p, true} + for _, item := range prop.ValueList { + d.modeValues = append(d.modeValues, toInt(item.Value)) + } + } else if prop.ValueRange != nil { + d.propMode = siidPiid{s, p, true} + d.modeValues = rangeValues(prop.ValueRange) + } + } + if !d.propMode.valid { + d.propNaturalWind = siidPiid{s, p, true} + } } return d, nil @@ -102,6 +143,10 @@ func (d *fanDevice) IsOn(ctx context.Context) (bool, error) { return false, nil } +func (d *fanDevice) HasFanLevel() bool { return d.propFanLevel.valid } + +func (d *fanDevice) FanLevelValues() []int { return d.fanLevelValues } + func (d *fanDevice) SetFanLevel(ctx context.Context, level int) error { if !d.propFanLevel.valid { return xiaomi.ErrNotSupported @@ -120,6 +165,28 @@ func (d *fanDevice) GetFanLevel(ctx context.Context) (int, error) { return toInt(prop.Value), nil } +func (d *fanDevice) HasMode() bool { return d.propMode.valid } + +func (d *fanDevice) ModeValues() []int { return d.modeValues } + +func (d *fanDevice) SetMode(ctx context.Context, mode int) error { + if !d.propMode.valid { + return xiaomi.ErrNotSupported + } + return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, mode) +} + +func (d *fanDevice) GetMode(ctx context.Context) (int, error) { + if !d.propMode.valid { + return 0, xiaomi.ErrNotSupported + } + prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid) + if err != nil { + return 0, err + } + return toInt(prop.Value), nil +} + func (d *fanDevice) SetOscillation(ctx context.Context, on bool) error { if !d.propOscillation.valid { return xiaomi.ErrNotSupported @@ -145,11 +212,11 @@ func (d *fanDevice) SetNaturalWind(ctx context.Context, on bool) error { if !d.propNaturalWind.valid { return xiaomi.ErrNotSupported } - mode := 0 // normal + v := 0 if on { - mode = 1 // natural wind + v = 1 } - return d.SetProp(ctx, d.propNaturalWind.siid, d.propNaturalWind.piid, mode) + return d.SetProp(ctx, d.propNaturalWind.siid, d.propNaturalWind.piid, v) } func (d *fanDevice) IsNaturalWind(ctx context.Context) (bool, error) { @@ -163,57 +230,105 @@ func (d *fanDevice) IsNaturalWind(ctx context.Context) (bool, error) { return toInt(prop.Value) == 1, nil } -func (d *fanDevice) OnStateChanged(handler func(state FanState, changed string)) (string, error) { - type field struct{ siid, piid int; name string; fill func(*FanState, interface{}) } +func (d *fanDevice) OnPropsChanged(handler func(props FanProps, changed string)) (string, error) { + type field struct { + siid, piid int + name string + fill func(*FanProps, interface{}) + } var fields []field if d.propOn.valid { fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", - func(s *FanState, v interface{}) { s.On, _ = v.(bool) }}) + func(s *FanProps, v interface{}) { s.On, _ = v.(bool) }}) } if d.propFanLevel.valid { fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", - func(s *FanState, v interface{}) { s.FanLevel = toInt(v) }}) + func(s *FanProps, v interface{}) { s.FanLevel = toInt(v) }}) + } + if d.propMode.valid { + fields = append(fields, field{d.propMode.siid, d.propMode.piid, "mode", + func(s *FanProps, v interface{}) { s.Mode = toInt(v) }}) } if d.propOscillation.valid { - fields = append(fields, field{d.propOscillation.siid, d.propOscillation.piid, "swing", - func(s *FanState, v interface{}) { s.Oscillation, _ = v.(bool) }}) + fields = append(fields, field{d.propOscillation.siid, d.propOscillation.piid, "oscillation", + func(s *FanProps, v interface{}) { s.Oscillation, _ = v.(bool) }}) } if d.propNaturalWind.valid { - fields = append(fields, field{d.propNaturalWind.siid, d.propNaturalWind.piid, "mode", - func(s *FanState, v interface{}) { s.NaturalWind = toInt(v) == 1 }}) + fields = append(fields, field{d.propNaturalWind.siid, d.propNaturalWind.piid, "natural-wind", + func(s *FanProps, v interface{}) { s.NaturalWind = toInt(v) == 1 }}) } + if len(fields) == 0 { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { for _, f := range fields { if f.siid == prop.SIID && f.piid == prop.PIID { - f.fill(&d.stateCache, prop.Value) - handler(d.stateCache, f.name) + f.fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) return } } }) } -func (d *fanDevice) GetState() FanState { return d.stateCache } -func (d *fanDevice) FetchState(ctx context.Context) (FanState, error) { +func (d *fanDevice) GetProps() FanProps { return d.propsCache } + +func (d *fanDevice) FetchProps(ctx context.Context) (FanProps, 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}) } + 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.propMode.valid { + keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propMode.siid, PIID: d.propMode.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{} + if err != nil { + return d.propsCache, err + } + result := FanProps{} 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 + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: + result.On, _ = p.Value.(bool) + case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid: + result.FanLevel = toInt(p.Value) + case d.propMode.valid && p.SIID == d.propMode.siid && p.PIID == d.propMode.piid: + result.Mode = toInt(p.Value) + case d.propOscillation.valid && p.SIID == d.propOscillation.siid && p.PIID == d.propOscillation.piid: + result.Oscillation, _ = p.Value.(bool) + case d.propNaturalWind.valid && p.SIID == d.propNaturalWind.siid && p.PIID == d.propNaturalWind.piid: + result.NaturalWind = toInt(p.Value) == 1 } } - d.stateCache = state - return state, nil + d.propsCache = result + return result, nil +} + +// rangeValues 从 SPEC value-range 生成离散值列表,如 [1,8,1] → [1,2,3,4,5,6,7,8] +func rangeValues(r *miot.MIoTSpecValueRange) []int { + if r == nil { + return nil + } + min, max, step := toInt(r.Min), toInt(r.Max), toInt(r.Step) + if step <= 0 { + step = 1 + } + if max > 100 { + return nil // 太大会爆内存,返回 nil 让调用方自行处理 + } + var vals []int + for i := min; i <= max; i += step { + vals = append(vals, i) + } + return vals } diff --git a/xiaomi/devices/humidifier.go b/xiaomi/devices/humidifier.go index 5e586a6..d10d32c 100644 --- a/xiaomi/devices/humidifier.go +++ b/xiaomi/devices/humidifier.go @@ -16,13 +16,13 @@ type Humidifier interface { SetFanLevel(ctx context.Context, level int) error GetFanLevel(ctx context.Context) (int, error) - OnStateChanged(handler func(state HumidifierState, changed string)) (string, error) - GetState() HumidifierState - FetchState(ctx context.Context) (HumidifierState, error) + OnPropsChanged(handler func(props HumidifierProps, changed string)) (string, error) + GetProps() HumidifierProps + FetchProps(ctx context.Context) (HumidifierProps, error) } -// HumidifierState 加湿器状态(MQTT 缓存) -type HumidifierState struct { +// HumidifierProps 加湿器状态(MQTT 缓存) +type HumidifierProps struct { On bool `json:"on"` TargetHumidity int `json:"targetHumidity"` FanLevel int `json:"fanLevel"` @@ -33,7 +33,7 @@ type humidifierDevice struct { propOn siidPiid propTargetHumidity siidPiid propFanLevel siidPiid - stateCache HumidifierState + propsCache HumidifierProps } func NewHumidifier(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Humidifier, error) { @@ -84,46 +84,46 @@ func (d *humidifierDevice) GetFanLevel(ctx context.Context) (int, error) { return toInt(prop.Value), nil } -func (d *humidifierDevice) OnStateChanged(handler func(state HumidifierState, changed string)) (string, error) { - type field struct{ siid, piid int; name string; fill func(*HumidifierState, interface{}) } +func (d *humidifierDevice) OnPropsChanged(handler func(props HumidifierProps, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*HumidifierProps, interface{}) } var fields []field if d.propOn.valid { - fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HumidifierState, v interface{}) { s.On, _ = v.(bool) }}) + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HumidifierProps, v interface{}) { s.On, _ = v.(bool) }}) } if d.propTargetHumidity.valid { - fields = append(fields, field{d.propTargetHumidity.siid, d.propTargetHumidity.piid, "target-humidity", func(s *HumidifierState, v interface{}) { s.TargetHumidity = toInt(v) }}) + fields = append(fields, field{d.propTargetHumidity.siid, d.propTargetHumidity.piid, "target-humidity", func(s *HumidifierProps, v interface{}) { s.TargetHumidity = toInt(v) }}) } if d.propFanLevel.valid { - fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", func(s *HumidifierState, v interface{}) { s.FanLevel = toInt(v) }}) + fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", func(s *HumidifierProps, v interface{}) { s.FanLevel = toInt(v) }}) } if len(fields) == 0 { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { for _, f := range fields { if f.siid == prop.SIID && f.piid == prop.PIID { - f.fill(&d.stateCache, prop.Value) - handler(d.stateCache, f.name) + f.fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) return } } }) } -func (d *humidifierDevice) GetState() HumidifierState { return d.stateCache } -func (d *humidifierDevice) FetchState(ctx context.Context) (HumidifierState, error) { +func (d *humidifierDevice) GetProps() HumidifierProps { return d.propsCache } +func (d *humidifierDevice) FetchProps(ctx context.Context) (HumidifierProps, 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{} + if err != nil { return d.propsCache, err } + result := HumidifierProps{} 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) + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: result.On, _ = p.Value.(bool) + case d.propTargetHumidity.valid && p.SIID == d.propTargetHumidity.siid && p.PIID == d.propTargetHumidity.piid: result.TargetHumidity = toInt(p.Value) + case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid: result.FanLevel = toInt(p.Value) } } - d.stateCache = state - return state, nil + d.propsCache = result + return result, nil } diff --git a/xiaomi/devices/light.go b/xiaomi/devices/light.go index 376c027..0a1bc08 100644 --- a/xiaomi/devices/light.go +++ b/xiaomi/devices/light.go @@ -2,6 +2,8 @@ package devices import ( "context" + "strconv" + "xiaomihome/xiaomi" ) @@ -29,13 +31,13 @@ type Light interface { SetColor(ctx context.Context, r, g, b int) error GetColor(ctx context.Context) (r, g, b int, err error) - OnStateChanged(handler func(state LightState, changed string)) (string, error) - GetState() LightState - FetchState(ctx context.Context) (LightState, error) + OnPropsChanged(handler func(props LightProps, changed string)) (string, error) + GetProps() LightProps + FetchProps(ctx context.Context) (LightProps, error) } -// LightState 灯光完整状态(字段名对齐 SPEC type) -type LightState struct { +// LightProps 灯光完整状态(字段名对齐 SPEC type) +type LightProps struct { On bool `json:"on"` // SPEC: "on" Brightness int `json:"brightness"` // SPEC: "brightness" ColorTemperature int `json:"colorTemperature"` // SPEC: "color-temperature" @@ -49,7 +51,7 @@ type lightDevice struct { propColorTemp siidPiid // "color-temperature" propColor siidPiid // "color" - stateCache LightState // MQTT 推送维护的本地缓存 + propsCache LightProps // MQTT 推送维护的本地缓存 } // NewLight creates a Light control for the given device. @@ -183,28 +185,28 @@ func (d *lightDevice) GetColor(ctx context.Context) (r, g, b int, err error) { return r, g, b, nil } -func (d *lightDevice) OnStateChanged(handler func(state LightState, changed string)) (string, error) { +func (d *lightDevice) OnPropsChanged(handler func(props LightProps, changed string)) (string, error) { type field struct { siid, piid int name string - fill func(*LightState, interface{}) + fill func(*LightProps, interface{}) } var fields []field if d.propOn.valid { fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", - func(s *LightState, v interface{}) { s.On, _ = v.(bool) }}) + func(s *LightProps, v interface{}) { s.On, _ = v.(bool) }}) } if d.propBrightness.valid { fields = append(fields, field{d.propBrightness.siid, d.propBrightness.piid, "brightness", - func(s *LightState, v interface{}) { s.Brightness = toInt(v) }}) + func(s *LightProps, v interface{}) { s.Brightness = toInt(v) }}) } if d.propColorTemp.valid { fields = append(fields, field{d.propColorTemp.siid, d.propColorTemp.piid, "color-temperature", - func(s *LightState, v interface{}) { s.ColorTemperature = toInt(v) }}) + func(s *LightProps, v interface{}) { s.ColorTemperature = toInt(v) }}) } if d.propColor.valid { fields = append(fields, field{d.propColor.siid, d.propColor.piid, "color", - func(s *LightState, v interface{}) { + func(s *LightProps, v interface{}) { if v, ok := v.(float64); ok { c := int(v) s.ColorRGB = [3]int{(c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF} @@ -217,8 +219,8 @@ func (d *lightDevice) OnStateChanged(handler func(state LightState, changed stri 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) + f.fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) return } } @@ -240,29 +242,32 @@ func toInt(v interface{}) int { return int(n) case uint32: return int(n) + case string: + i, _ := strconv.Atoi(n) + return i } return 0 } -func (d *lightDevice) GetState() LightState { return d.stateCache } -func (d *lightDevice) FetchState(ctx context.Context) (LightState, error) { +func (d *lightDevice) GetProps() LightProps { return d.propsCache } +func (d *lightDevice) FetchProps(ctx context.Context) (LightProps, 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{} + if err != nil { return d.propsCache, err } + result := LightProps{} 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.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: result.On, _ = p.Value.(bool) + case d.propBrightness.valid && p.SIID == d.propBrightness.siid && p.PIID == d.propBrightness.piid: result.Brightness = toInt(p.Value) + case d.propColorTemp.valid && p.SIID == d.propColorTemp.siid && p.PIID == d.propColorTemp.piid: result.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} } + if v, ok := p.Value.(float64); ok { c := int(v); result.ColorRGB = [3]int{(c>>16)&0xFF, (c>>8)&0xFF, c&0xFF} } } } - d.stateCache = state - return state, nil + d.propsCache = result + return result, nil } diff --git a/xiaomi/devices/occupancy_sensor.go b/xiaomi/devices/occupancy_sensor.go index c1a5b9b..a5216c1 100644 --- a/xiaomi/devices/occupancy_sensor.go +++ b/xiaomi/devices/occupancy_sensor.go @@ -13,16 +13,25 @@ type OccupancySensor interface { IsOccupied(ctx context.Context) (bool, error) 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) + OnPropsChanged(handler func(occupied bool, illuminance float64)) (string, error) + GetProps() (bool, float64) + FetchProps(ctx context.Context) (bool, float64, error) } type occupancyDevice struct { BaseDevice - propOccupancy siidPiid - propIlluminance siidPiid - stateCache [2]interface{} // [0]=bool occupied, [1]=float64 lux + propOccupancy siidPiid // "occupancy" / "occupancy-status"(bool/uint8) + propNoMotion siidPiid // "no-motion-duration"(uint16,linp.motion.* 等无 occupancy 属性的设备) + propIlluminance siidPiid // "illumination" + propsCache [2]interface{} // [0]=bool occupied, [1]=float64 lux +} + +// occupied 根据设备支持的属性类型计算有人状态。 +func (d *occupancyDevice) occupied(v interface{}) bool { + if d.propOccupancy.valid { + return toBool(v) + } + return toFloat(v) == 0 // no-motion-duration==0 → 有人 } func NewOccupancySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (OccupancySensor, error) { @@ -35,22 +44,34 @@ func NewOccupancySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Occupan for _, svc := range spec.Services { for _, prop := range svc.Properties { switch propertyShortName(prop.Type) { - case "occupancy": + case "occupancy", "occupancy-status": if !d.propOccupancy.valid { d.propOccupancy = siidPiid{svc.IID, prop.PIID, true} } + case "no-motion-duration": + if !d.propNoMotion.valid { d.propNoMotion = siidPiid{svc.IID, prop.PIID, true} } case "illumination": if !d.propIlluminance.valid { d.propIlluminance = siidPiid{svc.IID, prop.PIID, true} } } } } - if !d.propOccupancy.valid { return nil, fmt.Errorf("%w: no occupancy property in SPEC", xiaomi.ErrNotSupported) } + if !d.propOccupancy.valid && !d.propNoMotion.valid { + return nil, fmt.Errorf("%w: no occupancy/no-motion-duration property in SPEC", xiaomi.ErrNotSupported) + } return d, nil } +// occupiedProp 返回判断有人状态所用的属性。 +func (d *occupancyDevice) occupiedProp() siidPiid { + if d.propOccupancy.valid { + return d.propOccupancy + } + return d.propNoMotion +} + func (d *occupancyDevice) IsOccupied(ctx context.Context) (bool, error) { - prop, err := d.GetProp(ctx, d.propOccupancy.siid, d.propOccupancy.piid) + p := d.occupiedProp() + prop, err := d.GetProp(ctx, p.siid, p.piid) if err != nil { return false, err } - if b, ok := prop.Value.(bool); ok { return b, nil } - return false, nil + return d.occupied(prop.Value), nil } func (d *occupancyDevice) GetIlluminance(ctx context.Context) (float64, error) { if !d.propIlluminance.valid { return 0, xiaomi.ErrNotSupported } @@ -59,46 +80,49 @@ func (d *occupancyDevice) GetIlluminance(ctx context.Context) (float64, error) { return toFloat(prop.Value), nil } -func (d *occupancyDevice) OnStateChanged(handler func(occupied bool, illuminance float64)) (string, error) { - if !d.propOccupancy.valid && !d.propIlluminance.valid { return "", xiaomi.ErrNotSupported } +func (d *occupancyDevice) OnPropsChanged(handler func(occupied bool, illuminance float64)) (string, error) { + if !d.propOccupancy.valid && !d.propNoMotion.valid && !d.propIlluminance.valid { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { switch { case d.propOccupancy.valid && prop.SIID == d.propOccupancy.siid && prop.PIID == d.propOccupancy.piid: - if b, ok := prop.Value.(bool); ok { d.stateCache[0] = b } + d.propsCache[0] = toBool(prop.Value) + case d.propNoMotion.valid && prop.SIID == d.propNoMotion.siid && prop.PIID == d.propNoMotion.piid: + d.propsCache[0] = toFloat(prop.Value) == 0 case d.propIlluminance.valid && prop.SIID == d.propIlluminance.siid && prop.PIID == d.propIlluminance.piid: - d.stateCache[1] = toFloat(prop.Value) + d.propsCache[1] = toFloat(prop.Value) } - occ, _ := d.stateCache[0].(bool) - lux, _ := d.stateCache[1].(float64) + occ, _ := d.propsCache[0].(bool) + lux, _ := d.propsCache[1].(float64) handler(occ, lux) }) } -func (d *occupancyDevice) GetState() (bool, float64) { - b, _ := d.stateCache[0].(bool) - f, _ := d.stateCache[1].(float64) +func (d *occupancyDevice) GetProps() (bool, float64) { + b, _ := d.propsCache[0].(bool) + f, _ := d.propsCache[1].(float64) return b, f } -func (d *occupancyDevice) FetchState(ctx context.Context) (bool, float64, error) { +func (d *occupancyDevice) FetchProps(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}) } + p := d.occupiedProp() + if p.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: p.siid, PIID: p.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) + b, _ := d.propsCache[0].(bool) + f, _ := d.propsCache[1].(float64) return b, f, err } var occupied bool var illuminance float64 - for _, p := range props { + for _, r := 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) + case p.valid && r.SIID == p.siid && r.PIID == p.piid: + occupied = d.occupied(r.Value) + case d.propIlluminance.valid && r.SIID == d.propIlluminance.siid && r.PIID == d.propIlluminance.piid: + illuminance = toFloat(r.Value) } } - d.stateCache = [2]interface{}{occupied, illuminance} + d.propsCache = [2]interface{}{occupied, illuminance} return occupied, illuminance, nil } diff --git a/xiaomi/devices/properties.go b/xiaomi/devices/properties.go index db14010..49292b7 100644 --- a/xiaomi/devices/properties.go +++ b/xiaomi/devices/properties.go @@ -1,7 +1,28 @@ package devices +// ── 设备类型常量 ── +// 用于 bridge.OnPropsChanged 的 deviceType 字段 / factory 分类 + +const ( + TypeAirConditioner = "air_conditioner" // 空调 + TypeLight = "light" // 灯 + TypeSwitch = "switch" // 开关 + TypeFan = "fan" // 风扇 + TypeCover = "cover" // 窗帘/遮阳帘 + TypeHumidifier = "humidifier" // 加湿器 + TypeVacuum = "vacuum" // 吸尘器/扫地机器人 + TypeWaterHeater = "water_heater" // 热水器 + TypeThermostat = "thermostat" // 温控器 + TypeOccupancySensor = "sensor_occupy" // 人体传感器 + TypeTempHumidSensor = "sensor" // 温湿度传感器(官方 URN type) + TypeSpeaker = "speaker" // 音箱 + TypeGateway = "gateway" // 网关 + TypePlug = "plug" // 插座 + +) + // ── 设备属性名常量 ── -// 用于 OnStateChanged 回调的 changed 字段,switch 前先用 deviceType 区分设备类型。 +// 用于 OnPropsChanged 回调的 changed 字段,switch 前先用 deviceType 区分设备类型。 const ( // ── 通用 ── diff --git a/xiaomi/devices/speaker.go b/xiaomi/devices/speaker.go index ebb4ef1..fadfe2b 100644 --- a/xiaomi/devices/speaker.go +++ b/xiaomi/devices/speaker.go @@ -49,13 +49,13 @@ type Speaker interface { 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) + OnPropsChanged(handler func(props SpeakerProps, changed string)) (string, error) + GetProps() SpeakerProps + FetchProps(ctx context.Context) (SpeakerProps, error) } -// SpeakerState 音箱完整状态(字段名对齐 SPEC type) -type SpeakerState struct { +// SpeakerProps 音箱完整状态(字段名对齐 SPEC type) +type SpeakerProps struct { On bool `json:"on"` // SPEC: "on" Volume int `json:"volume"` // SPEC: "volume" Mute bool `json:"mute"` // SPEC: "mute" @@ -86,7 +86,7 @@ type speakerDevice struct { actExecuteTextDirective isAction actPlayMusic isAction - stateCache SpeakerState + propsCache SpeakerProps } // NewSpeaker creates a Speaker control for the given device. @@ -183,7 +183,7 @@ func (d *speakerDevice) TurnOff(ctx context.Context) error { } func (d *speakerDevice) Toggle(ctx context.Context) error { - s := d.GetState() + s := d.GetProps() if s.On { return d.TurnOff(ctx) } @@ -339,24 +339,24 @@ func (d *speakerDevice) PlayMusic(ctx context.Context) error { // ── 订阅 / 状态 ── -func (d *speakerDevice) OnStateChanged(handler func(state SpeakerState, changed string)) (string, error) { +func (d *speakerDevice) OnPropsChanged(handler func(props SpeakerProps, changed string)) (string, error) { type field struct { siid, piid int name string - fill func(*SpeakerState, interface{}) + fill func(*SpeakerProps, interface{}) } var fields []field - add := func(sp siidPiid, name string, fill func(*SpeakerState, interface{})) { + add := func(sp siidPiid, name string, fill func(*SpeakerProps, 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{}) { + add(d.propOn, "on", func(s *SpeakerProps, v interface{}) { s.On, _ = v.(bool) }) + add(d.propVolume, "volume", func(s *SpeakerProps, v interface{}) { s.Volume = toInt(v) }) + add(d.propMute, "mute", func(s *SpeakerProps, v interface{}) { s.Mute, _ = v.(bool) }) + add(d.propPlayState, "play-state", func(s *SpeakerProps, v interface{}) { s.PlayState = toInt(v) }) + add(d.propSleepMode, "sleep-mode", func(s *SpeakerProps, v interface{}) { s.SleepMode, _ = v.(bool) }) + add(d.propAudioID, "audio-id", func(s *SpeakerProps, v interface{}) { if str, ok := v.(string); ok { s.AudioID = str } @@ -368,17 +368,17 @@ func (d *speakerDevice) OnStateChanged(handler func(state SpeakerState, changed 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) + f.fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) return } } }) } -func (d *speakerDevice) GetState() SpeakerState { return d.stateCache } +func (d *speakerDevice) GetProps() SpeakerProps { return d.propsCache } -func (d *speakerDevice) FetchState(ctx context.Context) (SpeakerState, error) { +func (d *speakerDevice) FetchProps(ctx context.Context) (SpeakerProps, error) { keys := []xiaomi.PropKey{} add := func(sp siidPiid) { if sp.valid { @@ -394,29 +394,29 @@ func (d *speakerDevice) FetchState(ctx context.Context) (SpeakerState, error) { props, err := d.client.GetProps(ctx, keys) if err != nil { - return d.stateCache, err + return d.propsCache, err } - state := SpeakerState{} + result := SpeakerProps{} for _, p := range props { switch { case match(d.propOn, p): - state.On, _ = p.Value.(bool) + result.On, _ = p.Value.(bool) case match(d.propVolume, p): - state.Volume = toInt(p.Value) + result.Volume = toInt(p.Value) case match(d.propMute, p): - state.Mute, _ = p.Value.(bool) + result.Mute, _ = p.Value.(bool) case match(d.propPlayState, p): - state.PlayState = toInt(p.Value) + result.PlayState = toInt(p.Value) case match(d.propSleepMode, p): - state.SleepMode, _ = p.Value.(bool) + result.SleepMode, _ = p.Value.(bool) case match(d.propAudioID, p): if s, ok := p.Value.(string); ok { - state.AudioID = s + result.AudioID = s } } } - d.stateCache = state - return state, nil + d.propsCache = result + return result, nil } func match(sp siidPiid, p *xiaomi.PropertyValue) bool { diff --git a/xiaomi/devices/switch.go b/xiaomi/devices/switch.go index 208cee0..546b491 100644 --- a/xiaomi/devices/switch.go +++ b/xiaomi/devices/switch.go @@ -23,16 +23,22 @@ type Switch interface { ToggleChannel(ctx context.Context, ch int) error 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) + OnPropsChanged(handler func(ch int, on bool)) (string, error) + GetProps() SwitchProps + FetchProps(ctx context.Context) (SwitchProps, error) +} + +// SwitchProps 多键开关完整状态。 +// JSON: {"on":[true,false,true]},索引 0 = 通道 1 +type SwitchProps struct { + On []bool `json:"on"` } // switchDevice is the concrete Switch implementation. type switchDevice struct { BaseDevice channels []siidPiid // "on" property per channel (index 0 = channel 1) - stateCache []bool // MQTT 推送维护的本地缓存,索引 0 = 通道 1 + propsCache SwitchProps } type siidPiid struct { @@ -46,7 +52,6 @@ func NewSwitch(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Switch, error) { BaseDevice: NewBaseDevice(client, info), } - // Use the full SPEC to find all "on" properties (supports multi-channel) miotDev := d.getMIoTDevice() if miotDev == nil || miotDev.SpecInstance() == nil { return nil, xiaomi.ErrNoSPEC @@ -66,7 +71,7 @@ func NewSwitch(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Switch, error) { return nil, xiaomi.ErrNotSupported } d.channels = channels - d.stateCache = make([]bool, len(channels)) + d.propsCache.On = make([]bool, len(channels)) return d, nil } @@ -75,18 +80,9 @@ func (d *switchDevice) ChannelCount() int { return len(d.channels) } -func (d *switchDevice) TurnOn(ctx context.Context) error { - return d.TurnOnChannel(ctx, 1) -} - -func (d *switchDevice) TurnOff(ctx context.Context) error { - return d.TurnOffChannel(ctx, 1) -} - -func (d *switchDevice) Toggle(ctx context.Context) error { - return d.ToggleChannel(ctx, 1) -} - +func (d *switchDevice) TurnOn(ctx context.Context) error { return d.TurnOnChannel(ctx, 1) } +func (d *switchDevice) TurnOff(ctx context.Context) error { return d.TurnOffChannel(ctx, 1) } +func (d *switchDevice) Toggle(ctx context.Context) error { return d.ToggleChannel(ctx, 1) } func (d *switchDevice) IsOn(ctx context.Context) (bool, error) { return d.IsChannelOn(ctx, 1) } @@ -133,13 +129,12 @@ func (d *switchDevice) IsChannelOn(ctx context.Context, ch int) (bool, error) { return false, nil } -func (d *switchDevice) OnStateChanged(handler func(ch int, on bool)) (string, error) { +func (d *switchDevice) OnPropsChanged(handler func(ch int, on bool)) (string, error) { if len(d.channels) == 0 { return "", xiaomi.ErrNotSupported } - // 构建 siid/piid → channel index 的快速查找表 type chField struct{ siid, piid int } - lookup := make(map[chField]int) // key → channel index (0-based) + lookup := make(map[chField]int) for i, c := range d.channels { lookup[chField{c.siid, c.piid}] = i } @@ -147,8 +142,8 @@ func (d *switchDevice) OnStateChanged(handler func(ch int, on bool)) (string, er for k, idx := range lookup { if k.siid == prop.SIID && k.piid == prop.PIID { if b, ok := prop.Value.(bool); ok { - d.stateCache[idx] = b - handler(idx+1, b) // ch = idx+1 + d.propsCache.On[idx] = b + handler(idx+1, b) } return } @@ -157,21 +152,24 @@ func (d *switchDevice) OnStateChanged(handler func(ch int, on bool)) (string, er } func (d *switchDevice) channel(ch int) (siidPiid, bool) { - i := ch - 1 // channel 1 → index 0 + i := ch - 1 if i < 0 || i >= len(d.channels) { return siidPiid{}, false } return d.channels[i], true } -func (d *switchDevice) GetState() []bool { return d.stateCache } -func (d *switchDevice) FetchState(ctx context.Context) ([]bool, error) { +func (d *switchDevice) GetProps() SwitchProps { return d.propsCache } + +func (d *switchDevice) FetchProps(ctx context.Context) (SwitchProps, 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 } + if err != nil { + return d.propsCache, err + } states := make([]bool, len(d.channels)) for i, c := range d.channels { for _, p := range props { @@ -181,6 +179,6 @@ func (d *switchDevice) FetchState(ctx context.Context) ([]bool, error) { } } } - d.stateCache = states - return states, nil + d.propsCache.On = states + return d.propsCache, nil } diff --git a/xiaomi/devices/temp_humidity_sensor.go b/xiaomi/devices/temp_humidity_sensor.go index 0965c68..4fa5f3f 100644 --- a/xiaomi/devices/temp_humidity_sensor.go +++ b/xiaomi/devices/temp_humidity_sensor.go @@ -13,16 +13,16 @@ type TempHumiditySensor interface { GetTemperature(ctx context.Context) (float64, error) GetHumidity(ctx context.Context) (float64, error) - OnStateChanged(handler func(temp, humidity float64)) (string, error) - GetState() (float64, float64) - FetchState(ctx context.Context) (float64, float64, error) + OnPropsChanged(handler func(temp, humidity float64)) (string, error) + GetProps() (float64, float64) + FetchProps(ctx context.Context) (float64, float64, error) } type tempHumidityDevice struct { BaseDevice propTemp siidPiid propHumidity siidPiid - stateCache [2]float64 // [0]=temperature, [1]=humidity + propsCache [2]float64 // [0]=temperature, [1]=humidity } func NewTempHumiditySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (TempHumiditySensor, error) { @@ -61,33 +61,33 @@ func (d *tempHumidityDevice) GetHumidity(ctx context.Context) (float64, error) { return toFloat(prop.Value), nil } -func (d *tempHumidityDevice) OnStateChanged(handler func(temp, humidity float64)) (string, error) { +func (d *tempHumidityDevice) OnPropsChanged(handler func(temp, humidity float64)) (string, error) { if !d.propTemp.valid && !d.propHumidity.valid { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { switch { case d.propTemp.valid && prop.SIID == d.propTemp.siid && prop.PIID == d.propTemp.piid: - d.stateCache[0] = toFloat(prop.Value) + d.propsCache[0] = toFloat(prop.Value) case d.propHumidity.valid && prop.SIID == d.propHumidity.siid && prop.PIID == d.propHumidity.piid: - d.stateCache[1] = toFloat(prop.Value) + d.propsCache[1] = toFloat(prop.Value) } - handler(d.stateCache[0], d.stateCache[1]) + handler(d.propsCache[0], d.propsCache[1]) }) } -func (d *tempHumidityDevice) GetState() (float64, float64) { return d.stateCache[0], d.stateCache[1] } -func (d *tempHumidityDevice) FetchState(ctx context.Context) (float64, float64, error) { +func (d *tempHumidityDevice) GetProps() (float64, float64) { return d.propsCache[0], d.propsCache[1] } +func (d *tempHumidityDevice) FetchProps(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]} + if err != nil { return d.propsCache[0], d.propsCache[1], err } + state := [2]float64{d.propsCache[0], d.propsCache[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 + d.propsCache = state return state[0], state[1], nil } diff --git a/xiaomi/devices/thermostat.go b/xiaomi/devices/thermostat.go index e731d84..732f26b 100644 --- a/xiaomi/devices/thermostat.go +++ b/xiaomi/devices/thermostat.go @@ -17,9 +17,9 @@ type Thermostat interface { SetHeating(ctx context.Context, on bool) error IsHeating(ctx context.Context) (bool, error) - OnStateChanged(handler func(state HeaterState, changed string)) (string, error) - GetState() HeaterState - FetchState(ctx context.Context) (HeaterState, error) + OnPropsChanged(handler func(props HeaterProps, changed string)) (string, error) + GetProps() HeaterProps + FetchProps(ctx context.Context) (HeaterProps, error) } type thermostatDevice struct { @@ -28,7 +28,7 @@ type thermostatDevice struct { propTargetTemp siidPiid propCurrentTemp siidPiid propHeating siidPiid - stateCache HeaterState + propsCache HeaterProps } func NewThermostat(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Thermostat, error) { @@ -87,46 +87,46 @@ func (d *thermostatDevice) IsHeating(ctx context.Context) (bool, error) { return false, nil } -func (d *thermostatDevice) OnStateChanged(handler func(state HeaterState, changed string)) (string, error) { - type field struct{ siid, piid int; name string; fill func(*HeaterState, interface{}) } +func (d *thermostatDevice) OnPropsChanged(handler func(props HeaterProps, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*HeaterProps, interface{}) } var fields []field if d.propOn.valid { - fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HeaterState, v interface{}) { s.On, _ = v.(bool) }}) + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HeaterProps, v interface{}) { s.On, _ = v.(bool) }}) } if d.propTargetTemp.valid { - fields = append(fields, field{d.propTargetTemp.siid, d.propTargetTemp.piid, "target-temperature", func(s *HeaterState, v interface{}) { s.TargetTemp = toFloat(v) }}) + fields = append(fields, field{d.propTargetTemp.siid, d.propTargetTemp.piid, "target-temperature", func(s *HeaterProps, v interface{}) { s.TargetTemp = toFloat(v) }}) } if d.propCurrentTemp.valid { - fields = append(fields, field{d.propCurrentTemp.siid, d.propCurrentTemp.piid, "temperature", func(s *HeaterState, v interface{}) { s.CurrentTemp = toFloat(v) }}) + fields = append(fields, field{d.propCurrentTemp.siid, d.propCurrentTemp.piid, "temperature", func(s *HeaterProps, v interface{}) { s.CurrentTemp = toFloat(v) }}) } if len(fields) == 0 { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { for _, f := range fields { if f.siid == prop.SIID && f.piid == prop.PIID { - f.fill(&d.stateCache, prop.Value) - handler(d.stateCache, f.name) + f.fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) return } } }) } -func (d *thermostatDevice) GetState() HeaterState { return d.stateCache } -func (d *thermostatDevice) FetchState(ctx context.Context) (HeaterState, error) { +func (d *thermostatDevice) GetProps() HeaterProps { return d.propsCache } +func (d *thermostatDevice) FetchProps(ctx context.Context) (HeaterProps, 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{} + if err != nil { return d.propsCache, err } + result := HeaterProps{} 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) + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: result.On, _ = p.Value.(bool) + case d.propTargetTemp.valid && p.SIID == d.propTargetTemp.siid && p.PIID == d.propTargetTemp.piid: result.TargetTemp = toFloat(p.Value) + case d.propCurrentTemp.valid && p.SIID == d.propCurrentTemp.siid && p.PIID == d.propCurrentTemp.piid: result.CurrentTemp = toFloat(p.Value) } } - d.stateCache = state - return state, nil + d.propsCache = result + return result, nil } diff --git a/xiaomi/devices/vacuum.go b/xiaomi/devices/vacuum.go index c8bcd31..e1af3f7 100644 --- a/xiaomi/devices/vacuum.go +++ b/xiaomi/devices/vacuum.go @@ -13,15 +13,15 @@ type Vacuum interface { ReturnToDock(ctx context.Context) error SetFanLevel(ctx context.Context, level int) error GetFanLevel(ctx context.Context) (int, error) - GetStatus(ctx context.Context) (VacuumStatus, error) + GetStatus(ctx context.Context) (VacuumProps, error) - OnStateChanged(handler func(state VacuumStatus, changed string)) (string, error) - GetState() VacuumStatus - FetchState(ctx context.Context) (VacuumStatus, error) + OnPropsChanged(handler func(props VacuumProps, changed string)) (string, error) + GetProps() VacuumProps + FetchProps(ctx context.Context) (VacuumProps, error) } -// VacuumStatus 扫地机运行状态(字段名对齐 SPEC type) -type VacuumStatus struct { +// VacuumProps 扫地机运行状态(字段名对齐 SPEC type) +type VacuumProps struct { On bool `json:"on"` Mode int `json:"mode"` FanLevel int `json:"fanLevel"` @@ -34,7 +34,7 @@ type vacuumDevice struct { propMode siidPiid propFanLevel siidPiid propBattery siidPiid - stateCache VacuumStatus + propsCache VacuumProps } func NewVacuum(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Vacuum, error) { @@ -72,7 +72,7 @@ func (d *vacuumDevice) GetFanLevel(ctx context.Context) (int, error) { if err != nil { return 0, err } return toInt(prop.Value), nil } -func (d *vacuumDevice) GetStatus(ctx context.Context) (VacuumStatus, error) { +func (d *vacuumDevice) GetStatus(ctx context.Context) (VacuumProps, error) { // 云端实时查询 keys := []xiaomi.PropKey{} add := func(sp siidPiid) { @@ -80,8 +80,8 @@ func (d *vacuumDevice) GetStatus(ctx context.Context) (VacuumStatus, error) { } add(d.propOn); add(d.propMode); add(d.propFanLevel); add(d.propBattery) props, err := d.client.GetProps(ctx, keys) - if err != nil { return VacuumStatus{}, err } - status := VacuumStatus{} + if err != nil { return VacuumProps{}, err } + status := VacuumProps{} for _, p := range props { switch { case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: @@ -97,37 +97,37 @@ func (d *vacuumDevice) GetStatus(ctx context.Context) (VacuumStatus, error) { return status, nil } -func (d *vacuumDevice) OnStateChanged(handler func(state VacuumStatus, changed string)) (string, error) { - type field struct{ siid, piid int; name string; fill func(*VacuumStatus, interface{}) } +func (d *vacuumDevice) OnPropsChanged(handler func(props VacuumProps, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*VacuumProps, interface{}) } var fields []field if d.propOn.valid { - fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *VacuumStatus, v interface{}) { s.On, _ = v.(bool) }}) + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *VacuumProps, v interface{}) { s.On, _ = v.(bool) }}) } if d.propMode.valid { - fields = append(fields, field{d.propMode.siid, d.propMode.piid, "mode", func(s *VacuumStatus, v interface{}) { s.Mode = toInt(v) }}) + fields = append(fields, field{d.propMode.siid, d.propMode.piid, "mode", func(s *VacuumProps, v interface{}) { s.Mode = toInt(v) }}) } if d.propFanLevel.valid { - fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", func(s *VacuumStatus, v interface{}) { s.FanLevel = toInt(v) }}) + fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level", func(s *VacuumProps, v interface{}) { s.FanLevel = toInt(v) }}) } if d.propBattery.valid { - fields = append(fields, field{d.propBattery.siid, d.propBattery.piid, "battery", func(s *VacuumStatus, v interface{}) { s.Battery = toInt(v) }}) + fields = append(fields, field{d.propBattery.siid, d.propBattery.piid, "battery", func(s *VacuumProps, v interface{}) { s.Battery = toInt(v) }}) } if len(fields) == 0 { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { for _, f := range fields { if f.siid == prop.SIID && f.piid == prop.PIID { - f.fill(&d.stateCache, prop.Value) - handler(d.stateCache, f.name) + f.fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) return } } }) } -func (d *vacuumDevice) GetState() VacuumStatus { return d.stateCache } -func (d *vacuumDevice) FetchState(ctx context.Context) (VacuumStatus, error) { +func (d *vacuumDevice) GetProps() VacuumProps { return d.propsCache } +func (d *vacuumDevice) FetchProps(ctx context.Context) (VacuumProps, error) { status, err := d.GetStatus(ctx) - if err != nil { return d.stateCache, err } - d.stateCache = status + if err != nil { return d.propsCache, err } + d.propsCache = status return status, nil } diff --git a/xiaomi/devices/water_heater.go b/xiaomi/devices/water_heater.go index b52643b..bc15e85 100644 --- a/xiaomi/devices/water_heater.go +++ b/xiaomi/devices/water_heater.go @@ -15,13 +15,13 @@ type WaterHeater interface { GetTargetTemp(ctx context.Context) (float64, error) GetCurrentTemp(ctx context.Context) (float64, error) - OnStateChanged(handler func(state HeaterState, changed string)) (string, error) - GetState() HeaterState - FetchState(ctx context.Context) (HeaterState, error) + OnPropsChanged(handler func(props HeaterProps, changed string)) (string, error) + GetProps() HeaterProps + FetchProps(ctx context.Context) (HeaterProps, error) } -// HeaterState 热水器状态(MQTT 缓存) -type HeaterState struct { +// HeaterProps 热水器状态(MQTT 缓存) +type HeaterProps struct { On bool `json:"on"` TargetTemp float64 `json:"targetTemperature"` CurrentTemp float64 `json:"temperature"` @@ -32,7 +32,7 @@ type waterHeaterDevice struct { propOn siidPiid propTargetTemp siidPiid propCurrentTemp siidPiid - stateCache HeaterState + propsCache HeaterProps } func NewWaterHeater(client *xiaomi.Client, info *xiaomi.DeviceInfo) (WaterHeater, error) { @@ -79,46 +79,46 @@ func (d *waterHeaterDevice) GetCurrentTemp(ctx context.Context) (float64, error) return toFloat(prop.Value), nil } -func (d *waterHeaterDevice) OnStateChanged(handler func(state HeaterState, changed string)) (string, error) { - type field struct{ siid, piid int; name string; fill func(*HeaterState, interface{}) } +func (d *waterHeaterDevice) OnPropsChanged(handler func(props HeaterProps, changed string)) (string, error) { + type field struct{ siid, piid int; name string; fill func(*HeaterProps, interface{}) } var fields []field if d.propOn.valid { - fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HeaterState, v interface{}) { s.On, _ = v.(bool) }}) + fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on", func(s *HeaterProps, v interface{}) { s.On, _ = v.(bool) }}) } if d.propTargetTemp.valid { - fields = append(fields, field{d.propTargetTemp.siid, d.propTargetTemp.piid, "target-temperature", func(s *HeaterState, v interface{}) { s.TargetTemp = toFloat(v) }}) + fields = append(fields, field{d.propTargetTemp.siid, d.propTargetTemp.piid, "target-temperature", func(s *HeaterProps, v interface{}) { s.TargetTemp = toFloat(v) }}) } if d.propCurrentTemp.valid { - fields = append(fields, field{d.propCurrentTemp.siid, d.propCurrentTemp.piid, "temperature", func(s *HeaterState, v interface{}) { s.CurrentTemp = toFloat(v) }}) + fields = append(fields, field{d.propCurrentTemp.siid, d.propCurrentTemp.piid, "temperature", func(s *HeaterProps, v interface{}) { s.CurrentTemp = toFloat(v) }}) } if len(fields) == 0 { return "", xiaomi.ErrNotSupported } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { for _, f := range fields { if f.siid == prop.SIID && f.piid == prop.PIID { - f.fill(&d.stateCache, prop.Value) - handler(d.stateCache, f.name) + f.fill(&d.propsCache, prop.Value) + handler(d.propsCache, f.name) return } } }) } -func (d *waterHeaterDevice) GetState() HeaterState { return d.stateCache } -func (d *waterHeaterDevice) FetchState(ctx context.Context) (HeaterState, error) { +func (d *waterHeaterDevice) GetProps() HeaterProps { return d.propsCache } +func (d *waterHeaterDevice) FetchProps(ctx context.Context) (HeaterProps, 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{} + if err != nil { return d.propsCache, err } + result := HeaterProps{} 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) + case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: result.On, _ = p.Value.(bool) + case d.propTargetTemp.valid && p.SIID == d.propTargetTemp.siid && p.PIID == d.propTargetTemp.piid: result.TargetTemp = toFloat(p.Value) + case d.propCurrentTemp.valid && p.SIID == d.propCurrentTemp.siid && p.PIID == d.propCurrentTemp.piid: result.CurrentTemp = toFloat(p.Value) } } - d.stateCache = state - return state, nil + d.propsCache = result + return result, nil } diff --git a/xiaomi/examples/04_light_control/main.go b/xiaomi/examples/04_light_control/main.go index 2a509be..092cba7 100644 --- a/xiaomi/examples/04_light_control/main.go +++ b/xiaomi/examples/04_light_control/main.go @@ -93,11 +93,11 @@ func main() { log.Printf("当前颜色: R=%d G=%d B=%d", r, g, b) } - subID, err := lt.OnStateChanged(func(state devices.LightState, changed string) { + subID, err := lt.OnPropsChanged(func(props devices.LightProps, changed string) { log.Printf("灯光状态变化: %s → on=%v brightness=%d colorTemp=%dK rgb=[%d,%d,%d]", changed, - state.On, state.Brightness, state.ColorTemperature, - state.ColorRGB[0], state.ColorRGB[1], state.ColorRGB[2]) + props.On, props.Brightness, props.ColorTemperature, + props.ColorRGB[0], props.ColorRGB[1], props.ColorRGB[2]) }) if err != nil { log.Printf("订阅状态失败: %v", err) diff --git a/xiaomi/examples/05_ac_control/main.go b/xiaomi/examples/05_ac_control/main.go index 2402990..a4e66a3 100644 --- a/xiaomi/examples/05_ac_control/main.go +++ b/xiaomi/examples/05_ac_control/main.go @@ -101,10 +101,10 @@ func main() { log.Println("开启节能模式...") ac.SetEco(ctx, true) - subID, err := ac.OnStateChanged(func(state devices.ACState, changed string) { + subID, err := ac.OnPropsChanged(func(props devices.ACProps, changed string) { log.Printf("空调状态变化: %s → on=%v mode=%s target=%.1f current=%.1f fan=%s swing=%s", - changed, state.On, state.Mode, state.TargetTemperature, state.Temperature, - state.FanLevel, state.Swing) + changed, props.On, props.Mode, props.TargetTemperature, props.Temperature, + props.FanLevel, props.Swing) }) if err != nil { log.Printf("订阅状态失败: %v", err) diff --git a/xiaomi/examples/11_sensor_control/main.go b/xiaomi/examples/11_sensor_control/main.go index 54a189b..7638dbf 100644 --- a/xiaomi/examples/11_sensor_control/main.go +++ b/xiaomi/examples/11_sensor_control/main.go @@ -37,7 +37,11 @@ func main() { defer client.Close() // 获取所有传感器 - sensorList, err := client.GetDevices(ctx, xiaomi.FilterByKind(xiaomi.KindSensor)) + filterSensor := xiaomi.Or( + xiaomi.FilterByKind(xiaomi.KindOccupancySensor), + xiaomi.FilterByKind(xiaomi.KindSensor), + ) + sensorList, err := client.GetDevices(ctx, filterSensor) if err != nil { log.Fatalf("获取传感器列表失败: %v", err) } diff --git a/xiaomi/examples/12_ac_subscribe/main.go b/xiaomi/examples/12_ac_subscribe/main.go index 5d0b23e..d116dc4 100644 --- a/xiaomi/examples/12_ac_subscribe/main.go +++ b/xiaomi/examples/12_ac_subscribe/main.go @@ -1,4 +1,4 @@ -// Package main demonstrates AirConditioner.OnStateChanged subscription. +// Package main demonstrates AirConditioner.OnPropsChanged subscription. // 运行后等待状态变化,Ctrl+C 退出。 package main @@ -52,10 +52,10 @@ func main() { log.Printf("初始状态: on=%v", on) // 订阅状态变化 - subID, err := ac.OnStateChanged(func(state devices.ACState, changed string) { + subID, err := ac.OnPropsChanged(func(props devices.ACProps, changed string) { log.Printf("[%s] on=%v mode=%s target=%.1f current=%.1f fan=%s swing=%s", - changed, state.On, state.Mode, state.TargetTemperature, state.Temperature, - state.FanLevel, state.Swing) + changed, props.On, props.Mode, props.TargetTemperature, props.Temperature, + props.FanLevel, props.Swing) }) if err != nil { log.Printf("订阅失败: %v", err) diff --git a/xiaomi/examples/13_multi_user/main.go b/xiaomi/examples/13_multi_user/main.go index feed36d..9e93667 100644 --- a/xiaomi/examples/13_multi_user/main.go +++ b/xiaomi/examples/13_multi_user/main.go @@ -51,7 +51,7 @@ func main() { } // 注册全局回调 → 推送到 NATS / WebSocket / 数据库 - b.OnStateChanged(func(id int64, did, deviceType, changed string, state interface{}) { + b.OnPropsChanged(func(id int64, did, deviceType, changed string, state interface{}) { log.Printf("[%d] %s/%s changed=%s → %+v", id, did, deviceType, changed, state) }) @@ -89,12 +89,12 @@ func demoControl(ctx context.Context, pool *bridge.Pool, id int64, did string) { } // 读缓存 - state := ac.GetState() + state := ac.GetProps() 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) + state2, err := ac.FetchProps(ctx) if err != nil { log.Printf("[demo] 云端刷新失败: %v", err) return diff --git a/xiaomi/examples/14_business_scenario/main.go b/xiaomi/examples/14_business_scenario/main.go index 1b99e8b..6355e6f 100644 --- a/xiaomi/examples/14_business_scenario/main.go +++ b/xiaomi/examples/14_business_scenario/main.go @@ -40,10 +40,10 @@ func saveToDB(id int64, did, deviceType, changed string, state interface{}) { // 同类化 state 拿到完整 State 结构体 switch s := state.(type) { - case bridge.ACState: + case bridge.ACProps: 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: + case bridge.LightProps: log.Printf("[DB] 灯 %d/%s changed=%s: on=%v brightness=%d colorTemp=%d", id, did, changed, s.On, s.Brightness, s.ColorTemperature) default: @@ -53,10 +53,10 @@ func saveToDB(id int64, did, deviceType, changed string, state interface{}) { func pushWS(id int64, did, deviceType, changed string, state interface{}) { switch s := state.(type) { - case bridge.ACState: + case bridge.ACProps: 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: + case bridge.LightProps: log.Printf("[WS→%d] 灯 %s changed=%s: on=%v brightness=%d", id, did, changed, s.On, s.Brightness) default: @@ -92,7 +92,7 @@ func initBridge(ctx context.Context, pool *bridge.Pool, cfg UserCfg) error { } // 注册回调:MQTT 推送 → DB + WS - b.OnStateChanged(func(id int64, did, deviceType, changed string, state interface{}) { + b.OnPropsChanged(func(id int64, did, deviceType, changed string, state interface{}) { saveToDB(id, did, deviceType, changed, state) pushWS(id, did, deviceType, changed, state) }) @@ -116,13 +116,13 @@ func controlAC(ctx context.Context, pool *bridge.Pool, id int64, did string) err } // 读缓存 - state := ac.GetState() + state := ac.GetProps() 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 { + if s, err := ac.FetchProps(ctx); err == nil { fmt.Printf(" 云端: on=%v mode=%s target=%.1f current=%.1f\n", s.On, s.Mode, s.TargetTemperature, s.Temperature) } @@ -136,7 +136,7 @@ func controlAC(ctx context.Context, pool *bridge.Pool, id int64, did string) err time.Sleep(1 * time.Second) // 最终状态 - final := ac.GetState() + final := ac.GetProps() fmt.Printf("操作后: on=%v mode=%s target=%.1f current=%.1f\n", final.On, final.Mode, final.TargetTemperature, final.Temperature) return nil diff --git a/xiaomi/migration/EXAMPLES_PLAN.md b/xiaomi/migration/EXAMPLES_PLAN.md index 3c1a7ea..7702b61 100644 --- a/xiaomi/migration/EXAMPLES_PLAN.md +++ b/xiaomi/migration/EXAMPLES_PLAN.md @@ -85,13 +85,13 @@ xiaomi/examples/ **覆盖 API**: - `devices.Create(client, info)` 工厂 → `Switch` - `Switch.{TurnOn, TurnOff, Toggle, IsOn}(ctx)` -- `Switch.OnStateChanged(handler)` 状态订阅 +- `Switch.OnPropsChanged(handler)` 状态订阅 **演示重点**: - 工厂创建单设备 - TurnOn/TurnOff/Toggle 完整链路 - IsOn 状态查询 -- OnStateChanged 回调演示 +- OnPropsChanged 回调演示 --- @@ -102,7 +102,7 @@ xiaomi/examples/ - `Light.{SetBrightness, GetBrightness}(ctx, pct)` - `Light.{SetColorTemp, GetColorTemp}(ctx, kelvin)` - `Light.{SetColor, GetColor}(ctx, r, g, b)` -- `Light.OnStateChanged(handler)` +- `Light.OnPropsChanged(handler)` **演示重点**: - 亮度 0-100 调节 @@ -123,11 +123,11 @@ xiaomi/examples/ - `SetTargetHumidity(ctx, pct)` / `GetTargetHumidity(ctx)` / `GetCurrentHumidity(ctx)` - `SetAuxHeat(ctx, on)` / `GetAuxHeat(ctx)` - `SetEco(ctx, on)` / `GetEco(ctx)` -- `OnStateChanged(handler)` → `ACState` +- `OnPropsChanged(handler)` → `ACState` **演示重点**: - 完整空调控制链路(开→设制冷→设26°C→自动风速→垂直摆风) -- OnStateChanged 实时状态回调 +- OnPropsChanged 实时状态回调 - 不支持的功能优雅降级(如无湿度传感器,跳过湿度设置) --- @@ -281,7 +281,7 @@ func main() { |-------------------|---------| | 用户/家庭/房间信息(GetUserInfo / GetHomeList) | 01_user_home | | 设备列表 + Filter | 02_device_list | -| Switch 控制 + OnStateChanged | 03_switch_control | +| Switch 控制 + OnPropsChanged | 03_switch_control | | Light 控制 | 04_light_control | | AirConditioner 完整控制 | 05_ac_control | | Fan / Cover 控制 | 06_fan_cover | diff --git a/xiaomi/types.go b/xiaomi/types.go index d4c983c..deeaa21 100644 --- a/xiaomi/types.go +++ b/xiaomi/types.go @@ -156,8 +156,11 @@ const ( KindWaterHeater Kind = "water_heater" KindThermostat Kind = "thermostat" KindSpeaker Kind = "speaker" - KindSensor Kind = "sensor" - KindUnknown Kind = "unknown" + KindGateway Kind = "gateway" + KindPlug Kind = "plug" + KindOccupancySensor Kind = "sensor_occupy" + KindSensor Kind = "sensor" + KindUnknown Kind = "unknown" ) // DeviceKind 从 URN 快速判断设备种类(不依赖 SPEC 加载)