**回读目标聚合(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)分别记录日志
964 lines
28 KiB
Go
964 lines
28 KiB
Go
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
|
||
}
|