chore: 提交高层 xiaomi 封装库及示例

- 新增 xiaomi/examples/ 下 8 个示例程序(用户/家庭查询、设备列表、开关/灯光/空调控制、属性订阅、高级过滤分类、SPEC 解析)
- miot_client_sub.go: 重构 SubProp/SubEvent,使用 buildPropTopic/buildEventTopic 支持通配符订阅(siid/piid=0 → +),并修复锁顺序问题(将 RequestRefreshProp 移到 Lock 外)
- spec_parser.go: 新增 downloadSpecFile 方法,本地 SPEC 文件缺失时自动从 miot-spec.org 下载
- ARCH_PLAN.md: 架构设计从 Proposed 更新为 Accepted(v1.0→v1.1),补充设备分类/工厂/SPEC 映射等模块设计
- xiaomi/: 新增 miot 上层强类型封装模块,包含 Client 主入口、用户/家庭/设备 API、属性读写、动作调用、订阅通知,以及 devices/ 设备控制抽象(Switch/Light/AirConditioner/Fan/Cover/Humidifier/Vacuum/WaterHeater/Thermostat)和 specs/ SPEC 查询辅助
- 更多 xiaomi 示例(风扇/窗帘/传感器控制)
This commit is contained in:
2026-06-29 08:51:21 +08:00
parent 35efed7d15
commit a3d94c4b9f
93 changed files with 7582 additions and 157 deletions
+120
View File
@@ -0,0 +1,120 @@
package specs
import "xiaomihome/miot"
// PropertyMapper provides bidirectional mapping between SPEC raw values (integers)
// and human-readable enum strings. For example, an air conditioner mode property
// might use raw value 0 for "cool", 1 for "heat", etc.
//
// The mapper is constructed from the SPEC value-list combined with an external
// lookup table that maps raw values to their semantic names.
type PropertyMapper struct {
rawToEnum map[interface{}]string
enumToRaw map[string]interface{}
}
// NewPropertyMapper creates a PropertyMapper from a SPEC value-list and
// a mapping of raw values → enum strings.
//
// Example:
//
// mapper := NewPropertyMapper(prop.ValueList, map[interface{}]string{
// 0: "cool",
// 1: "heat",
// 2: "fan",
// 3: "dry",
// 4: "auto",
// })
func NewPropertyMapper(vlist miot.MIoTSpecValueList, valueMap map[interface{}]string) *PropertyMapper {
m := &PropertyMapper{
rawToEnum: make(map[interface{}]string),
enumToRaw: make(map[string]interface{}),
}
// Build from the provided valueMap
for raw, enum := range valueMap {
m.rawToEnum[raw] = enum
m.enumToRaw[enum] = raw
}
// Also build from SPEC value-list descriptions (as a fallback)
for _, item := range vlist {
if item.Desc != "" {
name := item.Desc
if item.Name != "" {
name = item.Name
}
m.rawToEnum[item.Value] = name
m.enumToRaw[name] = item.Value
}
}
return m
}
// ToEnum converts a raw SPEC value to its enum string representation.
// Returns ("", false) if the raw value is not in the mapping.
func (m *PropertyMapper) ToEnum(raw interface{}) (string, bool) {
if m == nil {
return "", false
}
s, ok := m.rawToEnum[raw]
return s, ok
}
// ToRaw converts an enum string to its raw SPEC value.
// Returns (nil, false) if the enum string is not in the mapping.
func (m *PropertyMapper) ToRaw(enum string) (interface{}, bool) {
if m == nil {
return nil, false
}
v, ok := m.enumToRaw[enum]
return v, ok
}
// HasEnum checks whether the given enum string exists in the mapping.
func (m *PropertyMapper) HasEnum(enum string) bool {
if m == nil {
return false
}
_, ok := m.enumToRaw[enum]
return ok
}
// HasRaw checks whether the given raw value exists in the mapping.
func (m *PropertyMapper) HasRaw(raw interface{}) bool {
if m == nil {
return false
}
_, ok := m.rawToEnum[raw]
return ok
}
// FromValueList creates a PropertyMapper purely from the SPEC value list.
// Uses the ValueItem.Desc or ValueItem.Name as the enum string.
func FromValueList(vlist miot.MIoTSpecValueList) *PropertyMapper {
if len(vlist) == 0 {
return &PropertyMapper{
rawToEnum: make(map[interface{}]string),
enumToRaw: make(map[string]interface{}),
}
}
m := &PropertyMapper{
rawToEnum: make(map[interface{}]string),
enumToRaw: make(map[string]interface{}),
}
for _, item := range vlist {
name := item.Name
if name == "" {
name = item.Desc
}
if name != "" {
m.rawToEnum[item.Value] = name
m.enumToRaw[name] = item.Value
}
}
return m
}
+171
View File
@@ -0,0 +1,171 @@
// Package specs provides SPEC property resolution — mapping semantic property
// names (like "on", "mode", "target-temperature") to siid/piid pairs.
//
// This enables device control abstractions to work across different Xiaomi
// device models that may assign different siid/piid numbering to the same
// functional property.
package specs
import (
"strings"
"xiaomihome/miot"
)
// PropertyResolver resolves semantic property names to siid/piid pairs.
// It indexes a device's SPEC instance by property type (Type field),
// description (translated), and data format for flexible lookup.
type PropertyResolver struct {
spec *miot.MIoTSpecInstance
byType map[string]*miot.MIoTSpecProperty // type → first matching property
byDesc map[string]*miot.MIoTSpecProperty // description → property
byFormat map[string][]*miot.MIoTSpecProperty // format → properties
actionMap map[int]map[string]int // siid → (actionType → aiid)
}
// NewPropertyResolver creates a PropertyResolver from a SPEC instance.
// Returns nil if spec is nil.
func NewPropertyResolver(spec *miot.MIoTSpecInstance) *PropertyResolver {
if spec == nil {
return nil
}
r := &PropertyResolver{
spec: spec,
byType: make(map[string]*miot.MIoTSpecProperty),
byDesc: make(map[string]*miot.MIoTSpecProperty),
byFormat: make(map[string][]*miot.MIoTSpecProperty),
actionMap: make(map[int]map[string]int),
}
// Index all properties across all services
for _, svc := range spec.Services {
for i := range svc.Properties {
prop := svc.Properties[i]
// Index by type (first match wins for primary lookup)
name := propShortName(prop.Type)
if name != "" {
if _, exists := r.byType[name]; !exists {
r.byType[name] = prop
}
}
// Index by translated description
if prop.DescriptionTrans != "" {
if _, exists := r.byDesc[prop.DescriptionTrans]; !exists {
r.byDesc[prop.DescriptionTrans] = prop
}
}
// Fallback: index by raw description
if prop.Description != "" {
if _, exists := r.byDesc[prop.Description]; !exists {
r.byDesc[prop.Description] = prop
}
}
// Index by format
if prop.Format != "" {
r.byFormat[prop.Format] = append(r.byFormat[prop.Format], prop)
}
}
// Index actions
for _, act := range svc.Actions {
if r.actionMap[act.SIID] == nil {
r.actionMap[act.SIID] = make(map[string]int)
}
if act.Type != "" {
r.actionMap[act.SIID][act.Type] = act.AIID
}
}
}
return r
}
// FindByType looks up a property by its SPEC type string (e.g., "on", "mode",
// "target-temperature", "fan-level", "brightness").
// Returns (siid, piid, found).
func (r *PropertyResolver) FindByType(propType string) (siid, piid int, found bool) {
if prop, ok := r.byType[propType]; ok {
return prop.SIID, prop.PIID, true
}
return 0, 0, false
}
// FindByFormat looks up the first property with the given format string within
// a specific service (siid). Returns (piid, found).
// format examples: "bool", "uint8", "uint16", "float", "string"
func (r *PropertyResolver) FindByFormat(format string, siid int) (piid int, found bool) {
props, ok := r.byFormat[format]
if !ok {
return 0, false
}
for _, p := range props {
if p.SIID == siid {
return p.PIID, true
}
}
return 0, false
}
// FindByDescription looks up a property by its description text (translated or raw).
// Returns (siid, piid, found).
func (r *PropertyResolver) FindByDescription(desc string) (siid, piid int, found bool) {
if prop, ok := r.byDesc[desc]; ok {
return prop.SIID, prop.PIID, true
}
return 0, 0, false
}
// FindAction looks up an action by its type within a specific service.
// Returns (aiid, found).
func (r *PropertyResolver) FindAction(siid int, actionType string) (aiid int, found bool) {
if actions, ok := r.actionMap[siid]; ok {
if aiid, ok := actions[actionType]; ok {
return aiid, true
}
}
return 0, false
}
// GetAllProperties returns all properties for a given siid.
func (r *PropertyResolver) GetAllProperties(siid int) []*miot.MIoTSpecProperty {
var result []*miot.MIoTSpecProperty
for _, svc := range r.spec.Services {
if svc.IID == siid {
return svc.Properties
}
}
return result
}
// GetProperty returns a specific property by siid/piid.
func (r *PropertyResolver) GetProperty(siid, piid int) *miot.MIoTSpecProperty {
for _, svc := range r.spec.Services {
if svc.IID != siid {
continue
}
for _, p := range svc.Properties {
if p.PIID == piid {
return p
}
}
}
return nil
}
// Spec returns the underlying SPEC instance.
func (r *PropertyResolver) Spec() *miot.MIoTSpecInstance {
return r.spec
}
// propShortName extracts the short name from a SPEC property URN.
func propShortName(urn string) string {
parts := strings.Split(urn, ":")
if len(parts) >= 4 && parts[2] == "property" {
return parts[3]
}
return urn
}
+240
View File
@@ -0,0 +1,240 @@
package specs
import (
"testing"
"xiaomihome/miot"
)
// buildTestSpec creates a mock SPEC instance for testing.
func buildTestSpec() *miot.MIoTSpecInstance {
return &miot.MIoTSpecInstance{
URN: "urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-c1:1",
Type: "air-conditioner",
Description: "Xiaomi Air Conditioner",
DescriptionTrans: "小米空调",
Services: []*miot.MIoTSpecService{
{
IID: 1,
Type: "device-information",
Description: "Device Information",
Properties: []*miot.MIoTSpecProperty{
{SIID: 1, PIID: 1, Type: "", Format: "string", Access: []string{"read"}, Description: "manufacturer", DescriptionTrans: "制造商"},
},
},
{
IID: 2,
Type: "air-conditioner",
Description: "Air Conditioner",
Properties: []*miot.MIoTSpecProperty{
{SIID: 2, PIID: 1, Type: "on", Format: "bool", Access: []string{"read", "write", "notify"}, Description: "Power", DescriptionTrans: "开关"},
{SIID: 2, PIID: 2, Type: "mode", Format: "uint8", Access: []string{"read", "write", "notify"}, Description: "Mode", DescriptionTrans: "模式"},
{SIID: 2, PIID: 3, Type: "target-temperature", Format: "float", Access: []string{"read", "write", "notify"}, Description: "Target Temp", DescriptionTrans: "目标温度"},
{SIID: 2, PIID: 4, Type: "temperature", Format: "float", Access: []string{"read"}, Description: "Current Temp", DescriptionTrans: "当前温度"},
{SIID: 2, PIID: 5, Type: "fan-level", Format: "uint8", Access: []string{"read", "write", "notify"}, Description: "Fan Speed", DescriptionTrans: "风速"},
{SIID: 2, PIID: 6, Type: "swing-mode", Format: "uint8", Access: []string{"read", "write", "notify"}, Description: "Swing", DescriptionTrans: "摆风"},
},
Actions: []*miot.MIoTSpecAction{
{SIID: 2, AIID: 1, Type: "toggle", Description: "Toggle Power"},
},
},
},
}
}
func TestNewPropertyResolver(t *testing.T) {
t.Run("valid spec", func(t *testing.T) {
spec := buildTestSpec()
r := NewPropertyResolver(spec)
if r == nil {
t.Fatal("resolver should not be nil")
}
if r.spec != spec {
t.Error("spec reference mismatch")
}
})
t.Run("nil spec", func(t *testing.T) {
r := NewPropertyResolver(nil)
if r != nil {
t.Error("resolver should be nil for nil spec")
}
})
}
func TestFindByType(t *testing.T) {
r := NewPropertyResolver(buildTestSpec())
tests := []struct {
name string
propType string
wantSIID int
wantPIID int
wantOK bool
}{
{"on", "on", 2, 1, true},
{"mode", "mode", 2, 2, true},
{"target-temperature", "target-temperature", 2, 3, true},
{"temperature", "temperature", 2, 4, true},
{"fan-level", "fan-level", 2, 5, true},
{"swing-mode", "swing-mode", 2, 6, true},
{"nonexistent", "brightness", 0, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
siid, piid, ok := r.FindByType(tt.propType)
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if siid != tt.wantSIID {
t.Errorf("siid = %d, want %d", siid, tt.wantSIID)
}
if piid != tt.wantPIID {
t.Errorf("piid = %d, want %d", piid, tt.wantPIID)
}
})
}
}
func TestFindByFormat(t *testing.T) {
r := NewPropertyResolver(buildTestSpec())
tests := []struct {
name string
format string
siid int
wantPIID int
wantOK bool
}{
{"bool in siid 2", "bool", 2, 1, true},
{"float in siid 2 (first match)", "float", 2, 3, true},
{"string in siid 1", "string", 1, 1, true},
{"nonexistent format", "int16", 2, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
piid, ok := r.FindByFormat(tt.format, tt.siid)
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if piid != tt.wantPIID {
t.Errorf("piid = %d, want %d", piid, tt.wantPIID)
}
})
}
}
func TestFindByDescription(t *testing.T) {
r := NewPropertyResolver(buildTestSpec())
tests := []struct {
name string
desc string
wantSIID int
wantPIID int
wantOK bool
}{
{"by translated desc", "开关", 2, 1, true},
{"by raw desc", "Power", 2, 1, true},
{"nonexistent desc", "亮度", 0, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
siid, piid, ok := r.FindByDescription(tt.desc)
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if siid != tt.wantSIID {
t.Errorf("siid = %d, want %d", siid, tt.wantSIID)
}
if piid != tt.wantPIID {
t.Errorf("piid = %d, want %d", piid, tt.wantPIID)
}
})
}
}
func TestFindAction(t *testing.T) {
r := NewPropertyResolver(buildTestSpec())
t.Run("existing action", func(t *testing.T) {
aiid, ok := r.FindAction(2, "toggle")
if !ok {
t.Error("toggle action should be found")
}
if aiid != 1 {
t.Errorf("aiid = %d, want 1", aiid)
}
})
t.Run("nonexistent action", func(t *testing.T) {
_, ok := r.FindAction(2, "nonexistent")
if ok {
t.Error("nonexistent action should not be found")
}
})
t.Run("nonexistent service", func(t *testing.T) {
_, ok := r.FindAction(99, "toggle")
if ok {
t.Error("action in nonexistent service should not be found")
}
})
}
func TestGetAllProperties(t *testing.T) {
r := NewPropertyResolver(buildTestSpec())
t.Run("siid 2 has 6 properties", func(t *testing.T) {
props := r.GetAllProperties(2)
if len(props) != 6 {
t.Errorf("len = %d, want 6", len(props))
}
})
t.Run("siid 1 has 1 property", func(t *testing.T) {
props := r.GetAllProperties(1)
if len(props) != 1 {
t.Errorf("len = %d, want 1", len(props))
}
})
t.Run("nonexistent siid", func(t *testing.T) {
props := r.GetAllProperties(99)
if len(props) != 0 {
t.Errorf("len = %d, want 0", len(props))
}
})
}
func TestGetProperty(t *testing.T) {
r := NewPropertyResolver(buildTestSpec())
t.Run("existing property", func(t *testing.T) {
prop := r.GetProperty(2, 3)
if prop == nil {
t.Fatal("property should be found")
}
if prop.Type != "target-temperature" {
t.Errorf("Type = %q", prop.Type)
}
})
t.Run("nonexistent property", func(t *testing.T) {
prop := r.GetProperty(2, 99)
if prop != nil {
t.Error("property should be nil")
}
})
}
func TestSpec(t *testing.T) {
spec := buildTestSpec()
r := NewPropertyResolver(spec)
if r.Spec() != spec {
t.Error("Spec() should return the original spec")
}
}