feat: State→Props 命名统一 + 新增 Plug/Gateway 设备 + 分类系统重构

**命名统一(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 等迁移文档同步更新
This commit is contained in:
2026-08-05 17:54:08 +08:00
parent b808e51b3e
commit 04333750ab
37 changed files with 916 additions and 536 deletions
+152 -37
View File
@@ -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
}