Files
xiaomihome/xiaomi/devices/fan.go
T
4566704 13f1d84cf3 feat(xiaomi): Fan 驱动移植 AC 的 set 后回读(readback)机制
- 改动:fanDevice 复用 acDevice 的 readback 机制(recordReadback + 防抖 3s + 重试 3s×5 + 超时 30s + FetchProps 值比对 + 逐字段回调),覆盖 on/fan-level/mode/oscillation/natural-wind 全部 set 方法
- 原因:BLE/Mesh 设备(如 ecosnu.airfresh.eksn1)固件不广播云端指令,set 成功后无 properties_changed 推送,业务层收不到状态回调;此前 readback 仅 AC 驱动支持
- 门控:仍按 NeedReadback(model) 白名单生效,不影响未配置型号
2026-10-05 10:11:42 +08:00

532 lines
15 KiB
Go
Raw Permalink 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"
"sync"
"time"
"github.com/sirupsen/logrus"
"git.zeroonesoft.cn/golib/xiaomihome/miot"
"git.zeroonesoft.cn/golib/xiaomihome/xiaomi"
)
// Fan 风扇/新风机统一控制接口。
// 风扇和新风都有 mode 和 fan-level,但部分新风机没有 fan-level,此时用 mode 控制。
type Fan interface {
Device
TurnOn(ctx context.Context) error
TurnOff(ctx context.Context) error
IsOn(ctx context.Context) (bool, error)
// 风速控制(有 fan-level 时可用)
HasFanLevel() bool
FanLevelValues() []int
SetFanLevel(ctx context.Context, level int) error
GetFanLevel(ctx context.Context) (int, error)
// 模式控制(有 mode+ValueList 时可用,返回原始 uint8 值)
HasMode() bool
ModeValues() []int
SetMode(ctx context.Context, mode int) error
GetMode(ctx context.Context) (int, error)
SetOscillation(ctx context.Context, on bool) error
IsOscillating(ctx context.Context) (bool, error)
SetNaturalWind(ctx context.Context, on bool) error
IsNaturalWind(ctx context.Context) (bool, error)
OnPropsChanged(handler func(props FanProps, changed string)) (string, error)
GetProps() FanProps
FetchProps(ctx context.Context) (FanProps, error)
}
// FanProps 风扇/新风机完整状态(MQTT 缓存)
type FanProps struct {
On bool `json:"on"`
FanLevel int `json:"fanLevel"`
Mode int `json:"mode"`
Oscillation bool `json:"oscillation"`
NaturalWind bool `json:"naturalWind"`
}
type fanDevice struct {
BaseDevice
propOn siidPiid // "on"
propFanLevel siidPiid // "fan-level"
propMode siidPiid // "mode" — 有 ValueList 时为模式选择器
propNaturalWind siidPiid // "mode" — 无 ValueList 时为自然风开关
propOscillation siidPiid // "horizontal-swing" or "oscillation"
modeValues []int // 可用模式值列表(来自 SPEC value-list)
fanLevelValues []int // 可用风速档位值列表
propsCache FanProps
// set 后回读(验证设置是否成功):记录目标值,防抖 + 重试比较。
// 用于固件不广播云端指令的 BLE/Mesh 设备(如 ecosnu.airfresh.eksn1)。
rbMu sync.Mutex
rbPending bool // 是否有回读调度在跑
rbLastSet time.Time // 最近一次 set 时间
rbTargets []rbTarget // 本次待验证的目标(聚合连续 set)
rbHandler func(props FanProps, changed string) // OnPropsChanged 注册的 handler
}
// NewFan creates a Fan control for the given device.
func NewFan(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Fan, error) {
d := &fanDevice{
BaseDevice: NewBaseDevice(client, info),
}
resolver := d.getSpecResolver()
if resolver == nil {
return nil, xiaomi.ErrNoSPEC
}
siid, piid, ok := resolver.find("on")
if !ok {
return nil, xiaomi.ErrNotSupported
}
d.propOn = siidPiid{siid, piid, true}
if s, p, ok := resolver.find("fan-level"); ok {
d.propFanLevel = siidPiid{s, p, true}
if prop := resolver.prop("fan-level"); prop != nil {
if len(prop.ValueList) > 0 {
for _, item := range prop.ValueList {
d.fanLevelValues = append(d.fanLevelValues, toInt(item.Value))
}
} else if prop.ValueRange != nil {
d.fanLevelValues = rangeValues(prop.ValueRange)
}
}
}
if s, p, ok := resolver.find("horizontal-swing"); ok {
d.propOscillation = siidPiid{s, p, true}
}
// mode 属性:有 ValueList → 模式选择器,有 ValueRange → 连续值,否则 → 自然风开关
if s, p, ok := resolver.find("mode"); ok {
if prop := resolver.prop("mode"); prop != nil {
if len(prop.ValueList) > 0 {
d.propMode = siidPiid{s, p, true}
for _, item := range prop.ValueList {
d.modeValues = append(d.modeValues, toInt(item.Value))
}
} else if prop.ValueRange != nil {
d.propMode = siidPiid{s, p, true}
d.modeValues = rangeValues(prop.ValueRange)
}
}
if !d.propMode.valid {
d.propNaturalWind = siidPiid{s, p, true}
}
}
return d, nil
}
func (d *fanDevice) 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 *fanDevice) 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 *fanDevice) 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 *fanDevice) HasFanLevel() bool { return d.propFanLevel.valid }
func (d *fanDevice) FanLevelValues() []int { return d.fanLevelValues }
func (d *fanDevice) SetFanLevel(ctx context.Context, level int) error {
if !d.propFanLevel.valid {
return xiaomi.ErrNotSupported
}
if err := d.SetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid, level); err != nil {
return err
}
d.recordReadback("fan-level", level)
return nil
}
func (d *fanDevice) GetFanLevel(ctx context.Context) (int, error) {
if !d.propFanLevel.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid)
if err != nil {
return 0, err
}
return toInt(prop.Value), nil
}
func (d *fanDevice) HasMode() bool { return d.propMode.valid }
func (d *fanDevice) ModeValues() []int { return d.modeValues }
func (d *fanDevice) SetMode(ctx context.Context, mode int) error {
if !d.propMode.valid {
return xiaomi.ErrNotSupported
}
if err := d.SetProp(ctx, d.propMode.siid, d.propMode.piid, mode); err != nil {
return err
}
d.recordReadback("mode", mode)
return nil
}
func (d *fanDevice) GetMode(ctx context.Context) (int, error) {
if !d.propMode.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid)
if err != nil {
return 0, err
}
return toInt(prop.Value), nil
}
func (d *fanDevice) SetOscillation(ctx context.Context, on bool) error {
if !d.propOscillation.valid {
return xiaomi.ErrNotSupported
}
if err := d.SetProp(ctx, d.propOscillation.siid, d.propOscillation.piid, on); err != nil {
return err
}
d.recordReadback("oscillation", on)
return nil
}
func (d *fanDevice) IsOscillating(ctx context.Context) (bool, error) {
if !d.propOscillation.valid {
return false, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propOscillation.siid, d.propOscillation.piid)
if err != nil {
return false, err
}
if b, ok := prop.Value.(bool); ok {
return b, nil
}
return false, nil
}
func (d *fanDevice) SetNaturalWind(ctx context.Context, on bool) error {
if !d.propNaturalWind.valid {
return xiaomi.ErrNotSupported
}
v := 0
if on {
v = 1
}
if err := d.SetProp(ctx, d.propNaturalWind.siid, d.propNaturalWind.piid, v); err != nil {
return err
}
d.recordReadback("natural-wind", on)
return nil
}
func (d *fanDevice) IsNaturalWind(ctx context.Context) (bool, error) {
if !d.propNaturalWind.valid {
return false, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propNaturalWind.siid, d.propNaturalWind.piid)
if err != nil {
return false, err
}
return toInt(prop.Value) == 1, nil
}
func (d *fanDevice) OnPropsChanged(handler func(props FanProps, changed string)) (string, error) {
type field struct {
siid, piid int
name string
fill func(*FanProps, interface{})
}
var fields []field
if d.propOn.valid {
fields = append(fields, field{d.propOn.siid, d.propOn.piid, "on",
func(s *FanProps, v interface{}) { s.On, _ = v.(bool) }})
}
if d.propFanLevel.valid {
fields = append(fields, field{d.propFanLevel.siid, d.propFanLevel.piid, "fan-level",
func(s *FanProps, v interface{}) { s.FanLevel = toInt(v) }})
}
if d.propMode.valid {
fields = append(fields, field{d.propMode.siid, d.propMode.piid, "mode",
func(s *FanProps, v interface{}) { s.Mode = toInt(v) }})
}
if d.propOscillation.valid {
fields = append(fields, field{d.propOscillation.siid, d.propOscillation.piid, "oscillation",
func(s *FanProps, v interface{}) { s.Oscillation, _ = v.(bool) }})
}
if d.propNaturalWind.valid {
fields = append(fields, field{d.propNaturalWind.siid, d.propNaturalWind.piid, "natural-wind",
func(s *FanProps, v interface{}) { s.NaturalWind = toInt(v) == 1 }})
}
if len(fields) == 0 {
return "", xiaomi.ErrNotSupported
}
// 保存 handler 供 set 后回读使用
d.rbMu.Lock()
d.rbHandler = handler
d.rbMu.Unlock()
return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) {
for _, f := range fields {
if f.siid == prop.SIID && f.piid == prop.PIID {
f.fill(&d.propsCache, prop.Value)
handler(d.propsCache, f.name)
return
}
}
})
}
func (d *fanDevice) GetProps() FanProps { return d.propsCache }
func (d *fanDevice) FetchProps(ctx context.Context) (FanProps, error) {
keys := []xiaomi.PropKey{}
if d.propOn.valid {
keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid})
}
if d.propFanLevel.valid {
keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propFanLevel.siid, PIID: d.propFanLevel.piid})
}
if d.propMode.valid {
keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propMode.siid, PIID: d.propMode.piid})
}
if d.propOscillation.valid {
keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOscillation.siid, PIID: d.propOscillation.piid})
}
if d.propNaturalWind.valid {
keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propNaturalWind.siid, PIID: d.propNaturalWind.piid})
}
props, err := d.client.GetProps(ctx, keys)
if err != nil {
return d.propsCache, err
}
result := FanProps{}
for _, p := range props {
switch {
case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid:
result.On, _ = p.Value.(bool)
case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid:
result.FanLevel = toInt(p.Value)
case d.propMode.valid && p.SIID == d.propMode.siid && p.PIID == d.propMode.piid:
result.Mode = toInt(p.Value)
case d.propOscillation.valid && p.SIID == d.propOscillation.siid && p.PIID == d.propOscillation.piid:
result.Oscillation, _ = p.Value.(bool)
case d.propNaturalWind.valid && p.SIID == d.propNaturalWind.siid && p.PIID == d.propNaturalWind.piid:
result.NaturalWind = toInt(p.Value) == 1
}
}
d.propsCache = result
return result, nil
}
// recordReadback 记录本次 set 的目标值并调度回读验证(白名单型号)。
// 连续 set 聚合:同属性覆盖目标值,不同属性追加。与 acDevice 同机制。
func (d *fanDevice) 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("[fanDevice] readback scheduled: %s (%s) targets=%v\n", d.info.DID, d.info.Model, d.rbTargetNames())
go d.readbackLoop()
}
// readbackLoop 防抖 + 重试回读,验证 set 目标值是否被设备执行。
// 静默 readbackIdle 后开始回读;未命中目标则间隔重试,直到命中或超时。
func (d *fanDevice) 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("[fanDevice] 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("[fanDevice] 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 *fanDevice) checkReadbackTargets(props FanProps, targets []rbTarget) []string {
var missed []string
for _, t := range targets {
if !rbValueEqual(fanReadbackFieldValue(props, t.name), t.want) {
missed = append(missed, t.name)
}
}
return missed
}
// finishReadbackOK 全部目标命中:逐字段上报业务层(与 MQTT 推送同语义)。
func (d *fanDevice) finishReadbackOK(props FanProps, targets []rbTarget) {
d.rbMu.Lock()
handler := d.rbHandler
d.rbMu.Unlock()
if handler == nil {
logrus.Warnf("[fanDevice] 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("[fanDevice] readback %s success: on=%v fanLevel=%d mode=%d swing=%v naturalWind=%v\n",
d.info.DID, props.On, props.FanLevel, props.Mode, props.Oscillation, props.NaturalWind)
d.rbMu.Lock()
d.rbPending = false
d.rbMu.Unlock()
}
// finishReadback 回读终止(超时/未生效):清理调度并记录日志。
func (d *fanDevice) finishReadback(reason string) {
d.rbMu.Lock()
names := d.rbTargetNamesLocked()
d.rbTargets = nil
d.rbPending = false
d.rbMu.Unlock()
logrus.Warnf("[fanDevice] readback %s finished: %s targets=%v\n", d.info.DID, reason, names)
}
func (d *fanDevice) rbTargetNames() []string {
d.rbMu.Lock()
defer d.rbMu.Unlock()
return d.rbTargetNamesLocked()
}
func (d *fanDevice) rbTargetNamesLocked() []string {
names := make([]string, 0, len(d.rbTargets))
for _, t := range d.rbTargets {
names = append(names, t.name)
}
return names
}
// fanReadbackFieldValue 从 FanProps 取指定属性的值(与 rbTarget.want 类型一致)。
func fanReadbackFieldValue(props FanProps, name string) interface{} {
switch name {
case "on":
return props.On
case "fan-level":
return props.FanLevel
case "mode":
return props.Mode
case "oscillation":
return props.Oscillation
case "natural-wind":
return props.NaturalWind
}
return nil
}
// rangeValues 从 SPEC value-range 生成离散值列表,如 [1,8,1] → [1,2,3,4,5,6,7,8]
func rangeValues(r *miot.MIoTSpecValueRange) []int {
if r == nil {
return nil
}
min, max, step := toInt(r.Min), toInt(r.Max), toInt(r.Step)
if step <= 0 {
step = 1
}
if max > 100 {
return nil // 太大会爆内存,返回 nil 让调用方自行处理
}
var vals []int
for i := min; i <= max; i += step {
vals = append(vals, i)
}
return vals
}