feat: AC 三合一设备支持 + 新增 Plug/Gateway 驱动 + Cover 结构化重构

**AC 驱动增强**
- 支持 thermostat + air-conditioner + air-fresh 多服务三合一设备,属性跨服务合并解析
- 新增 specResolver.findValListInService/findInService/hasService/serviceShortName 方法
- 移除 KindThermostat,heater/electric-blanket/thermostat 重新归类为 KindAirConditioner

**Cover 窗帘重构**
- CoverProps 结构化:Position/Status/Fault 分离
- SetPosition 优先写 target-position,回退 current-position
- OnPropsChanged/FetchProps 支持多属性同步刷新

**新增设备类型**
- Plug 驱动:插座/插头,支持开合、在线状态、属性变更订阅
- Gateway 驱动:网关设备类型识别
- miot/miot_errors.go:新增 MIoT 云端错误码定义

**MIoT 客户端健壮性**
- SetProp 空结果视为失败;设备被移除或离线时自动刷新设备列表
- GetDevices 的 has_more 改用 parseBool 严格解析
- MIPS SubscribeMany 批量订阅减少往返
- matchURN 改为按段精确匹配,避免 tofan 误匹配 fan

**Bridge**
- 移除 Thermostat case,Cover 改用结构化 CoverProps
- 新增设备创建后自动订阅属性变更

**其他**
- 日志整体 Info → Debug 降噪
- .gitignore 忽略 /.codebuddy/memory
- 更新 devices_test 与 08_adv_filter_classify 示例
This commit is contained in:
2026-08-23 19:42:55 +08:00
parent 04333750ab
commit 86c21ce91c
25 changed files with 1129 additions and 155 deletions
+49 -7
View File
@@ -199,25 +199,67 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi
return nil
}
// "on" is required
siid, piid, ok := resolver.find("on")
if !ok {
return nil, xiaomi.ErrNotSupported
// 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}
}
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 s, p, ok := resolver.find("target-temperature"); ok {
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}
}
if s, p, ok := resolver.find("fan-level"); ok {
// 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"))
}
+47 -2
View File
@@ -142,7 +142,8 @@ func (d *BaseDevice) getSpecResolver() *specResolver {
// specResolver is a thin wrapper to avoid importing specs package in every device file.
type specResolver struct {
byType map[string]*miot.MIoTSpecProperty
byType map[string]*miot.MIoTSpecProperty // property short name → first match
services []*miot.MIoTSpecService // 保留服务列表(支持按服务查找)
}
func newSpecResolver(spec *miot.MIoTSpecInstance) *specResolver {
@@ -150,7 +151,8 @@ func newSpecResolver(spec *miot.MIoTSpecInstance) *specResolver {
return nil
}
r := &specResolver{
byType: make(map[string]*miot.MIoTSpecProperty),
byType: make(map[string]*miot.MIoTSpecProperty),
services: spec.Services,
}
for _, svc := range spec.Services {
for i := range svc.Properties {
@@ -176,6 +178,17 @@ func propertyShortName(urn string) string {
return urn
}
// serviceShortName extracts the short name from a SPEC service URN.
// E.g., "urn:miot-spec-v2:service:thermostat:0000784A:..." → "thermostat".
// 注意:不能用 propertyShortName 解析服务 URN(其只认 parts[2]=="property")。
func serviceShortName(urn string) string {
parts := strings.Split(urn, ":")
if len(parts) >= 4 && parts[2] == "service" {
return parts[3]
}
return urn
}
func (r *specResolver) find(propType string) (siid, piid int, found bool) {
if r == nil {
return 0, 0, false
@@ -186,6 +199,38 @@ func (r *specResolver) find(propType string) (siid, piid int, found bool) {
return 0, 0, false
}
// findInService 在指定服务(短名,如 "thermostat")内查找属性。
// 用于多服务同名属性冲突时按服务精确定位(如三合一设备空调实体在 thermostat 服务)。
func (r *specResolver) findInService(svcName, propType string) (siid, piid int, found bool) {
if r == nil {
return 0, 0, false
}
for _, svc := range r.services {
if svc == nil || serviceShortName(svc.Type) != svcName {
continue
}
for _, prop := range svc.Properties {
if prop != nil && propertyShortName(prop.Type) == propType {
return svc.IID, prop.PIID, true
}
}
}
return 0, 0, false
}
// hasService 判断设备是否包含指定服务(短名)。
func (r *specResolver) hasService(svcName string) bool {
if r == nil {
return false
}
for _, svc := range r.services {
if svc != nil && serviceShortName(svc.Type) == svcName {
return true
}
}
return false
}
// prop returns the full SPEC property for the given type name.
func (r *specResolver) prop(propType string) *miot.MIoTSpecProperty {
if r == nil {
+75 -19
View File
@@ -5,6 +5,13 @@ import (
"xiaomihome/xiaomi"
)
// CoverProps 窗帘完整状态。
type CoverProps struct {
Position int `json:"position"` // 当前位置 0-100
Status int `json:"status"` // 运动状态:0=停止 1=开启中 2=关闭中
Fault int `json:"fault"` // 故障码,0=无故障
}
// Cover 窗帘/晾衣架统一控制接口
type Cover interface {
Device
@@ -15,17 +22,20 @@ type Cover interface {
SetPosition(ctx context.Context, pct int) error
GetPosition(ctx context.Context) (int, error)
OnPropsChanged(handler func(position int)) (string, error)
GetProps() int
FetchProps(ctx context.Context) (int, error)
OnPropsChanged(handler func(props CoverProps)) (string, error)
GetProps() CoverProps
FetchProps(ctx context.Context) (CoverProps, error)
}
type coverDevice struct {
BaseDevice
propMotor siidPiid
propPosition siidPiid
propMotor siidPiid // motor-control(write)
propPosition siidPiid // current-position(read)
propTarget siidPiid // target-position(write,SetPosition 用)
propStatus siidPiid // status(read)
propFault siidPiid // fault(read)
propsCache int
propsCache CoverProps
}
const (
@@ -48,6 +58,15 @@ func NewCover(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Cover, error) {
if s, p, ok := resolver.find("current-position"); ok {
d.propPosition = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("target-position"); ok {
d.propTarget = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("status"); ok {
d.propStatus = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("fault"); ok {
d.propFault = siidPiid{s, p, true}
}
return d, nil
}
@@ -63,10 +82,19 @@ func (d *coverDevice) Stop(ctx context.Context) error {
if !d.propMotor.valid { return xiaomi.ErrNotSupported }
return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorStop)
}
// SetPosition 设定目标位置,写 target-position(可写属性)。
// 老设备无 target-position 时回退写 current-position。
func (d *coverDevice) SetPosition(ctx context.Context, pct int) error {
if !d.propPosition.valid { return xiaomi.ErrNotSupported }
return d.SetProp(ctx, d.propPosition.siid, d.propPosition.piid, pct)
if d.propTarget.valid {
return d.SetProp(ctx, d.propTarget.siid, d.propTarget.piid, pct)
}
if d.propPosition.valid {
return d.SetProp(ctx, d.propPosition.siid, d.propPosition.piid, pct)
}
return xiaomi.ErrNotSupported
}
func (d *coverDevice) GetPosition(ctx context.Context) (int, error) {
if !d.propPosition.valid { return 0, xiaomi.ErrNotSupported }
prop, err := d.GetProp(ctx, d.propPosition.siid, d.propPosition.piid)
@@ -74,20 +102,48 @@ func (d *coverDevice) GetPosition(ctx context.Context) (int, error) {
return toInt(prop.Value), nil
}
func (d *coverDevice) OnPropsChanged(handler func(position int)) (string, error) {
if !d.propPosition.valid { return "", xiaomi.ErrNotSupported }
func (d *coverDevice) OnPropsChanged(handler func(props CoverProps)) (string, error) {
if !d.propPosition.valid && !d.propStatus.valid && !d.propFault.valid {
return "", xiaomi.ErrNotSupported
}
return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) {
if prop.SIID == d.propPosition.siid && prop.PIID == d.propPosition.piid {
d.propsCache = toInt(prop.Value)
handler(d.propsCache)
switch {
case d.propPosition.valid && prop.SIID == d.propPosition.siid && prop.PIID == d.propPosition.piid:
d.propsCache.Position = toInt(prop.Value)
case d.propStatus.valid && prop.SIID == d.propStatus.siid && prop.PIID == d.propStatus.piid:
d.propsCache.Status = toInt(prop.Value)
case d.propFault.valid && prop.SIID == d.propFault.siid && prop.PIID == d.propFault.piid:
d.propsCache.Fault = toInt(prop.Value)
default:
return // 其他属性不触发回调
}
handler(d.propsCache)
})
}
func (d *coverDevice) GetProps() int { return d.propsCache }
func (d *coverDevice) FetchProps(ctx context.Context) (int, error) {
pos, err := d.GetPosition(ctx)
if err != nil { return d.propsCache, err }
d.propsCache = pos
return pos, nil
func (d *coverDevice) GetProps() CoverProps { return d.propsCache }
func (d *coverDevice) FetchProps(ctx context.Context) (CoverProps, error) {
keys := []xiaomi.PropKey{}
if d.propPosition.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propPosition.siid, PIID: d.propPosition.piid}) }
if d.propStatus.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propStatus.siid, PIID: d.propStatus.piid}) }
if d.propFault.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propFault.siid, PIID: d.propFault.piid}) }
if len(keys) == 0 {
return d.propsCache, xiaomi.ErrNotSupported
}
props, err := d.client.GetProps(ctx, keys)
if err != nil {
return d.propsCache, err
}
for _, p := range props {
switch {
case d.propPosition.valid && p.SIID == d.propPosition.siid && p.PIID == d.propPosition.piid:
d.propsCache.Position = toInt(p.Value)
case d.propStatus.valid && p.SIID == d.propStatus.siid && p.PIID == d.propStatus.piid:
d.propsCache.Status = toInt(p.Value)
case d.propFault.valid && p.SIID == d.propFault.siid && p.PIID == d.propFault.piid:
d.propsCache.Fault = toInt(p.Value)
}
}
return d.propsCache, nil
}
+29
View File
@@ -131,6 +131,18 @@ func TestMatchURN(t *testing.T) {
[]string{"heater"},
true,
},
{
"tofan-wk01 not fan", // "tofan" 含 "fan" 子串,不得误匹配 fan 系列
"urn:miot-spec-v2:device:thermostat:0000A031:tofan-wk01:1:0000C822",
[]string{"fan", "ceiling-fan", "ventilator", "air-fresh", "air-purifier"},
false,
},
{
"tofan-wk01 thermostat",
"urn:miot-spec-v2:device:thermostat:0000A031:tofan-wk01:1:0000C822",
[]string{"heater", "electric-blanket", "thermostat"},
true,
},
{
"no match",
"urn:miot-spec-v2:device:unknown:0000A099:test:1",
@@ -171,3 +183,20 @@ func TestCreateNilInfo(t *testing.T) {
t.Error("expected error for nil info")
}
}
func TestServiceShortName(t *testing.T) {
cases := []struct {
urn string
want string
}{
{"urn:miot-spec-v2:service:thermostat:0000784A:tofan-wk01:1", "thermostat"},
{"urn:miot-spec-v2:service:air-conditioner:0000780F:tofan-wk01:1", "air-conditioner"},
{"urn:tofan-spec:service:adv-setting:00007801:tofan-wk01:1", "adv-setting"},
{"urn:miot-spec-v2:property:on:00000006:tofan-wk01:1", "urn:miot-spec-v2:property:on:00000006:tofan-wk01:1"},
}
for _, c := range cases {
if got := serviceShortName(c.urn); got != c.want {
t.Errorf("serviceShortName(%q) = %q, want %q", c.urn, got, c.want)
}
}
}
+44 -9
View File
@@ -1,6 +1,8 @@
package devices
import (
"strings"
"xiaomihome/xiaomi"
)
@@ -84,7 +86,7 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er
case matchURN(urn, "water-heater", "water-cooler", "kettle"):
ctrl, err = NewWaterHeater(client, info)
case matchURN(urn, "heater", "electric-blanket", "thermostat"):
ctrl, err = NewThermostat(client, info)
ctrl, err = NewAirConditioner(client, info)
case matchURN(urn, "sensor-occupy", "sensor_occupy", "occupancy-sensor", "occupancy_sensor", "motion-sensor", "motion_sensor", "magnet"):
ctrl, err = NewOccupancySensor(client, info)
case matchURN(urn, "sensor-temp", "sensor_temp", "sensor-humidity", "sensor_humidity", "sensor-ht", "sensor_ht", "sensor-smoke", "sensor-gasm", "illumination"):
@@ -105,17 +107,50 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er
return ctrl, nil
}
// matchURN checks if the device URN contains any of the given type strings.
// matchURN 匹配 URN 的 device 类型段。
// 标准格式 urn:miot-spec-v2:device:{TYPE}:... 做精确段匹配(避免 "tofan" 撞 "fan" 这类子串误判);
// 非标准 URN 回退到边界子串匹配。
func matchURN(urn string, types ...string) bool {
for _, t := range types {
if len(urn) >= len(t) {
// Simple substring match — URN format: urn:miot-spec-v2:device:TYPE:...
for i := 0; i <= len(urn)-len(t); i++ {
if urn[i:i+len(t)] == t {
return true
}
parts := strings.Split(urn, ":")
if len(parts) >= 4 && parts[2] == "device" {
devType := parts[3]
for _, t := range types {
if devType == t {
return true
}
}
return false
}
// 非标准 URN:按边界匹配,前后必须是分隔符,避免误撞
for _, t := range types {
if boundaryContains(urn, t) {
return true
}
}
return false
}
// boundaryContains 检查 sub 是否在 s 中出现且前后是分隔符或边界。
func boundaryContains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] != sub {
continue
}
beforeOK := i == 0
if !beforeOK {
beforeOK = isBoundary(s[i-1])
}
afterOK := i+len(sub) == len(s)
if !afterOK {
afterOK = isBoundary(s[i+len(sub)])
}
if beforeOK && afterOK {
return true
}
}
return false
}
func isBoundary(c byte) bool {
return c == ':' || c == '-' || c == '.' || c == '_' || c == '/'
}
+22
View File
@@ -0,0 +1,22 @@
package devices
import "xiaomihome/xiaomi"
// Gateway 网关设备(仅类型识别,不参与控制)。
type Gateway interface {
Device
}
type gatewayDevice struct {
BaseDevice
}
// NewGateway creates a Gateway device wrapper for type recognition.
func NewGateway(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Gateway, error) {
if info == nil {
return nil, xiaomi.ErrDeviceNotFound
}
return &gatewayDevice{
BaseDevice: NewBaseDevice(client, info),
}, nil
}
+102
View File
@@ -0,0 +1,102 @@
package devices
import (
"context"
"fmt"
"xiaomihome/xiaomi"
)
// Plug 插座/插头统一控制接口(电脑开机卡、智能插座等)。
type Plug interface {
Device
TurnOn(ctx context.Context) error
TurnOff(ctx context.Context) error
IsOn(ctx context.Context) (bool, error)
OnPropsChanged(handler func(on bool)) (string, error)
GetProps() PlugProps
FetchProps(ctx context.Context) (PlugProps, error)
}
// PlugProps 插座完整状态。
type PlugProps struct {
On bool `json:"on"`
}
type plugDevice struct {
BaseDevice
propOn siidPiid
propsCache PlugProps
}
// NewPlug creates a Plug control for the given device.
func NewPlug(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Plug, error) {
if info == nil {
return nil, xiaomi.ErrDeviceNotFound
}
miotDev := client.GetMIoTDevice(info.DID)
if miotDev == nil || miotDev.SpecInstance() == nil {
return nil, fmt.Errorf("%w: device has no SPEC", xiaomi.ErrNotSupported)
}
d := &plugDevice{
BaseDevice: NewBaseDevice(client, info),
}
for _, svc := range miotDev.SpecInstance().Services {
for _, prop := range svc.Properties {
if propertyShortName(prop.Type) == "on" && !d.propOn.valid {
d.propOn = siidPiid{prop.SIID, prop.PIID, true}
}
}
}
if !d.propOn.valid {
return nil, fmt.Errorf("%w: no on property", xiaomi.ErrNotSupported)
}
return d, nil
}
func (d *plugDevice) TurnOn(ctx context.Context) error {
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true)
}
func (d *plugDevice) TurnOff(ctx context.Context) error {
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false)
}
func (d *plugDevice) IsOn(ctx context.Context) (bool, error) {
prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid)
if err != nil {
return false, err
}
b, _ := prop.Value.(bool)
return b, nil
}
func (d *plugDevice) OnPropsChanged(handler func(on bool)) (string, error) {
return d.SubProp(d.propOn.siid, d.propOn.piid, func(did string, prop *xiaomi.PropertyValue) {
if b, ok := prop.Value.(bool); ok {
d.propsCache.On = b
handler(b)
}
})
}
func (d *plugDevice) GetProps() PlugProps { return d.propsCache }
func (d *plugDevice) FetchProps(ctx context.Context) (PlugProps, error) {
props, err := d.client.GetProps(ctx, []xiaomi.PropKey{
{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid},
})
if err != nil {
return d.propsCache, err
}
for _, p := range props {
if p.SIID == d.propOn.siid && p.PIID == d.propOn.piid {
d.propsCache.On, _ = p.Value.(bool)
}
}
return d.propsCache, nil
}
-1
View File
@@ -12,7 +12,6 @@ const (
TypeHumidifier = "humidifier" // 加湿器
TypeVacuum = "vacuum" // 吸尘器/扫地机器人
TypeWaterHeater = "water_heater" // 热水器
TypeThermostat = "thermostat" // 温控器
TypeOccupancySensor = "sensor_occupy" // 人体传感器
TypeTempHumidSensor = "sensor" // 温湿度传感器(官方 URN type)
TypeSpeaker = "speaker" // 音箱