Files
xiaomihome/xiaomi/devices/air_conditioner.go
T
4566704 a0ef854726 feat: 完善 AC"set 后回读"机制 — 目标聚合 + 防抖重试验证
**回读目标聚合(recordReadback)**
- 所有 set 方法(TurnOn/TurnOff/SetMode/SetTargetTemp/SetFanSpeed/
  SetSwing/SetTargetHumidity/SetAuxHeat/SetEco)成功后记录目标值 rbTarget
- 同属性目标覆盖、不同属性追加,连续 set 自动聚合

**防抖 + 重试验证(readbackLoop)**
- 静默窗口 readbackIdle=3s:等待最后一次 set 无新操作才开始验证
- 未命中目标时按 readbackRetryInterval=3s 重试,上限 readbackMaxRetries=5
- 整体超时 readbackTimeout=30s,防云端卡住导致回读永久挂起
- 命中后逐字段回调 OnPropsChanged 注册的 handler(与 MQTT 推送同语义)
- rbValueEqual 数值比较用 epsilon=0.01,bool/string 直接相等

**白名单配置透传**
- xiaomi.Config 新增 ReadbackModels 白名单(留空 = 全部不回读,默认)
- Client 新增 NeedReadback(model) 判定 + Logger() 访问器
- bridge.Pool.NewPool(readbackModels...) 透传;newBridge 传入 Client 配置

**其他**
- bridge 设备创建失败(Warn)与不支持跳过(Debug)分别记录日志
2026-08-23 23:33:32 +08:00

964 lines
28 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"
"sync"
"time"
"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"
// readbackIdle set 后回读的防抖窗口:最后一次 set 之后等待该时长无新操作才开始验证。
// 用于固件不广播云端指令的设备(BLE/Mesh),防抖合并连续操作。
readbackIdle = 3 * time.Second
// readbackRetryInterval 回读未命中目标时的重试间隔。
readbackRetryInterval = 3 * time.Second
// readbackMaxRetries 回读最大重试次数(含首次尝试)。
readbackMaxRetries = 5
// readbackTimeout 回读整体超时(含防抖等待 + 重试),防止云端卡住导致回读永久失效。
readbackTimeout = 30 * time.Second
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 推送维护的本地缓存
// set 后回读(验证设置是否成功):记录目标值,防抖 + 重试比较
rbMu sync.Mutex
rbPending bool // 是否有回读调度在跑
rbLastSet time.Time // 最近一次 set 时间
rbTargets []rbTarget // 本次待验证的目标(聚合连续 set)
rbHandler func(props ACProps, changed string) // OnPropsChanged 注册的 handler
}
// rbTarget 回读验证目标:某属性本次 set 的目标值。
type rbTarget struct {
name string // "on"/"mode"/"target-temperature"/"fan-level"/"swing"/...
want interface{} // 目标值:bool / float64 / string(与 ACProps 字段类型一致)
}
// 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
}
if err := d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true); err != nil {
return err
}
d.recordReadback("on", true)
return nil
}
func (d *acDevice) TurnOff(ctx context.Context) error {
if !d.propOn.valid {
return xiaomi.ErrNotSupported
}
if err := d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false); err != nil {
return err
}
d.recordReadback("on", false)
return nil
}
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
}
if err := d.SetProp(ctx, d.propMode.siid, d.propMode.piid, v); err != nil {
return err
}
d.recordReadback("mode", d.modeMap.name(v))
return nil
}
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
}
if err := d.SetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid, celsius); err != nil {
return err
}
d.recordReadback("target-temperature", celsius)
return nil
}
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
}
if err := d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, v); err != nil {
return err
}
d.recordReadback("fan-level", d.fanMap.name(v))
return nil
}
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
}
if err := d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, v); err != nil {
return err
}
d.recordReadback("swing", d.swingMap.name(v))
return nil
}
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
}
if err := d.SetProp(ctx, d.propTargetHumidity.siid, d.propTargetHumidity.piid, pct); err != nil {
return err
}
d.recordReadback("target-humidity", float64(pct))
return nil
}
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
}
if err := d.SetProp(ctx, d.propAuxHeat.siid, d.propAuxHeat.piid, on); err != nil {
return err
}
d.recordReadback("heater", on)
return nil
}
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
}
if err := d.SetProp(ctx, d.propEco.siid, d.propEco.piid, on); err != nil {
return err
}
d.recordReadback("eco", on)
return nil
}
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
}
// 保存 handler 供 set 后回读使用
d.rbMu.Lock()
d.rbHandler = handler
d.rbMu.Unlock()
// 每个属性精确订阅 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
}
// recordReadback 记录本次 set 的目标值并调度回读验证(白名单型号)。
// 连续 set 聚合:同属性覆盖目标值,不同属性追加。
func (d *acDevice) recordReadback(name string, want interface{}) {
if d.client == nil || !d.client.NeedReadback(d.info.Model) {
return
}
d.rbMu.Lock()
replaced := false
for i := range d.rbTargets {
if d.rbTargets[i].name == name {
d.rbTargets[i].want = want
replaced = true
break
}
}
if !replaced {
d.rbTargets = append(d.rbTargets, rbTarget{name: name, want: want})
}
d.rbLastSet = time.Now()
if d.rbPending {
d.rbMu.Unlock()
return // 已有回读调度在跑(goroutine 会检查 lastSetAt 继续等待)
}
d.rbPending = true
d.rbMu.Unlock()
if d.client.Logger() != nil {
d.client.Logger().Debugf("[acDevice] readback scheduled: %s (%s) targets=%v\n", d.info.DID, d.info.Model, d.rbTargetNames())
}
go d.readbackLoop()
}
// readbackLoop 防抖 + 重试回读,验证 set 目标值是否被设备执行。
// 静默 readbackIdle 后开始回读;未命中目标则间隔重试,直到命中或超时。
func (d *acDevice) readbackLoop() {
ctx, cancel := context.WithTimeout(context.Background(), readbackTimeout)
defer cancel()
for {
// 防抖:等待静默窗口
select {
case <-time.After(readbackIdle):
case <-ctx.Done():
d.finishReadback("timeout waiting idle")
return
}
d.rbMu.Lock()
idle := time.Since(d.rbLastSet) >= readbackIdle
targets := make([]rbTarget, len(d.rbTargets))
copy(targets, d.rbTargets)
d.rbMu.Unlock()
if !idle {
continue // 期间又有新的 set,继续等待静默
}
// 回读验证(带重试)
for attempt := 1; ; attempt++ {
if attempt > 1 {
select {
case <-time.After(readbackRetryInterval):
case <-ctx.Done():
d.finishReadback("timeout")
return
}
}
props, err := d.FetchProps(ctx)
if err != nil {
if d.client.Logger() != nil {
d.client.Logger().Warnf("[acDevice] readback %s attempt %d failed: %v\n", d.info.DID, attempt, err)
}
if ctx.Err() != nil {
d.finishReadback("timeout")
return
}
continue // 拉取失败重试
}
missed := d.checkReadbackTargets(props, targets)
if len(missed) == 0 {
d.finishReadbackOK(props, targets)
return
}
if d.client.Logger() != nil {
d.client.Logger().Debugf("[acDevice] readback %s attempt %d not applied yet: missed=%v\n", d.info.DID, attempt, missed)
}
if attempt >= readbackMaxRetries {
d.finishReadback("not applied after retries")
return
}
}
}
}
// checkReadbackTargets 检查回读状态是否达到所有 set 目标值,返回未命中的属性名。
func (d *acDevice) checkReadbackTargets(props ACProps, targets []rbTarget) []string {
var missed []string
for _, t := range targets {
if !rbValueEqual(readbackFieldValue(props, t.name), t.want) {
missed = append(missed, t.name)
}
}
return missed
}
// finishReadbackOK 全部目标命中:逐字段上报业务层(与 MQTT 推送同语义)。
func (d *acDevice) finishReadbackOK(props ACProps, targets []rbTarget) {
d.rbMu.Lock()
handler := d.rbHandler
d.rbMu.Unlock()
if handler == nil {
if d.client.Logger() != nil {
d.client.Logger().Warnf("[acDevice] readback %s done but handler not registered (OnPropsChanged never called)\n", d.info.DID)
}
d.rbMu.Lock()
d.rbPending = false
d.rbMu.Unlock()
return
}
for _, t := range targets {
handler(props, t.name)
}
if d.client.Logger() != nil {
d.client.Logger().Debugf("[acDevice] readback %s success: %v on=%v mode=%s target=%.0f temp=%.0f fan=%s hum=%d\n",
d.info.DID, d.rbTargetNamesLocked(), props.On, props.Mode, props.TargetTemperature,
props.Temperature, props.FanLevel, props.CurrentHumidity)
}
d.rbMu.Lock()
d.rbPending = false
d.rbMu.Unlock()
}
// finishReadback 回读终止(超时/未生效):清理调度并记录日志。
func (d *acDevice) finishReadback(reason string) {
d.rbMu.Lock()
names := d.rbTargetNamesLocked()
d.rbTargets = nil
d.rbPending = false
d.rbMu.Unlock()
if d.client.Logger() != nil {
d.client.Logger().Warnf("[acDevice] readback %s finished: %s targets=%v\n", d.info.DID, reason, names)
}
}
func (d *acDevice) rbTargetNames() []string {
d.rbMu.Lock()
defer d.rbMu.Unlock()
return d.rbTargetNamesLocked()
}
func (d *acDevice) rbTargetNamesLocked() []string {
names := make([]string, 0, len(d.rbTargets))
for _, t := range d.rbTargets {
names = append(names, t.name)
}
return names
}
// readbackFieldValue 从 ACProps 取指定属性的值(与 rbTarget.want 类型一致)。
func readbackFieldValue(props ACProps, name string) interface{} {
switch name {
case "on":
return props.On
case "mode":
return props.Mode
case "target-temperature":
return props.TargetTemperature
case "temperature":
return props.Temperature
case "target-humidity":
return float64(props.TargetHumidity)
case "relative-humidity":
return float64(props.CurrentHumidity)
case "fan-level":
return props.FanLevel
case "swing":
return props.Swing
case "heater":
return props.AuxHeat
case "eco":
return props.Eco
case "electric-power":
return props.ElectricPower
case "power-consumption":
return props.PowerConsumption
}
return nil
}
// rbValueEqual 比较回读值与目标值:数值用 epsilon,bool/string 直接相等。
func rbValueEqual(got, want interface{}) bool {
if got == nil || want == nil {
return got == want
}
if gf, ok := got.(float64); ok {
wf, ok2 := toFloatOk(want)
if !ok2 {
return false
}
diff := gf - wf
if diff < 0 {
diff = -diff
}
return diff < 0.01
}
if gb, ok := got.(bool); ok {
wb, ok2 := want.(bool)
return ok2 && gb == wb
}
if gs, ok := got.(string); ok {
ws, ok2 := want.(string)
return ok2 && gs == ws
}
return got == want
}
// toFloatOk 将 interface{} 转为 float64(bool/string 除外),失败返回 false。
func toFloatOk(v interface{}) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return toFloat(v), true
}
return 0, false
}
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
}