package devices import ( "context" "sort" "strings" "sync" "time" "git.zeroonesoft.cn/golib/xiaomihome/miot" "git.zeroonesoft.cn/golib/xiaomihome/xiaomi" "github.com/sirupsen/logrus" ) // 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 // levelFallback 允许 low/medium/high 映射到数字档位(仅 fan-level 启用, // 用于枚举为 Level1~LevelN 的设备;mode/swing 枚举语义固定,禁用)。 levelFallback bool } 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 } // newValMapperLevel 创建支持 low/medium/high → LevelN 映射的 valMapper(仅 fan-level 使用)。 func newValMapperLevel(vl miot.MIoTSpecValueList) *valMapper { vm := newValMapper(vl) vm.levelFallback = true return vm } func (vm *valMapper) name(v int) string { if vm == nil { return "" } if s, ok := vm.val2name[v]; ok { if !vm.levelFallback { return s } // levelFallback:数字档位反向映射到语义枚举(与 val() 正向对称)。 // 0→auto,最低档→low,中间档→medium,最高档→high;其余返回 spec 描述。 levels := vm.levelValues() switch { case v == 0: return "auto" case len(levels) > 0 && v == levels[0]: return "low" case len(levels) > 1 && v == levels[len(levels)-1]: return "high" case len(levels) > 2 && v == levels[len(levels)/2]: return "medium" } return s } return "" } func (vm *valMapper) val(name string) (int, bool) { if vm == nil { return 0, false } key := strings.ToLower(name) if v, ok := vm.name2val[key]; ok { return v, true } // fallback:标准档位枚举(low/medium/high)映射到数字档位的相对位置。 // 适用于 fan-level 枚举为 Level1~LevelN(或纯数字)的设备,如 klwdz1.airc.001。 // 排除 0(通常为 Auto/Off),low→最低档,medium→中间档,high→最高档。 // 仅 levelFallback 启用(fan-level)时生效,mode/swing 不受影响。 if !vm.levelFallback { return 0, false } levels := vm.levelValues() if len(levels) == 0 { return 0, false } switch key { case "low": return levels[0], true case "high": return levels[len(levels)-1], true case "medium": return levels[len(levels)/2], true } return 0, false } // levelValues 返回非 0 档位的升序值列表(levelFallback 辅助)。 func (vm *valMapper) levelValues() []int { var levels []int for v := range vm.val2name { if v != 0 { levels = append(levels, v) } } if len(levels) == 0 { return nil } sort.Ints(levels) return levels } 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 = newValMapperLevel(findValListInService("thermostat", "fan-level")) } else if s, p, ok := resolver.find("fan-level"); ok { d.propFanSpeed = siidPiid{s, p, true} d.fanMap = newValMapperLevel(findValList("fan-level")) } } else if s, p, ok := resolver.find("fan-level"); ok { d.propFanSpeed = siidPiid{s, p, true} d.fanMap = newValMapperLevel(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() logrus.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 { logrus.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 } logrus.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 { logrus.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) } logrus.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() logrus.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 }