Files
xiaomihome/xiaomi/devices/air_conditioner.go
T
4566704 04333750ab 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 等迁移文档同步更新
2026-08-05 17:54:08 +08:00

628 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package devices
import (
"context"
"strings"
"xiaomihome/miot"
"xiaomihome/xiaomi"
)
// valMapper maps between device raw values and semantic names. Built from SPEC ValueList.
// Different brands assign different raw values for the same mode name,
// so this must be per-device. Names are lowercased from SPEC description for consistency.
type valMapper struct {
val2name map[int]string
name2val map[string]int
names []string
}
func newValMapper(vl miot.MIoTSpecValueList) *valMapper {
vm := &valMapper{
val2name: make(map[int]string),
name2val: make(map[string]int),
}
for _, item := range vl {
v := toInt(item.Value)
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
}
func (vm *valMapper) name(v int) string {
if vm == nil { return "" }
return vm.val2name[v]
}
func (vm *valMapper) val(name string) (int, bool) {
if vm == nil { return 0, false }
v, ok := vm.name2val[strings.ToLower(name)]
return v, ok
}
func (vm *valMapper) allNames() []string {
if vm == nil { return nil }
return vm.names
}
// ========== 空调模式/风速/摆风常量 ==========
const (
ACModeCool = "cool"
ACModeHeat = "heat"
ACModeFan = "fan"
ACModeDry = "dry"
ACModeAuto = "auto"
ACFanLow = "low"
ACFanMedium = "medium"
ACFanHigh = "high"
ACFanAuto = "auto"
ACSwingOff = "off"
ACSwingVertical = "vertical"
ACSwingHorizontal = "horizontal"
ACSwingBoth = "both"
)
// ========== AirConditioner 接口 ==========
// AirConditioner 空调统一控制接口
type AirConditioner interface {
Device
TurnOn(ctx context.Context) error
TurnOff(ctx context.Context) error
IsOn(ctx context.Context) (bool, error)
// 能力查询
HasFanSpeed() bool
HasSwing() bool
HasHumidity() bool
HasAuxHeat() bool
HasEco() bool
// 模式控制
SetMode(ctx context.Context, mode string) error
GetMode(ctx context.Context) (string, error)
// 温度控制
SetTargetTemp(ctx context.Context, celsius float64) error
GetTargetTemp(ctx context.Context) (float64, error)
GetCurrentTemp(ctx context.Context) (float64, error) // 室内温度
// 风速控制
SetFanSpeed(ctx context.Context, speed string) error
GetFanSpeed(ctx context.Context) (string, error)
// 摆风控制
SetSwing(ctx context.Context, mode string) error
GetSwing(ctx context.Context) (string, error)
// 湿度
SetTargetHumidity(ctx context.Context, pct int) error
GetTargetHumidity(ctx context.Context) (int, error)
GetCurrentHumidity(ctx context.Context) (int, error)
// 电辅热
SetAuxHeat(ctx context.Context, on bool) error
GetAuxHeat(ctx context.Context) (bool, error)
// 节能
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)
// 订阅
OnPropsChanged(handler func(props ACProps, changed string)) (string, error)
GetProps() ACProps
FetchProps(ctx context.Context) (ACProps, error)
}
// 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"`
Temperature float64 `json:"temperature"`
TargetHumidity int `json:"targetHumidity"`
CurrentHumidity int `json:"currentHumidity"`
FanLevel string `json:"fanLevel"` // SPEC ValueList 解析后的名称:low/medium/high/auto
Swing string `json:"swing"` // SPEC ValueList 解析后的名称:off/vertical/horizontal/both
AuxHeat bool `json:"auxHeat"`
Eco bool `json:"eco"`
ElectricPower float64 `json:"electricPower"` // 当前电功率(W),来自 electric-power
PowerConsumption float64 `json:"powerConsumption"` // 累计耗电量(kWh),来自 power-consumption
}
type acDevice struct {
BaseDevice
propOn siidPiid // "on"
propMode siidPiid // "mode"
propTargetTemp siidPiid // "target-temperature"
propCurrentTemp siidPiid // "temperature"
propFanSpeed siidPiid // "fan-level"
propSwing siidPiid // "swing-mode" or "horizontal-swing" or "vertical-swing"
propTargetHumidity siidPiid // "target-humidity"
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
propsCache ACProps // MQTT 推送维护的本地缓存
}
// NewAirConditioner creates an AirConditioner control for the given device.
func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirConditioner, error) {
d := &acDevice{
BaseDevice: NewBaseDevice(client, info),
}
dev := d.getMIoTDevice()
if dev == nil {
return nil, xiaomi.ErrNoSPEC
}
spec := dev.SpecInstance()
if spec == nil {
return nil, xiaomi.ErrNoSPEC
}
resolver := d.getSpecResolver()
if resolver == nil {
return nil, xiaomi.ErrNoSPEC
}
// Helper: find property ValueList from spec by type name
findValList := func(propType string) miot.MIoTSpecValueList {
for _, svc := range spec.Services {
for _, p := range svc.Properties {
if propertyShortName(p.Type) == propType {
return p.ValueList
}
}
}
return nil
}
// "on" is required
siid, piid, ok := resolver.find("on")
if !ok {
return nil, xiaomi.ErrNotSupported
}
d.propOn = siidPiid{siid, piid, true}
// Optional properties — each independently resolved
if s, p, ok := resolver.find("mode"); ok {
d.propMode = siidPiid{s, p, true}
d.modeMap = newValMapper(findValList("mode"))
}
if s, p, ok := resolver.find("target-temperature"); ok {
d.propTargetTemp = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("temperature"); ok {
d.propCurrentTemp = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("fan-level"); ok {
d.propFanSpeed = siidPiid{s, p, true}
d.fanMap = newValMapper(findValList("fan-level"))
}
// Try multiple swing mode type names
if s, p, ok := resolver.find("swing-mode"); ok {
d.propSwing = siidPiid{s, p, true}
d.swingMap = newValMapper(findValList("swing-mode"))
} else if s, p, ok := resolver.find("horizontal-swing"); ok {
d.propSwing = siidPiid{s, p, true}
d.swingMap = newValMapper(findValList("horizontal-swing"))
} else if s, p, ok := resolver.find("vertical-swing"); ok {
d.propSwing = siidPiid{s, p, true}
d.swingMap = newValMapper(findValList("vertical-swing"))
}
if s, p, ok := resolver.find("target-humidity"); ok {
d.propTargetHumidity = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("relative-humidity"); ok {
d.propCurrentHumidity = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("heater"); ok {
d.propAuxHeat = siidPiid{s, p, true}
} else if s, p, ok := resolver.find("aux-heat"); ok {
d.propAuxHeat = siidPiid{s, p, true}
}
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
}
func (d *acDevice) TurnOn(ctx context.Context) error {
if !d.propOn.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true)
}
func (d *acDevice) TurnOff(ctx context.Context) error {
if !d.propOn.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false)
}
func (d *acDevice) IsOn(ctx context.Context) (bool, error) {
if !d.propOn.valid {
return false, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid)
if err != nil {
return false, err
}
if b, ok := prop.Value.(bool); ok {
return b, nil
}
return false, nil
}
func (d *acDevice) HasFanSpeed() bool { return d.propFanSpeed.valid }
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 {
return xiaomi.ErrNotSupported
}
v, ok := d.modeMap.val(mode)
if !ok {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, v)
}
func (d *acDevice) GetMode(ctx context.Context) (string, error) {
if !d.propMode.valid {
return ACModeAuto, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid)
if err != nil {
return ACModeAuto, err
}
return d.modeMap.name(toInt(prop.Value)), nil
}
func (d *acDevice) SetTargetTemp(ctx context.Context, celsius float64) error {
if !d.propTargetTemp.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid, celsius)
}
func (d *acDevice) GetTargetTemp(ctx context.Context) (float64, error) {
if !d.propTargetTemp.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid)
if err != nil {
return 0, err
}
return toFloat(prop.Value), nil
}
func (d *acDevice) GetCurrentTemp(ctx context.Context) (float64, error) {
if !d.propCurrentTemp.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propCurrentTemp.siid, d.propCurrentTemp.piid)
if err != nil {
return 0, err
}
return toFloat(prop.Value), nil
}
func (d *acDevice) SetFanSpeed(ctx context.Context, speed string) error {
if !d.propFanSpeed.valid {
return xiaomi.ErrNotSupported
}
v, ok := d.fanMap.val(speed)
if !ok {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, v)
}
func (d *acDevice) GetFanSpeed(ctx context.Context) (string, error) {
if !d.propFanSpeed.valid {
return ACFanAuto, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid)
if err != nil {
return ACFanAuto, err
}
return d.fanMap.name(toInt(prop.Value)), nil
}
func (d *acDevice) SetSwing(ctx context.Context, mode string) error {
if !d.propSwing.valid {
return xiaomi.ErrNotSupported
}
v, ok := d.swingMap.val(mode)
if !ok {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, v)
}
func (d *acDevice) GetSwing(ctx context.Context) (string, error) {
if !d.propSwing.valid {
return ACSwingOff, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propSwing.siid, d.propSwing.piid)
if err != nil {
return ACSwingOff, err
}
return d.swingMap.name(toInt(prop.Value)), nil
}
func (d *acDevice) SetTargetHumidity(ctx context.Context, pct int) error {
if !d.propTargetHumidity.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propTargetHumidity.siid, d.propTargetHumidity.piid, pct)
}
func (d *acDevice) GetTargetHumidity(ctx context.Context) (int, error) {
if !d.propTargetHumidity.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propTargetHumidity.siid, d.propTargetHumidity.piid)
if err != nil {
return 0, err
}
return toInt(prop.Value), nil
}
func (d *acDevice) GetCurrentHumidity(ctx context.Context) (int, error) {
if !d.propCurrentHumidity.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propCurrentHumidity.siid, d.propCurrentHumidity.piid)
if err != nil {
return 0, err
}
return toInt(prop.Value), nil
}
func (d *acDevice) SetAuxHeat(ctx context.Context, on bool) error {
if !d.propAuxHeat.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propAuxHeat.siid, d.propAuxHeat.piid, on)
}
func (d *acDevice) GetAuxHeat(ctx context.Context) (bool, error) {
if !d.propAuxHeat.valid {
return false, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propAuxHeat.siid, d.propAuxHeat.piid)
if err != nil {
return false, err
}
if b, ok := prop.Value.(bool); ok {
return b, nil
}
return false, nil
}
func (d *acDevice) SetEco(ctx context.Context, on bool) error {
if !d.propEco.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propEco.siid, d.propEco.piid, on)
}
func (d *acDevice) GetEco(ctx context.Context) (bool, error) {
if !d.propEco.valid {
return false, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propEco.siid, d.propEco.piid)
if err != nil {
return false, err
}
if b, ok := prop.Value.(bool); ok {
return b, nil
}
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(*ACProps, interface{})
valType byte // 'b'=bool, 'i'=int, 'f'=float, 's'=string
}
func (d *acDevice) OnPropsChanged(handler func(props ACProps, changed string)) (string, error) {
fields := d.propFields()
if len(fields) == 0 {
return "", xiaomi.ErrNotSupported
}
// 每个属性精确订阅 siid/piid,避免 lumi.acpartner.mcn02 等设备
// siid/piid 冲突时把风速 int 路由到 on 槽位
var firstID string
for i, f := range fields {
idx := i
subID, err := d.SubProp(f.siid, f.piid, func(did string, prop *xiaomi.PropertyValue) {
// lumi.acpartner.mcn02 固件 bug:on 通道会收到 int 值,静默丢弃
switch f.valType {
case 'b':
if _, ok := prop.Value.(bool); !ok {
return
}
case 's':
if _, ok := prop.Value.(string); !ok {
return
}
}
fields[idx].fill(&d.propsCache, prop.Value)
handler(d.propsCache, f.name)
})
if err != nil {
return "", err
}
if firstID == "" {
firstID = subID
}
}
return firstID, nil
}
func (d *acDevice) propFields() []propField {
r := d.getSpecResolver()
var fields []propField
add := func(sp siidPiid, name string, specType string, fill func(*ACProps, interface{})) {
if sp.valid {
vt := byte(0)
switch specType {
case "bool":
vt = 'b'
case "uint8", "uint16", "uint32", "int8", "int16", "int32":
vt = 'i'
case "float":
vt = 'f'
case "string":
vt = 's'
}
fields = append(fields, propField{sp.siid, sp.piid, name, fill, vt})
}
}
add(d.propOn, "on", 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 *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 *ACProps, v interface{}) {
s.Swing = d.swingMap.name(toInt(v))
})
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
}
func specFormat(r *specResolver, propType string) string {
if r == nil {
return ""
}
return r.format(propType)
}
// toFloat converts an interface{} to float64.
func toFloat(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
}
return 0
}
// 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) 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 {
keys[i] = xiaomi.PropKey{DID: d.info.DID, SIID: f.siid, PIID: f.piid}
}
props, err := d.client.GetProps(ctx, keys)
if err != nil {
return d.propsCache, err
}
result := ACProps{}
for _, p := range props {
for _, f := range fields {
if f.siid == p.SIID && f.piid == p.PIID {
f.fill(&result, p.Value)
break
}
}
}
d.propsCache = result
return result, nil
}