- 新增 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 示例(风扇/窗帘/传感器控制)
1333 lines
34 KiB
Go
1333 lines
34 KiB
Go
// Package miot provides MIoT core client for Xiaomi Home devices.
|
|
// spec_parser.go — full SPEC parser ported from py-miot/miot_spec.py.
|
|
package miot
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"hash/crc32"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================================
|
|
// MIoTSpecInstance and related types (aligned with Python miot_spec.py)
|
|
// ============================================================================
|
|
|
|
// MIoTSpecInstance represents a parsed SPEC instance (device).
|
|
type MIoTSpecInstance struct {
|
|
URN string
|
|
Type string
|
|
Description string
|
|
DescriptionTrans string
|
|
Expires int64
|
|
Services []*MIoTSpecService
|
|
}
|
|
|
|
// GetServices returns the service list (convenience method for device.go).
|
|
func (i *MIoTSpecInstance) GetServices() []*MIoTSpecService {
|
|
return i.Services
|
|
}
|
|
|
|
// MIoTSpecService represents a service in the SPEC.
|
|
type MIoTSpecService struct {
|
|
IID int
|
|
Type string
|
|
Description string
|
|
DescriptionTrans string
|
|
NeedFilter bool
|
|
Proprietary bool
|
|
Properties []*MIoTSpecProperty
|
|
Actions []*MIoTSpecAction
|
|
Events []*MIoTSpecEvent
|
|
}
|
|
|
|
// MIoTSpecProperty represents a property in the SPEC.
|
|
type MIoTSpecProperty struct {
|
|
SIID int
|
|
PIID int
|
|
Type string
|
|
Description string
|
|
DescriptionTrans string
|
|
Format string
|
|
Access []string
|
|
Unit string
|
|
Expr string // simple expression e.g. "src_value * 2"
|
|
Precision int // decimal precision for floats
|
|
ValueRange *MIoTSpecValueRange
|
|
ValueList MIoTSpecValueList
|
|
NeedFilter bool
|
|
Proprietary bool
|
|
}
|
|
|
|
// MIoTSpecValueRange represents a value range in the SPEC.
|
|
type MIoTSpecValueRange struct {
|
|
Min interface{}
|
|
Max interface{}
|
|
Step interface{}
|
|
}
|
|
|
|
// MIoTSpecValueItem represents a single value list entry in the SPEC.
|
|
type MIoTSpecValueItem struct {
|
|
Value interface{}
|
|
Desc string
|
|
Name string
|
|
}
|
|
|
|
// MIoTSpecValueList represents a collection of value list entries.
|
|
type MIoTSpecValueList []*MIoTSpecValueItem
|
|
|
|
// MIoTSpecAction represents an action in the SPEC.
|
|
type MIoTSpecAction struct {
|
|
SIID int
|
|
AIID int
|
|
Type string
|
|
Description string
|
|
DescriptionTrans string
|
|
In []string
|
|
Out []string
|
|
NeedFilter bool
|
|
Proprietary bool
|
|
}
|
|
|
|
// MIoTSpecEvent represents an event in the SPEC.
|
|
type MIoTSpecEvent struct {
|
|
SIID int
|
|
EIID int
|
|
Type string
|
|
Description string
|
|
DescriptionTrans string
|
|
Arguments []string
|
|
NeedFilter bool
|
|
Proprietary bool
|
|
}
|
|
|
|
// ============================================================================
|
|
// MIoTSpecParser — the main parser
|
|
// ============================================================================
|
|
|
|
// MIoTSpecParser wraps SPEC parsing logic.
|
|
type MIoTSpecParser struct {
|
|
lang string
|
|
storage *MIoTStorage
|
|
specDir string
|
|
stdLib *SpecStdLib
|
|
initDone bool
|
|
initMu sync.Mutex
|
|
}
|
|
|
|
// NewMIoTSpecParser creates a new MIoTSpecParser.
|
|
func NewMIoTSpecParser(lang string, storage *MIoTStorage, specDir string) *MIoTSpecParser {
|
|
if lang == "" {
|
|
lang = DEFAULT_INTEGRATION_LANGUAGE
|
|
}
|
|
return &MIoTSpecParser{
|
|
lang: lang,
|
|
storage: storage,
|
|
specDir: specDir,
|
|
stdLib: NewSpecStdLib(lang),
|
|
}
|
|
}
|
|
|
|
// Init initializes the parser, loading or refreshing the standard library.
|
|
func (p *MIoTSpecParser) Init() error {
|
|
p.initMu.Lock()
|
|
defer p.initMu.Unlock()
|
|
|
|
if p.initDone {
|
|
return nil
|
|
}
|
|
|
|
// Try loading cached std lib
|
|
stdLibCache, _ := p.storage.Load("miot_specs", "spec_std_lib", "dict")
|
|
if cacheMap, ok := stdLibCache.(map[string]interface{}); ok {
|
|
if dataField, ok := cacheMap["data"]; ok {
|
|
if dataMap, ok := dataField.(map[string]interface{}); ok {
|
|
if tsField, ok := cacheMap["ts"]; ok {
|
|
var ts int64
|
|
switch v := tsField.(type) {
|
|
case float64:
|
|
ts = int64(v)
|
|
case json.Number:
|
|
ts, _ = v.Int64()
|
|
}
|
|
if ts > 0 && time.Now().Unix()-ts < SPEC_STD_LIB_EFFECTIVE_TIME {
|
|
log.Printf("[spec] using cached spec std lib, ts=%d", ts)
|
|
p.stdLib.Load(dataMap)
|
|
p.initDone = true
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh from cloud
|
|
if err := p.stdLib.Refresh(); err != nil {
|
|
log.Printf("[spec] refresh std lib failed: %v", err)
|
|
// Try stale cache as fallback
|
|
if cacheMap, ok := stdLibCache.(map[string]interface{}); ok {
|
|
if dataField, ok := cacheMap["data"]; ok {
|
|
if dataMap, ok := dataField.(map[string]interface{}); ok {
|
|
log.Printf("[spec] using stale cached spec std lib")
|
|
p.stdLib.Load(dataMap)
|
|
p.initDone = true
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
return fmt.Errorf("load spec std lib failed")
|
|
}
|
|
|
|
// Save the refreshed std lib
|
|
_ = p.storage.Save("miot_specs", "spec_std_lib", map[string]interface{}{
|
|
"data": p.stdLib.Dump(),
|
|
"ts": time.Now().Unix(),
|
|
})
|
|
|
|
p.initDone = true
|
|
return nil
|
|
}
|
|
|
|
// Parse parses a URN and returns a MIoTSpecInstance.
|
|
// The model parameter is the device model string (e.g., "lumi.plug.maeu01").
|
|
func (p *MIoTSpecParser) Parse(urn string, model string) (*MIoTSpecInstance, error) {
|
|
if !p.initDone {
|
|
if err := p.Init(); err != nil {
|
|
return nil, fmt.Errorf("spec parser init: %w", err)
|
|
}
|
|
}
|
|
|
|
// Read local JSON spec file
|
|
specData, err := p.readSpecFile(urn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read spec file: %w", err)
|
|
}
|
|
|
|
return p.parseSpec(urn, model, specData)
|
|
}
|
|
|
|
// readSpecFile reads and parses a local JSON spec file from specDir/{urn}.json.
|
|
// If the file doesn't exist, it attempts to download it from miot-spec.org first.
|
|
func (p *MIoTSpecParser) readSpecFile(urn string) (map[string]interface{}, error) {
|
|
fileName := urn
|
|
if runtime.GOOS == "windows" {
|
|
fileName = strings.ReplaceAll(urn, ":", "_")
|
|
}
|
|
filePath := filepath.Join(p.specDir, fileName+".json")
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
// Auto-download if file not found
|
|
if os.IsNotExist(err) {
|
|
if dlErr := p.downloadSpecFile(urn, filePath); dlErr != nil {
|
|
return nil, fmt.Errorf("download spec: %w", dlErr)
|
|
}
|
|
data, err = os.ReadFile(filePath)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", filePath, err)
|
|
}
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", filePath, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// downloadSpecFile downloads a single device spec from miot-spec.org and saves it locally.
|
|
func (p *MIoTSpecParser) downloadSpecFile(urn, filePath string) error {
|
|
url := fmt.Sprintf("https://miot-spec.org/miot-spec-v2/instance?type=%s", urn)
|
|
|
|
httpClient := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := httpClient.Get(url)
|
|
if err != nil {
|
|
return fmt.Errorf("GET %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("GET %s: status %d", url, resp.StatusCode)
|
|
}
|
|
|
|
// Ensure specDir exists
|
|
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
|
return fmt.Errorf("mkdir: %w", err)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("read body: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
|
return fmt.Errorf("write %s: %w", filePath, err)
|
|
}
|
|
|
|
log.Printf("[spec] downloaded spec: %s -> %s", urn, filePath)
|
|
return nil
|
|
}
|
|
|
|
// parseSpec parses a raw spec dict into a MIoTSpecInstance tree.
|
|
func (p *MIoTSpecParser) parseSpec(urn string, model string, specData map[string]interface{}) (*MIoTSpecInstance, error) {
|
|
// Get instance description from std lib
|
|
urnParts := strings.Split(urn, ":")
|
|
urnKey := strings.Join(urnParts[:min(6, len(urnParts))], ":")
|
|
deviceTypeKey := strings.Join(urnParts[:min(5, len(urnParts))], ":")
|
|
|
|
description := ""
|
|
if desc, ok := specData["description"].(string); ok {
|
|
description = desc
|
|
}
|
|
descriptionTrans := p.stdLib.DeviceTranslate(deviceTypeKey)
|
|
if descriptionTrans == "" {
|
|
descriptionTrans = description
|
|
}
|
|
if descriptionTrans == "" && len(urnParts) > 3 {
|
|
descriptionTrans = urnParts[3]
|
|
}
|
|
|
|
instance := &MIoTSpecInstance{
|
|
URN: urn,
|
|
Type: urnKey,
|
|
Description: description,
|
|
DescriptionTrans: descriptionTrans,
|
|
}
|
|
|
|
servicesRaw, _ := specData["services"].([]interface{})
|
|
if servicesRaw == nil {
|
|
return instance, nil
|
|
}
|
|
|
|
for _, svcRaw := range servicesRaw {
|
|
service, ok := svcRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if p.skipProprietary(service) {
|
|
continue
|
|
}
|
|
|
|
specSvc := p.parseService(service)
|
|
if specSvc == nil {
|
|
continue
|
|
}
|
|
instance.Services = append(instance.Services, specSvc)
|
|
}
|
|
|
|
return instance, nil
|
|
}
|
|
|
|
// parseService parses a single service from the spec.
|
|
func (p *MIoTSpecParser) parseService(service map[string]interface{}) *MIoTSpecService {
|
|
iid := intVal(service, "iid")
|
|
typeStr := strVal(service, "type")
|
|
description := strVal(service, "description")
|
|
|
|
if typeStr == "" || description == "" {
|
|
return nil
|
|
}
|
|
|
|
// Skip device-information service
|
|
typeParts := strings.Split(typeStr, ":")
|
|
if len(typeParts) > 3 && typeParts[3] == "device-information" {
|
|
return nil
|
|
}
|
|
|
|
svc := &MIoTSpecService{
|
|
IID: iid,
|
|
Type: typeStr,
|
|
Description: description,
|
|
Proprietary: len(typeParts) > 1 && typeParts[1] != "miot-spec-v2",
|
|
}
|
|
svc.NeedFilter = boolVal(service, "need_filter")
|
|
|
|
// Translate description
|
|
typeKey := strings.Join(typeParts[:min(5, len(typeParts))], ":")
|
|
svc.DescriptionTrans = p.stdLib.ServiceTranslate(typeKey)
|
|
if svc.DescriptionTrans == "" {
|
|
svc.DescriptionTrans = description
|
|
}
|
|
|
|
// Parse properties
|
|
propertiesRaw, _ := service["properties"].([]interface{})
|
|
for _, propRaw := range propertiesRaw {
|
|
prop, ok := propRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if p.skipProprietary(prop) {
|
|
continue
|
|
}
|
|
specProp := p.parseProperty(prop, svc, typeParts)
|
|
if specProp != nil && !specProp.NeedFilter {
|
|
svc.Properties = append(svc.Properties, specProp)
|
|
}
|
|
}
|
|
|
|
// Parse events
|
|
eventsRaw, _ := service["events"].([]interface{})
|
|
for _, eventRaw := range eventsRaw {
|
|
event, ok := eventRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if p.skipProprietary(event) {
|
|
continue
|
|
}
|
|
specEvent := p.parseEvent(event, svc)
|
|
if specEvent != nil && !specEvent.NeedFilter {
|
|
svc.Events = append(svc.Events, specEvent)
|
|
}
|
|
}
|
|
|
|
// Parse actions
|
|
actionsRaw, _ := service["actions"].([]interface{})
|
|
for _, actionRaw := range actionsRaw {
|
|
action, ok := actionRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if p.skipProprietary(action) {
|
|
continue
|
|
}
|
|
specAction := p.parseAction(action, svc)
|
|
if specAction != nil && !specAction.NeedFilter {
|
|
svc.Actions = append(svc.Actions, specAction)
|
|
}
|
|
}
|
|
|
|
return svc
|
|
}
|
|
|
|
// parseProperty parses a property from the spec.
|
|
func (p *MIoTSpecParser) parseProperty(prop map[string]interface{}, svc *MIoTSpecService, svcTypeParts []string) *MIoTSpecProperty {
|
|
piid := intVal(prop, "iid")
|
|
propType := strVal(prop, "type")
|
|
description := strVal(prop, "description")
|
|
formatStr := strVal(prop, "format")
|
|
accessRaw := prop["access"]
|
|
|
|
if propType == "" || description == "" || formatStr == "" || accessRaw == nil {
|
|
return nil
|
|
}
|
|
|
|
propTypeParts := strings.Split(propType, ":")
|
|
|
|
// Parse access
|
|
access := parseAccess(accessRaw)
|
|
|
|
// Parse unit
|
|
unit := strVal(prop, "unit")
|
|
if unit == "none" {
|
|
unit = ""
|
|
}
|
|
|
|
specProp := &MIoTSpecProperty{
|
|
SIID: svc.IID,
|
|
PIID: piid,
|
|
Type: propType,
|
|
Description: description,
|
|
Format: formatStr,
|
|
Access: access,
|
|
Unit: unit,
|
|
NeedFilter: boolVal(prop, "need_filter"),
|
|
Proprietary: len(propTypeParts) > 1 && propTypeParts[1] != "miot-spec-v2",
|
|
}
|
|
|
|
// Translate description
|
|
typeKey := strings.Join(propTypeParts[:min(5, len(propTypeParts))], ":")
|
|
specProp.DescriptionTrans = p.stdLib.PropertyTranslate(typeKey)
|
|
if specProp.DescriptionTrans == "" {
|
|
specProp.DescriptionTrans = description
|
|
}
|
|
|
|
// Parse value-range
|
|
if vrRaw, ok := prop["value-range"]; ok {
|
|
specProp.ValueRange = parseValueRange(vrRaw)
|
|
}
|
|
|
|
// Parse value-list
|
|
if vlRaw, ok := prop["value-list"]; ok {
|
|
specProp.ValueList = parseValueList(vlRaw, svcTypeParts, propTypeParts)
|
|
}
|
|
|
|
return specProp
|
|
}
|
|
|
|
// parseEvent parses an event from the spec.
|
|
func (p *MIoTSpecParser) parseEvent(event map[string]interface{}, svc *MIoTSpecService) *MIoTSpecEvent {
|
|
eiid := intVal(event, "iid")
|
|
eventType := strVal(event, "type")
|
|
description := strVal(event, "description")
|
|
|
|
if eventType == "" || description == "" {
|
|
return nil
|
|
}
|
|
|
|
eventTypeParts := strings.Split(eventType, ":")
|
|
|
|
specEvent := &MIoTSpecEvent{
|
|
SIID: svc.IID,
|
|
EIID: eiid,
|
|
Type: eventType,
|
|
Description: description,
|
|
NeedFilter: boolVal(event, "need_filter"),
|
|
Proprietary: len(eventTypeParts) > 1 && eventTypeParts[1] != "miot-spec-v2",
|
|
}
|
|
|
|
// Translate description
|
|
typeKey := strings.Join(eventTypeParts[:min(5, len(eventTypeParts))], ":")
|
|
specEvent.DescriptionTrans = p.stdLib.EventTranslate(typeKey)
|
|
if specEvent.DescriptionTrans == "" {
|
|
specEvent.DescriptionTrans = description
|
|
}
|
|
|
|
// Parse arguments
|
|
if args, ok := event["arguments"].([]interface{}); ok {
|
|
for _, arg := range args {
|
|
specEvent.Arguments = append(specEvent.Arguments, fmt.Sprintf("%v", arg))
|
|
}
|
|
}
|
|
|
|
return specEvent
|
|
}
|
|
|
|
// parseAction parses an action from the spec.
|
|
func (p *MIoTSpecParser) parseAction(action map[string]interface{}, svc *MIoTSpecService) *MIoTSpecAction {
|
|
aiid := intVal(action, "iid")
|
|
actionType := strVal(action, "type")
|
|
description := strVal(action, "description")
|
|
|
|
if actionType == "" || description == "" {
|
|
return nil
|
|
}
|
|
|
|
actionTypeParts := strings.Split(actionType, ":")
|
|
|
|
specAction := &MIoTSpecAction{
|
|
SIID: svc.IID,
|
|
AIID: aiid,
|
|
Type: actionType,
|
|
Description: description,
|
|
NeedFilter: boolVal(action, "need_filter"),
|
|
Proprietary: len(actionTypeParts) > 1 && actionTypeParts[1] != "miot-spec-v2",
|
|
}
|
|
|
|
// Translate description
|
|
typeKey := strings.Join(actionTypeParts[:min(5, len(actionTypeParts))], ":")
|
|
specAction.DescriptionTrans = p.stdLib.ActionTranslate(typeKey)
|
|
if specAction.DescriptionTrans == "" {
|
|
specAction.DescriptionTrans = description
|
|
}
|
|
|
|
// Parse in/out
|
|
if inRaw, ok := action["in"].([]interface{}); ok {
|
|
for _, in := range inRaw {
|
|
specAction.In = append(specAction.In, fmt.Sprintf("%v", in))
|
|
}
|
|
}
|
|
if outRaw, ok := action["out"].([]interface{}); ok {
|
|
for _, out := range outRaw {
|
|
specAction.Out = append(specAction.Out, fmt.Sprintf("%v", out))
|
|
}
|
|
}
|
|
|
|
return specAction
|
|
}
|
|
|
|
// skipProprietary returns true if the item is marked proprietary.
|
|
func (p *MIoTSpecParser) skipProprietary(item map[string]interface{}) bool {
|
|
if proprietary, ok := item["proprietary"]; ok {
|
|
switch v := proprietary.(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return v == "true" || v == "1"
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ============================================================================
|
|
// SpecStdLib — standard library for multi-language translation
|
|
// ============================================================================
|
|
|
|
// SpecStdLib is the MIoT-Spec-V2 standard library for multi-language descriptions.
|
|
type SpecStdLib struct {
|
|
lang string
|
|
devices map[string]map[string]string
|
|
services map[string]map[string]string
|
|
properties map[string]map[string]string
|
|
events map[string]map[string]string
|
|
actions map[string]map[string]string
|
|
values map[string]map[string]string
|
|
}
|
|
|
|
// NewSpecStdLib creates a new SpecStdLib.
|
|
func NewSpecStdLib(lang string) *SpecStdLib {
|
|
if lang == "" {
|
|
lang = DEFAULT_INTEGRATION_LANGUAGE
|
|
}
|
|
return &SpecStdLib{
|
|
lang: lang,
|
|
devices: make(map[string]map[string]string),
|
|
services: make(map[string]map[string]string),
|
|
properties: make(map[string]map[string]string),
|
|
events: make(map[string]map[string]string),
|
|
actions: make(map[string]map[string]string),
|
|
values: make(map[string]map[string]string),
|
|
}
|
|
}
|
|
|
|
// Load loads standard library data from a parsed map.
|
|
func (s *SpecStdLib) Load(data map[string]interface{}) {
|
|
if data == nil {
|
|
return
|
|
}
|
|
if d, ok := data["devices"].(map[string]interface{}); ok {
|
|
s.devices = convertStringMapMap(d)
|
|
}
|
|
if sv, ok := data["services"].(map[string]interface{}); ok {
|
|
s.services = convertStringMapMap(sv)
|
|
}
|
|
if p, ok := data["properties"].(map[string]interface{}); ok {
|
|
s.properties = convertStringMapMap(p)
|
|
}
|
|
if e, ok := data["events"].(map[string]interface{}); ok {
|
|
s.events = convertStringMapMap(e)
|
|
}
|
|
if a, ok := data["actions"].(map[string]interface{}); ok {
|
|
s.actions = convertStringMapMap(a)
|
|
}
|
|
if v, ok := data["values"].(map[string]interface{}); ok {
|
|
s.values = convertStringMapMap(v)
|
|
}
|
|
}
|
|
|
|
// Dump exports the standard library as a map.
|
|
func (s *SpecStdLib) Dump() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"devices": s.devices,
|
|
"services": s.services,
|
|
"properties": s.properties,
|
|
"events": s.events,
|
|
"actions": s.actions,
|
|
"values": s.values,
|
|
}
|
|
}
|
|
|
|
// DeviceTranslate translates a device type key.
|
|
func (s *SpecStdLib) DeviceTranslate(key string) string {
|
|
return s.translate(s.devices, key)
|
|
}
|
|
|
|
// ServiceTranslate translates a service type key.
|
|
func (s *SpecStdLib) ServiceTranslate(key string) string {
|
|
return s.translate(s.services, key)
|
|
}
|
|
|
|
// PropertyTranslate translates a property type key.
|
|
func (s *SpecStdLib) PropertyTranslate(key string) string {
|
|
return s.translate(s.properties, key)
|
|
}
|
|
|
|
// EventTranslate translates an event type key.
|
|
func (s *SpecStdLib) EventTranslate(key string) string {
|
|
return s.translate(s.events, key)
|
|
}
|
|
|
|
// ActionTranslate translates an action type key.
|
|
func (s *SpecStdLib) ActionTranslate(key string) string {
|
|
return s.translate(s.actions, key)
|
|
}
|
|
|
|
// ValueTranslate translates a value key.
|
|
func (s *SpecStdLib) ValueTranslate(key string) string {
|
|
return s.translate(s.values, key)
|
|
}
|
|
|
|
// translate is the common translation logic.
|
|
func (s *SpecStdLib) translate(m map[string]map[string]string, key string) string {
|
|
if m == nil || key == "" {
|
|
return ""
|
|
}
|
|
translations, ok := m[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if desc, ok := translations[s.lang]; ok {
|
|
return desc
|
|
}
|
|
return translations[DEFAULT_INTEGRATION_LANGUAGE]
|
|
}
|
|
|
|
// Refresh fetches the latest standard library from cloud endpoints.
|
|
func (s *SpecStdLib) Refresh() error {
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
stdLibs := map[string]map[string]map[string]string{
|
|
"devices": nil,
|
|
"services": nil,
|
|
"properties": nil,
|
|
"events": nil,
|
|
"actions": nil,
|
|
"values": nil,
|
|
}
|
|
|
|
// Retry up to 3 times
|
|
var lastErr error
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
if attempt > 0 {
|
|
log.Printf("[spec] retry std lib refresh, attempt %d", attempt)
|
|
time.Sleep(time.Duration(attempt) * time.Second)
|
|
}
|
|
|
|
var errs []error
|
|
|
|
// Fetch templates from miot-spec.org
|
|
types := []string{"device", "service", "property", "event", "action"}
|
|
for i, name := range types {
|
|
url := fmt.Sprintf("https://miot-spec.org/miot-spec-v2/template/list/%s", name)
|
|
result, err := s.fetchTemplateList(httpClient, url)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: %w", name, err))
|
|
continue
|
|
}
|
|
stdLibs[types[i]] = result
|
|
}
|
|
|
|
// Fetch property values
|
|
valResult, err := s.fetchPropertyValues(httpClient)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("property_value: %w", err))
|
|
} else {
|
|
stdLibs["values"] = valResult
|
|
}
|
|
|
|
if len(errs) == 0 {
|
|
// Merge external std lib from CDN
|
|
externalTypes := []string{"device", "service", "property", "event", "action", "property_value"}
|
|
for i, name := range externalTypes {
|
|
url := fmt.Sprintf("https://cdn.cnbj1.fds.api.mi-img.com/res-conf/xiaomi-home/std_ex_%s.json", name)
|
|
extData, err := s.fetchJSONMapMap(httpClient, url)
|
|
if err != nil {
|
|
log.Printf("[spec] get external std lib failed, %s: %v", name, err)
|
|
continue
|
|
}
|
|
if extData != nil {
|
|
targetKey := types[min(i, len(types)-1)]
|
|
if i == 5 { // property_value
|
|
targetKey = "values"
|
|
}
|
|
if existing := stdLibs[targetKey]; existing != nil {
|
|
for k, v := range extData {
|
|
existing[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply to self
|
|
if d, ok := stdLibs["devices"]; ok && d != nil {
|
|
s.devices = d
|
|
}
|
|
if sv, ok := stdLibs["services"]; ok && sv != nil {
|
|
s.services = sv
|
|
}
|
|
if p, ok := stdLibs["properties"]; ok && p != nil {
|
|
s.properties = p
|
|
}
|
|
if e, ok := stdLibs["events"]; ok && e != nil {
|
|
s.events = e
|
|
}
|
|
if a, ok := stdLibs["actions"]; ok && a != nil {
|
|
s.actions = a
|
|
}
|
|
if v, ok := stdLibs["values"]; ok && v != nil {
|
|
s.values = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
lastErr = fmt.Errorf("std lib refresh errors: %v", errs)
|
|
log.Printf("[spec] std lib refresh error: %v", lastErr)
|
|
}
|
|
}
|
|
|
|
if lastErr != nil {
|
|
return lastErr
|
|
}
|
|
return fmt.Errorf("std lib refresh failed after retries")
|
|
}
|
|
|
|
// fetchTemplateList fetches a template list from miot-spec.org.
|
|
func (s *SpecStdLib) fetchTemplateList(client *http.Client, url string) (map[string]map[string]string, error) {
|
|
data, err := s.fetchJSON(client, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resultList, ok := data["result"].([]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid template list format: %s", url)
|
|
}
|
|
|
|
result := make(map[string]map[string]string)
|
|
for _, itemRaw := range resultList {
|
|
item, ok := itemRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
itemType, ok := item["type"].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
descRaw, ok := item["description"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
descMap, ok := descRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Normalize zh_cn -> zh-Hans, zh_hk/zh_tw -> zh-Hant
|
|
translations := make(map[string]string)
|
|
for k, v := range descMap {
|
|
vs, ok := v.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
key := k
|
|
switch key {
|
|
case "zh_cn":
|
|
key = "zh-Hans"
|
|
case "zh_hk", "zh_tw":
|
|
key = "zh-Hant"
|
|
}
|
|
translations[key] = vs
|
|
}
|
|
result[itemType] = translations
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// fetchPropertyValues fetches property value normalizations from miot-spec.org.
|
|
func (s *SpecStdLib) fetchPropertyValues(client *http.Client) (map[string]map[string]string, error) {
|
|
url := "https://miot-spec.org/miot-spec-v2/normalization/list/property_value"
|
|
data, err := s.fetchJSON(client, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resultList, ok := data["result"].([]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid property value format")
|
|
}
|
|
|
|
result := make(map[string]map[string]string)
|
|
for _, itemRaw := range resultList {
|
|
item, ok := itemRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
urn, _ := item["urn"].(string)
|
|
proName, _ := item["proName"].(string)
|
|
normalization, _ := item["normalization"].(string)
|
|
desc, _ := item["description"].(string)
|
|
|
|
if urn == "" || proName == "" || normalization == "" {
|
|
continue
|
|
}
|
|
key := fmt.Sprintf("%s|%s|%s", urn, proName, normalization)
|
|
result[key] = map[string]string{
|
|
"zh-Hans": desc,
|
|
"en": normalization,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// fetchJSONMapMap fetches a JSON endpoint and returns map[string]map[string]string.
|
|
func (s *SpecStdLib) fetchJSONMapMap(client *http.Client, url string) (map[string]map[string]string, error) {
|
|
data, err := s.fetchJSON(client, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[string]map[string]string)
|
|
for k, v := range data {
|
|
vMap, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
inner := make(map[string]string)
|
|
for ik, iv := range vMap {
|
|
if ivs, ok := iv.(string); ok {
|
|
inner[ik] = ivs
|
|
}
|
|
}
|
|
result[k] = inner
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// fetchJSON is a helper to GET a URL and parse JSON.
|
|
func (s *SpecStdLib) fetchJSON(client *http.Client, url string) (map[string]interface{}, error) {
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GET %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("GET %s: status %d", url, resp.StatusCode)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("parse JSON %s: %w", url, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper functions
|
|
// ============================================================================
|
|
|
|
// convertStringMapMap converts map[string]interface{} to map[string]map[string]string.
|
|
func convertStringMapMap(input map[string]interface{}) map[string]map[string]string {
|
|
result := make(map[string]map[string]string)
|
|
for k, v := range input {
|
|
inner, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
innerMap := make(map[string]string)
|
|
for ik, iv := range inner {
|
|
if ivs, ok := iv.(string); ok {
|
|
innerMap[ik] = ivs
|
|
}
|
|
}
|
|
result[k] = innerMap
|
|
}
|
|
return result
|
|
}
|
|
|
|
// parseAccess parses the access field into a string slice.
|
|
func parseAccess(access interface{}) []string {
|
|
switch v := access.(type) {
|
|
case []interface{}:
|
|
result := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
case string:
|
|
return []string{v}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseValueRange parses a value-range from spec dict or list format.
|
|
func parseValueRange(vr interface{}) *MIoTSpecValueRange {
|
|
switch v := vr.(type) {
|
|
case map[string]interface{}:
|
|
return &MIoTSpecValueRange{
|
|
Min: v["min"],
|
|
Max: v["max"],
|
|
Step: v["step"],
|
|
}
|
|
case []interface{}:
|
|
if len(v) >= 3 {
|
|
return &MIoTSpecValueRange{
|
|
Min: v[0],
|
|
Max: v[1],
|
|
Step: v[2],
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseValueList parses a value-list from spec format.
|
|
func parseValueList(vl interface{}, svcTypeParts, propTypeParts []string) MIoTSpecValueList {
|
|
items, ok := vl.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Deduplicate descriptions
|
|
descCount := make(map[string]int)
|
|
var result MIoTSpecValueList
|
|
|
|
for _, itemRaw := range items {
|
|
item, ok := itemRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
value := item["value"]
|
|
description := strVal(item, "description")
|
|
name := strVal(item, "name")
|
|
|
|
if description == "" {
|
|
description = fmt.Sprintf("v_%v", value)
|
|
}
|
|
|
|
// Handle duplicate descriptions
|
|
count := descCount[description] + 1
|
|
descCount[description] = count
|
|
if count > 1 {
|
|
name = fmt.Sprintf("%s_%d", name, count)
|
|
description = fmt.Sprintf("%s_%d", description, count)
|
|
}
|
|
if name == "" {
|
|
name = description
|
|
}
|
|
|
|
result = append(result, &MIoTSpecValueItem{
|
|
Value: value,
|
|
Desc: description,
|
|
Name: name,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// strVal safely extracts a string value from a map.
|
|
func strVal(m map[string]interface{}, key string) string {
|
|
if v, ok := m[key]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// intVal safely extracts an int value from a map.
|
|
func intVal(m map[string]interface{}, key string) int {
|
|
if v, ok := m[key]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n)
|
|
case int:
|
|
return n
|
|
case json.Number:
|
|
i, _ := n.Int64()
|
|
return int(i)
|
|
case string:
|
|
i, _ := strconv.Atoi(n)
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// boolVal safely extracts a bool value from a map.
|
|
func boolVal(m map[string]interface{}, key string) bool {
|
|
if v, ok := m[key]; ok {
|
|
switch b := v.(type) {
|
|
case bool:
|
|
return b
|
|
case string:
|
|
return b == "true" || b == "1"
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ============================================================================
|
|
// MIoT Spec format mapping functions (aligned with Python miot_spec.py)
|
|
// ============================================================================
|
|
|
|
// SpecFormatGoType maps a spec format string to a Go type indicator.
|
|
// string→"str", bool→"bool", float→"float", default→"int".
|
|
func SpecFormatGoType(format string) string {
|
|
switch format {
|
|
case "string", "str":
|
|
return "str"
|
|
case "bool":
|
|
return "bool"
|
|
case "float":
|
|
return "float"
|
|
default:
|
|
return "int"
|
|
}
|
|
}
|
|
|
|
// SpecPrecision calculates precision from a step value.
|
|
// Aligns with Python: len(format(value[2], '.10f').rstrip('0').rstrip('.').split('.')[1]) if '.' in step_ else 0
|
|
func SpecPrecision(step interface{}) int {
|
|
// Convert step to float64 string with high precision
|
|
stepFloat := toFloat64(step)
|
|
stepStr := formatFloat(stepFloat)
|
|
if !strings.Contains(stepStr, ".") {
|
|
return 0
|
|
}
|
|
parts := strings.Split(stepStr, ".")
|
|
return len(parts[1])
|
|
}
|
|
|
|
// formatFloat formats a float with 10 decimal places and trims trailing zeros.
|
|
func formatFloat(f float64) string {
|
|
if f == 0 {
|
|
return "0"
|
|
}
|
|
s := strconv.FormatFloat(f, 'f', 10, 64)
|
|
s = strings.TrimRight(s, "0")
|
|
s = strings.TrimRight(s, ".")
|
|
return s
|
|
}
|
|
|
|
// ============================================================================
|
|
// SpecHash — simple spec ID hashing
|
|
// ============================================================================
|
|
|
|
// SpecHash generates a hash from type and iid components.
|
|
func SpecHash(typeStr string, iids ...int) uint32 {
|
|
parts := []string{typeStr}
|
|
for _, i := range iids {
|
|
parts = append(parts, strconv.Itoa(i))
|
|
}
|
|
return crc32.ChecksumIEEE([]byte(strings.Join(parts, ".")))
|
|
}
|
|
|
|
// min returns the minimum of two ints.
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ============================================================================
|
|
// MIoTSpecProperty methods — H1: eval_expr / value_format / value_precision
|
|
// H3: writable / readable / notifiable
|
|
// ============================================================================
|
|
|
|
// EvalExpr evaluates a simple expression in the Expr field.
|
|
// If Expr is empty, returns srcValue unchanged.
|
|
// Supports: "src_value * N", "src_value / N", "src_value + N", "src_value - N"
|
|
func (p *MIoTSpecProperty) EvalExpr(srcValue interface{}) interface{} {
|
|
if p.Expr == "" {
|
|
return srcValue
|
|
}
|
|
expr := strings.TrimSpace(p.Expr)
|
|
if expr == "src_value" || expr == "" {
|
|
return srcValue
|
|
}
|
|
|
|
// Parse simple binary expression: src_value <op> <number>
|
|
parts := strings.Fields(expr)
|
|
if len(parts) != 3 || parts[0] != "src_value" {
|
|
return srcValue
|
|
}
|
|
|
|
operand, err := strconv.ParseFloat(parts[2], 64)
|
|
if err != nil {
|
|
return srcValue
|
|
}
|
|
|
|
sv := toFloat64(srcValue)
|
|
|
|
var result float64
|
|
switch parts[1] {
|
|
case "*":
|
|
result = sv * operand
|
|
case "/":
|
|
if operand == 0 {
|
|
return srcValue
|
|
}
|
|
result = sv / operand
|
|
case "+":
|
|
result = sv + operand
|
|
case "-":
|
|
result = sv - operand
|
|
default:
|
|
return srcValue
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ValueFormat converts a value to the target type based on the Format field.
|
|
// string → int/float/bool as specified.
|
|
func (p *MIoTSpecProperty) ValueFormat(value interface{}) interface{} {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
switch p.Format {
|
|
case "int":
|
|
switch v := value.(type) {
|
|
case string:
|
|
i, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return int(toFloat64(v))
|
|
}
|
|
return int(i)
|
|
case float64:
|
|
return int(v)
|
|
case json.Number:
|
|
f, _ := v.Float64()
|
|
return int(f)
|
|
}
|
|
case "float":
|
|
switch v := value.(type) {
|
|
case string:
|
|
f, _ := strconv.ParseFloat(v, 64)
|
|
return f
|
|
case int:
|
|
return float64(v)
|
|
case json.Number:
|
|
f, _ := v.Float64()
|
|
return f
|
|
}
|
|
case "bool":
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return v == "true" || v == "True" || v == "1"
|
|
case float64:
|
|
return v != 0
|
|
case int:
|
|
return v != 0
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
// ValuePrecision applies precision rounding based on Format and Precision fields.
|
|
// float → round to Precision decimal places.
|
|
// int → align to ValueRange.Step.
|
|
func (p *MIoTSpecProperty) ValuePrecision(value interface{}) interface{} {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
switch p.Format {
|
|
case "float":
|
|
f := toFloat64(value)
|
|
return mathRound(f, p.Precision)
|
|
case "int":
|
|
v := toFloat64(value)
|
|
if p.ValueRange != nil && p.ValueRange.Step != nil {
|
|
step := toFloat64(p.ValueRange.Step)
|
|
if step > 0 {
|
|
v = math.Round(v/step) * step
|
|
}
|
|
}
|
|
return int(math.Round(v))
|
|
}
|
|
return value
|
|
}
|
|
|
|
// mathRound rounds a float64 to the specified number of decimal places.
|
|
func mathRound(f float64, places int) float64 {
|
|
if places <= 0 {
|
|
return math.Round(f)
|
|
}
|
|
pow := math.Pow10(places)
|
|
return math.Round(f*pow) / pow
|
|
}
|
|
|
|
// Writable returns true if the Access list contains "write".
|
|
func (p *MIoTSpecProperty) Writable() bool {
|
|
for _, a := range p.Access {
|
|
if a == "write" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Readable returns true if the Access list contains "read".
|
|
func (p *MIoTSpecProperty) Readable() bool {
|
|
for _, a := range p.Access {
|
|
if a == "read" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Notifiable returns true if the Access list contains "notify".
|
|
func (p *MIoTSpecProperty) Notifiable() bool {
|
|
for _, a := range p.Access {
|
|
if a == "notify" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ============================================================================
|
|
// MIoTSpecValueList methods — H2: query helpers
|
|
// ============================================================================
|
|
|
|
// Names returns all item names.
|
|
func (vl MIoTSpecValueList) Names() []string {
|
|
result := make([]string, 0, len(vl))
|
|
for _, item := range vl {
|
|
result = append(result, item.Name)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Values returns all item values.
|
|
func (vl MIoTSpecValueList) Values() []interface{} {
|
|
result := make([]interface{}, 0, len(vl))
|
|
for _, item := range vl {
|
|
result = append(result, item.Value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Descriptions returns all item descriptions.
|
|
func (vl MIoTSpecValueList) Descriptions() []string {
|
|
result := make([]string, 0, len(vl))
|
|
for _, item := range vl {
|
|
result = append(result, item.Desc)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetValueByDescription looks up a value by its description.
|
|
// Returns nil if not found.
|
|
func (vl MIoTSpecValueList) GetValueByDescription(desc string) interface{} {
|
|
for _, item := range vl {
|
|
if item.Desc == desc {
|
|
return item.Value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetDescriptionByValue looks up a description by its value.
|
|
// Returns "" if not found.
|
|
func (vl MIoTSpecValueList) GetDescriptionByValue(value interface{}) string {
|
|
for _, item := range vl {
|
|
if fmt.Sprintf("%v", item.Value) == fmt.Sprintf("%v", value) {
|
|
return item.Desc
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Ensure unused import of math for future precision handling.
|
|
var _ = math.Abs
|