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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user