Files
xiaomihome/xiaomi/devices/air_conditioner.go
T
4566704 86c21ce91c feat: AC 三合一设备支持 + 新增 Plug/Gateway 驱动 + Cover 结构化重构
**AC 驱动增强**
- 支持 thermostat + air-conditioner + air-fresh 多服务三合一设备,属性跨服务合并解析
- 新增 specResolver.findValListInService/findInService/hasService/serviceShortName 方法
- 移除 KindThermostat,heater/electric-blanket/thermostat 重新归类为 KindAirConditioner

**Cover 窗帘重构**
- CoverProps 结构化:Position/Status/Fault 分离
- SetPosition 优先写 target-position,回退 current-position
- OnPropsChanged/FetchProps 支持多属性同步刷新

**新增设备类型**
- Plug 驱动:插座/插头,支持开合、在线状态、属性变更订阅
- Gateway 驱动:网关设备类型识别
- miot/miot_errors.go:新增 MIoT 云端错误码定义

**MIoT 客户端健壮性**
- SetProp 空结果视为失败;设备被移除或离线时自动刷新设备列表
- GetDevices 的 has_more 改用 parseBool 严格解析
- MIPS SubscribeMany 批量订阅减少往返
- matchURN 改为按段精确匹配,避免 tofan 误匹配 fan

**Bridge**
- 移除 Thermostat case,Cover 改用结构化 CoverProps
- 新增设备创建后自动订阅属性变更

**其他**
- 日志整体 Info → Debug 降噪
- .gitignore 忽略 /.codebuddy/memory
- 更新 devices_test 与 08_adv_filter_classify 示例
2026-08-23 19:42:55 +08:00

670 lines
20 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
}
// findValListInService 从指定服务(短名)内查找属性的 ValueList。
findValListInService := func(svcName, propType string) miot.MIoTSpecValueList {
for _, svc := range spec.Services {
if serviceShortName(svc.Type) != svcName {
continue
}
for _, p := range svc.Properties {
if propertyShortName(p.Type) == propType {
return p.ValueList
}
}
}
return nil
}
// "on" is required。
// 三合一设备(thermostat+air-conditioner+air-fresh 多服务同名冲突)的空调实体在 thermostat 服务:
// 存在 thermostat 服务时优先从它取 on/target-temperature,否则取第一个同名属性(普通空调)。
if resolver.hasService("thermostat") {
if s, p, ok := resolver.findInService("thermostat", "on"); ok {
d.propOn = siidPiid{s, p, true}
} else if s, p, ok := resolver.find("on"); ok {
d.propOn = siidPiid{s, p, true}
}
} else {
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 resolver.hasService("thermostat") {
// 三合一设备:target-temperature 优先 thermostat 服务(空调温度)
if s, p, ok := resolver.findInService("thermostat", "target-temperature"); ok {
d.propTargetTemp = siidPiid{s, p, true}
} else if s, p, ok := resolver.find("target-temperature"); ok {
d.propTargetTemp = siidPiid{s, p, true}
}
} else 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}
}
// fan-level:三合一设备优先 thermostat 服务(空调风速 siid=4/piid=3),
// 否则取第一个同名属性(普通空调 air-conditioner 服务 / air-fresh 新风机)。
if resolver.hasService("thermostat") {
if s, p, ok := resolver.findInService("thermostat", "fan-level"); ok {
d.propFanSpeed = siidPiid{s, p, true}
d.fanMap = newValMapper(findValListInService("thermostat", "fan-level"))
} else if s, p, ok := resolver.find("fan-level"); ok {
d.propFanSpeed = siidPiid{s, p, true}
d.fanMap = newValMapper(findValList("fan-level"))
}
} else 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
}