fix(xiaomi): 空调风速按数字档位映射并增加 MQTT 反向语义解析

klwdz1.airc.001 等设备 fan-level 枚举为 Auto + Level1~Level5,SDK 侧
low/medium/high 在枚举里查不到,导致只有 Auto 能设置成功。

- valMapper 增加 levelFallback 开关(仅 fan-level 启用):精确匹配失败时
  low/medium/high 按数字档位相对位置映射(排除 Auto=0)
- name() 增加对称的反向映射:设备上报 1/3/5 → low/medium/high,
  控制与 MQTT 通知使用同一套语义枚举
- newValMapper 默认关闭 fallback,mode/swing 行为不变
- 新增 TestValMapperLevelFallback 覆盖正反向映射与 mode 无副作用
This commit is contained in:
2026-09-12 19:29:32 +08:00
parent a0ef854726
commit c18c0adaae
2 changed files with 121 additions and 8 deletions
+80 -8
View File
@@ -2,6 +2,7 @@ package devices
import ( import (
"context" "context"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -17,6 +18,10 @@ type valMapper struct {
val2name map[int]string val2name map[int]string
name2val map[string]int name2val map[string]int
names []string names []string
// levelFallback 允许 low/medium/high 映射到数字档位(仅 fan-level 启用,
// 用于枚举为 Level1~LevelN 的设备;mode/swing 枚举语义固定,禁用)。
levelFallback bool
} }
func newValMapper(vl miot.MIoTSpecValueList) *valMapper { func newValMapper(vl miot.MIoTSpecValueList) *valMapper {
@@ -38,15 +43,82 @@ func newValMapper(vl miot.MIoTSpecValueList) *valMapper {
return vm 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 { func (vm *valMapper) name(v int) string {
if vm == nil { return "" } if vm == nil {
return vm.val2name[v] 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) { func (vm *valMapper) val(name string) (int, bool) {
if vm == nil { return 0, false } if vm == nil {
v, ok := vm.name2val[strings.ToLower(name)] return 0, false
return v, ok }
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 { func (vm *valMapper) allNames() []string {
@@ -282,14 +354,14 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi
if resolver.hasService("thermostat") { if resolver.hasService("thermostat") {
if s, p, ok := resolver.findInService("thermostat", "fan-level"); ok { if s, p, ok := resolver.findInService("thermostat", "fan-level"); ok {
d.propFanSpeed = siidPiid{s, p, true} d.propFanSpeed = siidPiid{s, p, true}
d.fanMap = newValMapper(findValListInService("thermostat", "fan-level")) d.fanMap = newValMapperLevel(findValListInService("thermostat", "fan-level"))
} else if s, p, ok := resolver.find("fan-level"); ok { } else if s, p, ok := resolver.find("fan-level"); ok {
d.propFanSpeed = siidPiid{s, p, true} d.propFanSpeed = siidPiid{s, p, true}
d.fanMap = newValMapper(findValList("fan-level")) d.fanMap = newValMapperLevel(findValList("fan-level"))
} }
} else if s, p, ok := resolver.find("fan-level"); ok { } else if s, p, ok := resolver.find("fan-level"); ok {
d.propFanSpeed = siidPiid{s, p, true} d.propFanSpeed = siidPiid{s, p, true}
d.fanMap = newValMapper(findValList("fan-level")) d.fanMap = newValMapperLevel(findValList("fan-level"))
} }
// Try multiple swing mode type names // Try multiple swing mode type names
if s, p, ok := resolver.find("swing-mode"); ok { if s, p, ok := resolver.find("swing-mode"); ok {
+41
View File
@@ -3,6 +3,7 @@ package devices
import ( import (
"testing" "testing"
"xiaomihome/miot"
"xiaomihome/xiaomi" "xiaomihome/xiaomi"
) )
@@ -200,3 +201,43 @@ func TestServiceShortName(t *testing.T) {
} }
} }
} }
func TestValMapperLevelFallback(t *testing.T) {
// klwdz1.airc.001 场景:Auto + Level1~Level5
vl := miot.MIoTSpecValueList{
{Value: 0, Desc: "Auto"}, {Value: 1, Desc: "Level1"}, {Value: 2, Desc: "Level2"},
{Value: 3, Desc: "Level3"}, {Value: 4, Desc: "Level4"}, {Value: 5, Desc: "Level5"},
}
vm := newValMapperLevel(vl)
cases := []struct{ name string; want int; ok bool }{
{"auto", 0, true}, {"Auto", 0, true},
{"low", 1, true}, {"medium", 3, true}, {"high", 5, true},
{"level3", 3, true},
}
for _, c := range cases {
got, ok := vm.val(c.name)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("val(%q) = (%d,%v), want (%d,%v)", c.name, got, ok, c.want, c.ok)
}
}
// 反向解析(MQTT 上报值 → 语义枚举),与 val() 正向对称
rname := []struct{ v int; want string }{
{0, "auto"}, {1, "low"}, {3, "medium"}, {5, "high"}, {2, "Level2"},
}
for _, c := range rname {
if got := vm.name(c.v); got != c.want {
t.Errorf("name(%d) = %q, want %q", c.v, got, c.want)
}
}
// mode 枚举不受影响(无 low/medium/high fallback 副作用)
mvl := miot.MIoTSpecValueList{
{Value: 0, Desc: "Auto"}, {Value: 1, Desc: "Cool"}, {Value: 2, Desc: "Heat"},
}
mvm := newValMapper(mvl)
if _, ok := mvm.val("low"); ok {
t.Error("mode mapper should not resolve 'low'")
}
if v, ok := mvm.val("cool"); !ok || v != 1 {
t.Errorf("mode val(cool) = (%d,%v), want (1,true)", v, ok)
}
}