package mijia import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "math" "math/big" "net/http" "os" "path/filepath" "reflect" "strconv" "strings" "time" ) const ( deviceSpecUA = "mijiaAPI/4.1.2" deviceSpecMaxSize = 8 << 20 deviceCacheVersion = 2 deviceGetBatchSize = 20 ) var deviceSpecURL = "https://home.miot-spec.com/spec/" type DeviceInfo struct { Name string `json:"name"` Model string `json:"model"` Properties []PropertySpec `json:"properties"` Actions []ActionSpec `json:"actions"` } type PropertySpec struct { Name string `json:"name"` Description string `json:"description"` Type string `json:"type"` RW string `json:"rw"` Range []json.Number `json:"range,omitempty"` ValueList []ValueListItem `json:"value-list,omitempty"` SIID int `json:"siid"` PIID int `json:"piid"` } type ValueListItem struct { Value json.Number `json:"value"` Description string `json:"description"` DescZhCN string `json:"desc_zh_cn,omitempty"` } type ActionSpec struct { Name string `json:"name"` Description string `json:"description"` SIID int `json:"siid"` AIID int `json:"aiid"` Inputs []PropertySpec `json:"inputs,omitempty"` } type DeviceSelector struct { DID string Name string } type DeviceOption func(*deviceConfig) error type deviceConfig struct { httpClient *http.Client cacheDir string delay time.Duration } func WithDeviceHTTPClient(httpClient *http.Client) DeviceOption { return func(config *deviceConfig) error { if httpClient == nil { return errors.New("device HTTP client must not be nil") } config.httpClient = httpClient return nil } } func WithDeviceCacheDir(cacheDir string) DeviceOption { return func(config *deviceConfig) error { config.cacheDir = cacheDir return nil } } func WithDeviceDelay(delay time.Duration) DeviceOption { return func(config *deviceConfig) error { if delay < 0 { return errors.New("device delay must not be negative") } config.delay = delay return nil } } func GetDeviceInfo(ctx context.Context, httpClient *http.Client, model string, cacheDir string) (DeviceInfo, error) { if !validDeviceModel(model) || httpClient == nil { return DeviceInfo{}, &GetDeviceInfoError{DeviceModel: model} } cachePath := "" var cacheErr error if cacheDir != "" { cachePath = filepath.Join(cacheDir, model+".json") if cache, err := os.Open(cachePath); err == nil { data, readErr := readLimited(cache, deviceSpecMaxSize, "device info cache") closeErr := cache.Close() if readErr != nil { cacheErr = fmt.Errorf("read: %w", readErr) } else if closeErr != nil { cacheErr = fmt.Errorf("close: %w", closeErr) } else { info, decodeErr := decodeDeviceInfo(data, model) if decodeErr == nil { return info, nil } cacheErr = decodeErr } } else if !os.IsNotExist(err) { cacheErr = fmt.Errorf("open: %w", err) } } info, err := fetchDeviceInfo(ctx, httpClient, model) if err != nil { if cacheErr != nil { return DeviceInfo{}, fmt.Errorf("invalid device info cache %s: %v; refresh failed: %w", cachePath, cacheErr, err) } return DeviceInfo{}, err } if cachePath != "" { if err := writeDeviceInfoCache(cachePath, info); err != nil { return DeviceInfo{}, err } } return info, nil } func fetchDeviceInfo(ctx context.Context, httpClient *http.Client, model string) (DeviceInfo, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, deviceSpecURL+model, nil) if err != nil { return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) } request.Header.Set("User-Agent", deviceSpecUA) response, err := httpClient.Do(request) if err != nil { return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { return DeviceInfo{}, fmt.Errorf("%w: HTTP status %d", &GetDeviceInfoError{DeviceModel: model}, response.StatusCode) } body, err := readLimited(response.Body, deviceSpecMaxSize, "device spec HTML") if err != nil { return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) } info, err := parseDeviceInfoHTML(body) if err != nil { return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) } if err := validateDeviceInfo(&info, model); err != nil { return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) } return info, nil } func validDeviceModel(model string) bool { if model == "" { return false } for _, character := range []byte(model) { if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '.' && character != '-' && character != '_' { return false } } return true } func readLimited(reader io.Reader, maximum int64, description string) ([]byte, error) { data, err := io.ReadAll(io.LimitReader(reader, maximum+1)) if err != nil { return nil, err } if int64(len(data)) > maximum { return nil, fmt.Errorf("%s exceeds %d bytes", description, maximum) } return data, nil } func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) { start, err := deviceSpecJSONStart(body) if err != nil { return DeviceInfo{}, err } endOffset := indexFoldASCII(body[start:], []byte("")) if endOffset < 0 { return DeviceInfo{}, errors.New("device spec JSON script is not closed") } var page struct { Props struct { Product struct { Name string `json:"name"` Model string `json:"model"` } `json:"product"` I18n struct { ZhCN map[string]string `json:"zh_cn"` } `json:"i18n"` Tree struct { Services []struct { IID int `json:"iid"` Type string `json:"type"` Properties []struct { IID int `json:"iid"` Type string `json:"type"` Description string `json:"description"` Format string `json:"format"` Access []string `json:"access"` ValueRange []json.Number `json:"valueRange"` ValueList []struct { Value json.Number `json:"value"` Description string `json:"description"` I18nKey string `json:"i18nKey"` } `json:"valueList"` } `json:"properties"` Actions []struct { IID int `json:"iid"` Type string `json:"type"` Description string `json:"description"` Inputs []int `json:"in"` } `json:"actions"` } `json:"services"` } `json:"tree"` } `json:"props"` } decoder := json.NewDecoder(bytes.NewReader(body[start : start+endOffset])) decoder.UseNumber() if err := decoder.Decode(&page); err != nil { return DeviceInfo{}, err } info := DeviceInfo{Name: page.Props.Product.Name, Model: page.Props.Product.Model} propertyNames := make(map[string]struct{}) actionNames := make(map[string]struct{}) for _, service := range page.Props.Tree.Services { serviceProperties := make(map[int]PropertySpec, len(service.Properties)) for _, property := range service.Properties { propertyType := property.Format if strings.HasPrefix(propertyType, "int") { propertyType = "int" } else if strings.HasPrefix(propertyType, "uint") { propertyType = "uint" } if !validPropertyType(propertyType) { return DeviceInfo{}, fmt.Errorf("unsupported property type %q", propertyType) } name := property.Type if _, duplicate := propertyNames[name]; duplicate { name = service.Type + "-" + name } propertyNames[name] = struct{}{} description := localizedDescription(property.Description, page.Props.I18n.ZhCN[fmt.Sprintf("service:%03d:property:%03d", service.IID, property.IID)]) valueList := make([]ValueListItem, len(property.ValueList)) for index, item := range property.ValueList { valueList[index] = ValueListItem{Value: item.Value, Description: item.Description, DescZhCN: page.Props.I18n.ZhCN[item.I18nKey]} } propertySpec := PropertySpec{Name: name, Description: description, Type: propertyType, RW: accessString(property.Access), Range: property.ValueRange, ValueList: valueList, SIID: service.IID, PIID: property.IID} serviceProperties[property.IID] = propertySpec info.Properties = append(info.Properties, propertySpec) } for _, action := range service.Actions { name := action.Type if _, duplicate := actionNames[name]; duplicate { name = service.Type + "-" + name } actionNames[name] = struct{}{} description := localizedDescription(action.Description, page.Props.I18n.ZhCN[fmt.Sprintf("service:%03d:action:%03d", service.IID, action.IID)]) inputs := make([]PropertySpec, len(action.Inputs)) for index, propertyIID := range action.Inputs { propertySpec, ok := serviceProperties[propertyIID] if !ok { return DeviceInfo{}, fmt.Errorf("action %q references unknown property IID %d", name, propertyIID) } inputs[index] = clonePropertySpec(propertySpec) } info.Actions = append(info.Actions, ActionSpec{Name: name, Description: description, SIID: service.IID, AIID: action.IID, Inputs: inputs}) } } return info, nil } func deviceSpecJSONStart(body []byte) (int, error) { for offset := 0; offset < len(body); { relative := indexFoldASCII(body[offset:], []byte("= len(body) { return 0, nil, false } if body[offset] == '>' { return offset, attributes, true } nameStart := offset for offset < len(body) && !isHTMLSpace(body[offset]) && body[offset] != '=' && body[offset] != '>' { offset++ } if nameStart == offset { return 0, nil, false } name := strings.ToLower(string(body[nameStart:offset])) for offset < len(body) && isHTMLSpace(body[offset]) { offset++ } value := "" if offset < len(body) && body[offset] == '=' { offset++ for offset < len(body) && isHTMLSpace(body[offset]) { offset++ } if offset >= len(body) { return 0, nil, false } if body[offset] == '\'' || body[offset] == '"' { quote := body[offset] offset++ valueStart := offset for offset < len(body) && body[offset] != quote { offset++ } if offset >= len(body) { return 0, nil, false } value = string(body[valueStart:offset]) offset++ } else { valueStart := offset for offset < len(body) && !isHTMLSpace(body[offset]) && body[offset] != '>' { switch body[offset] { case '"', '\'', '`', '=', '<': return 0, nil, false } offset++ } if valueStart == offset { return 0, nil, false } value = string(body[valueStart:offset]) } } attributes[name] = value } return 0, nil, false } func isHTMLSpace(character byte) bool { return character == ' ' || character == '\t' || character == '\n' || character == '\r' || character == '\f' } func indexFoldASCII(data, target []byte) int { for index := 0; index+len(target) <= len(data); index++ { matched := true for targetIndex, targetCharacter := range target { dataCharacter := data[index+targetIndex] if dataCharacter >= 'A' && dataCharacter <= 'Z' { dataCharacter += 'a' - 'A' } if dataCharacter != targetCharacter { matched = false break } } if matched { return index } } return -1 } func validPropertyType(propertyType string) bool { switch propertyType { case "bool", "int", "uint", "float", "string": return true default: return false } } func localizedDescription(description, translation string) string { if translation == "" { return description } if description == "" { return translation } return description + " / " + translation } func accessString(access []string) string { readable, writable := false, false for _, item := range access { readable = readable || item == "read" writable = writable || item == "write" } result := "" if readable { result += "r" } if writable { result += "w" } return result } func writeDeviceInfoCache(path string, info DeviceInfo) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("create device info cache directory: %w", err) } cache := deviceInfoCache{Version: deviceCacheVersion, Name: info.Name, Model: info.Model} for _, property := range info.Properties { cache.Properties = append(cache.Properties, propertyCache{ PropertySpec: property, Method: cacheMethod{SIID: property.SIID, PIID: property.PIID}, }) } for _, action := range info.Actions { cache.Actions = append(cache.Actions, actionCache{ ActionSpec: action, Method: cacheMethod{SIID: action.SIID, AIID: action.AIID}, }) } data, err := json.MarshalIndent(cache, "", " ") if err != nil { return fmt.Errorf("encode device info cache: %w", err) } temporary, err := os.CreateTemp(filepath.Dir(path), ".miot-spec-*") if err != nil { return fmt.Errorf("create temporary device info cache: %w", err) } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(0o600); err != nil { temporary.Close() return err } if _, err := temporary.Write(data); err != nil { temporary.Close() return err } if err := temporary.Sync(); err != nil { temporary.Close() return err } if err := temporary.Close(); err != nil { return err } if err := os.Rename(temporaryPath, path); err != nil { return fmt.Errorf("replace device info cache: %w", err) } return nil } type cacheMethod struct { SIID int `json:"siid"` PIID int `json:"piid,omitempty"` AIID int `json:"aiid,omitempty"` } type propertyCache struct { PropertySpec Method cacheMethod `json:"method"` rwPresent bool } func (cache *propertyCache) UnmarshalJSON(data []byte) error { type propertyCacheAlias propertyCache decoded := struct { *propertyCacheAlias RW *string `json:"rw"` }{propertyCacheAlias: (*propertyCacheAlias)(cache)} if err := json.Unmarshal(data, &decoded); err != nil { return err } cache.rwPresent = decoded.RW != nil if decoded.RW != nil { cache.RW = *decoded.RW } return nil } type actionCache struct { ActionSpec Method cacheMethod `json:"method"` } type deviceInfoCache struct { Version int `json:"version"` Name string `json:"name"` Model string `json:"model"` Properties []propertyCache `json:"properties"` Actions []actionCache `json:"actions"` } func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() var cache deviceInfoCache if err := decoder.Decode(&cache); err != nil { return DeviceInfo{}, err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return DeviceInfo{}, errors.New("multiple JSON values") } return DeviceInfo{}, fmt.Errorf("trailing content: %w", err) } if cache.Version < deviceCacheVersion { return DeviceInfo{}, fmt.Errorf("device info cache stale: version %d, want %d", cache.Version, deviceCacheVersion) } if cache.Version != deviceCacheVersion { return DeviceInfo{}, fmt.Errorf("unsupported device info cache version %d", cache.Version) } info := DeviceInfo{Name: cache.Name, Model: cache.Model} for _, cachedProperty := range cache.Properties { if !cachedProperty.rwPresent { return DeviceInfo{}, fmt.Errorf("property %q is missing access metadata", cachedProperty.Name) } property := cachedProperty.PropertySpec if property.SIID == 0 { property.SIID = cachedProperty.Method.SIID } if property.PIID == 0 { property.PIID = cachedProperty.Method.PIID } info.Properties = append(info.Properties, property) } for _, cachedAction := range cache.Actions { action := cachedAction.ActionSpec if action.SIID == 0 { action.SIID = cachedAction.Method.SIID } if action.AIID == 0 { action.AIID = cachedAction.Method.AIID } info.Actions = append(info.Actions, action) } if err := validateDeviceInfo(&info, model); err != nil { return DeviceInfo{}, err } return info, nil } func validateDeviceInfo(info *DeviceInfo, model string) error { if info.Model == "" || info.Model != model { return fmt.Errorf("model %q does not match requested model %q", info.Model, model) } type propertyID struct { siid int piid int } properties := make(map[propertyID]PropertySpec, len(info.Properties)) for index, property := range info.Properties { if strings.TrimSpace(property.Name) == "" || !validPropertyType(property.Type) || (property.RW != "" && property.RW != "r" && property.RW != "w" && property.RW != "rw") || property.SIID <= 0 || property.PIID <= 0 { return fmt.Errorf("property %d is invalid", index) } properties[propertyID{siid: property.SIID, piid: property.PIID}] = property } for index, action := range info.Actions { if strings.TrimSpace(action.Name) == "" || action.SIID <= 0 || action.AIID <= 0 { return fmt.Errorf("action %d is invalid", index) } for inputIndex, input := range action.Inputs { if !validPropertyType(input.Type) || input.SIID <= 0 || input.PIID <= 0 || input.SIID != action.SIID { return fmt.Errorf("action %d input %d is invalid", index, inputIndex) } property, exists := properties[propertyID{siid: input.SIID, piid: input.PIID}] if !exists { return fmt.Errorf("action %d input %d does not reference a property", index, inputIndex) } info.Actions[index].Inputs[inputIndex] = property } } return nil } func NewDevice(ctx context.Context, client *Client, selector DeviceSelector, options ...DeviceOption) (*Device, error) { if client == nil { return nil, errors.New("client must not be nil") } config := deviceConfig{httpClient: client.session(), cacheDir: client.deviceCacheDir, delay: 500 * time.Millisecond} for _, option := range options { if option != nil { if err := option(&config); err != nil { return nil, err } } } devices, err := client.GetDevices(ctx, "") if err != nil { return nil, err } matches := make([]Device, 0, 1) for _, device := range devices { if selector.DID != "" { if device.DID == selector.DID { matches = append(matches, device) } } else if selector.Name != "" && device.Name == selector.Name { matches = append(matches, device) } } selected := selector.DID if selected == "" { selected = selector.Name } if len(matches) == 0 { return nil, &DeviceNotFoundError{DID: selected} } if len(matches) > 1 { return nil, &MultipleDevicesFoundError{Message: fmt.Sprintf("找到多个标识为 '%s' 的设备", selected)} } info, err := GetDeviceInfo(ctx, config.httpClient, matches[0].Model, config.cacheDir) if err != nil { return nil, err } device := matches[0] if device.Name == "" { device.Name = info.Name } device.client = client device.delay = config.delay device.properties = make(map[string]PropertySpec, len(info.Properties)) for _, property := range info.Properties { device.properties[property.Name] = property if strings.Contains(property.Name, "-") { device.properties[strings.ReplaceAll(property.Name, "-", "_")] = property } } device.actions = make(map[string]ActionSpec, len(info.Actions)) for _, action := range info.Actions { device.actions[action.Name] = action } return &device, nil } func (device *Device) Properties() map[string]PropertySpec { properties := make(map[string]PropertySpec, len(device.properties)) for name, property := range device.properties { properties[name] = clonePropertySpec(property) } return properties } func (device *Device) Actions() map[string]ActionSpec { actions := make(map[string]ActionSpec, len(device.actions)) for name, action := range device.actions { inputs := make([]PropertySpec, len(action.Inputs)) for index, input := range action.Inputs { inputs[index] = clonePropertySpec(input) } action.Inputs = inputs actions[name] = action } return actions } func clonePropertySpec(property PropertySpec) PropertySpec { property.Range = append([]json.Number(nil), property.Range...) property.ValueList = append([]ValueListItem(nil), property.ValueList...) return property } func (device *Device) Get(ctx context.Context, name string) (any, error) { property, ok := device.properties[name] if !ok { return nil, fmt.Errorf("不支持的属性: %s", name) } if !strings.Contains(property.RW, "r") { return nil, fmt.Errorf("属性 %s 不可读取", name) } results, err := device.client.GetProperties(ctx, []PropertyRequest{{DID: device.DID, SIID: property.SIID, PIID: property.PIID}}) if err != nil { return nil, err } if len(results) != 1 { return nil, fmt.Errorf("获取设备 '%s' 的属性 '%s' 返回了 %d 条结果", device.Name, name, len(results)) } if results[0].Code != 0 { return nil, &DeviceGetError{DeviceName: device.Name, Name: name, Code: results[0].Code} } if err := device.wait(ctx); err != nil { return nil, err } return results[0].Value, nil } func (device *Device) GetMany(ctx context.Context, names []string) ([]DevicePropertyResult, error) { if len(names) == 0 { return []DevicePropertyResult{}, nil } type propertyIdentity struct { did string siid int piid int } requests := make([]PropertyRequest, len(names)) seenNames := make(map[string]struct{}, len(names)) seenIdentities := make(map[propertyIdentity]struct{}, len(names)) for index, name := range names { if _, duplicate := seenNames[name]; duplicate { return nil, fmt.Errorf("重复的属性: %s", name) } seenNames[name] = struct{}{} property, ok := device.properties[name] if !ok { return nil, fmt.Errorf("不支持的属性: %s", name) } if !strings.Contains(property.RW, "r") { return nil, fmt.Errorf("属性 %s 不可读取", name) } identity := propertyIdentity{did: device.DID, siid: property.SIID, piid: property.PIID} if _, duplicate := seenIdentities[identity]; duplicate { return nil, fmt.Errorf("属性 %s 与其他请求使用重复的设备属性 identity", name) } seenIdentities[identity] = struct{}{} requests[index] = PropertyRequest{DID: identity.did, SIID: identity.siid, PIID: identity.piid} } results := make([]DevicePropertyResult, len(names)) for start := 0; start < len(requests); start += deviceGetBatchSize { end := min(start+deviceGetBatchSize, len(requests)) chunkResults, err := device.client.GetProperties(ctx, requests[start:end]) if err != nil { return nil, err } byIdentity := make(map[propertyIdentity]PropertyResult, len(chunkResults)) duplicateIdentities := make(map[propertyIdentity]struct{}) requested := make(map[propertyIdentity]struct{}, end-start) for _, request := range requests[start:end] { requested[propertyIdentity{did: request.DID, siid: request.SIID, piid: request.PIID}] = struct{}{} } for _, result := range chunkResults { identity := propertyIdentity{did: result.DID, siid: result.SIID, piid: result.PIID} if _, expected := requested[identity]; !expected { return nil, fmt.Errorf("get properties protocol error: unexpected identity (%s,%d,%d)", result.DID, result.SIID, result.PIID) } if _, duplicate := byIdentity[identity]; duplicate { duplicateIdentities[identity] = struct{}{} continue } byIdentity[identity] = result } for index, request := range requests[start:end] { identity := propertyIdentity{did: request.DID, siid: request.SIID, piid: request.PIID} name := names[start+index] if _, duplicate := duplicateIdentities[identity]; duplicate { results[start+index] = DevicePropertyResult{Name: name, Code: PropertyResultCodeDuplicate} continue } result, ok := byIdentity[identity] if !ok { results[start+index] = DevicePropertyResult{Name: name, Code: PropertyResultCodeMissing} continue } results[start+index] = DevicePropertyResult{Name: name, Value: result.Value, Code: result.Code} } } if err := device.wait(ctx); err != nil { return nil, err } return results, nil } func (device *Device) Set(ctx context.Context, name string, value any) error { property, ok := device.properties[name] if !ok { return fmt.Errorf("不支持的属性: %s", name) } if !strings.Contains(property.RW, "w") { return fmt.Errorf("属性 %s 不可写入", name) } converted, err := convertPropertyValue(property, value) if err != nil { return fmt.Errorf("属性 %s: %w", name, err) } results, err := device.client.SetProperties(ctx, []PropertySetRequest{{DID: device.DID, SIID: property.SIID, PIID: property.PIID, Value: converted}}) if err != nil { return err } if len(results) != 1 { return fmt.Errorf("设置设备 '%s' 的属性 '%s' 返回了 %d 条结果", device.Name, name, len(results)) } if results[0].Code != 0 && results[0].Code != 1 { return &DeviceSetError{DeviceName: device.Name, Name: name, Code: results[0].Code} } return device.wait(ctx) } func (device *Device) RunAction(ctx context.Context, name string, value any) (ActionResult, error) { return device.RunActionWith(ctx, name, value, nil) } // RunActionWith 在 RunAction 基础上支持注入扩展请求字段,对齐 Python // mijiaDevice.run_action 的 **kwargs(典型用法:小爱音箱 execute-text-directive // 需要发送 "in" 字段)。extra 直接使用真实键名(如 "in"),无需 Python 那种 // 前导下划线 workaround(那是绕过 Python 关键字限制的手段)。 // extra 键与保留键 (did, siid, aiid, value) 冲突时返回错误。 func (device *Device) RunActionWith(ctx context.Context, name string, value any, extra map[string]any) (ActionResult, error) { action, ok := device.actions[name] if !ok { return ActionResult{}, fmt.Errorf("不支持的动作: %s", name) } for key := range extra { if actionRequestReservedKey(key) { return ActionResult{}, fmt.Errorf("无效的参数: %s. 请勿使用保留键 (did, siid, aiid, value)", key) } } request := ActionRequest{DID: device.DID, SIID: action.SIID, AIID: action.AIID, Extra: extra} if value != nil { request.Value = value } results, err := device.client.RunActions(ctx, []ActionRequest{request}) if err != nil { return ActionResult{}, err } if len(results) != 1 { return ActionResult{}, fmt.Errorf("执行设备 '%s' 的动作 '%s' 返回了 %d 条结果", device.Name, name, len(results)) } if results[0].Code != 0 && results[0].Code != 1 { return results[0], &DeviceActionError{DeviceName: device.Name, Name: name, Code: results[0].Code} } if err := device.wait(ctx); err != nil { return ActionResult{}, err } return results[0], nil } func (device *Device) wait(ctx context.Context) error { if device.delay == 0 { return ctx.Err() } timer := time.NewTimer(device.delay) defer timer.Stop() select { case <-timer.C: return nil case <-ctx.Done(): return ctx.Err() } } func convertPropertyValue(property PropertySpec, value any) (any, error) { var converted any var numeric *big.Rat var numericValue bool switch property.Type { case "bool": boolean, err := convertBool(value) if err != nil { return nil, err } converted = boolean case "int": integer, err := convertInt64(value) if err != nil { return nil, err } converted, numeric, numericValue = integer, new(big.Rat).SetInt64(integer), true case "uint": integer, err := convertUint64(value) if err != nil { return nil, err } converted, numeric, numericValue = integer, new(big.Rat).SetUint64(integer), true case "float": floating, err := convertFloat64(value) if err != nil { return nil, err } numeric, err = textRat(strconv.FormatFloat(floating, 'g', -1, 64)) if err != nil { return nil, err } converted, numericValue = floating, true case "string": text, ok := value.(string) if !ok { return nil, fmt.Errorf("无效字符串值: %v", value) } converted = text default: return nil, fmt.Errorf("不支持的类型: %s", property.Type) } if numericValue { if err := validateRange(property.Type, numeric, property.Range); err != nil { return nil, err } } if len(property.ValueList) > 0 { matched := false for _, item := range property.ValueList { if numericValue { candidate, err := propertyNumberRat(property.Type, item.Value) matched = err == nil && numeric.Cmp(candidate) == 0 } else { matched = fmt.Sprint(converted) == item.Value.String() } if matched { break } } if !matched { return nil, fmt.Errorf("无效值: %v", converted) } } return converted, nil } func convertBool(value any) (bool, error) { if boolean, ok := value.(bool); ok { return boolean, nil } if text, ok := value.(string); ok { switch strings.ToLower(text) { case "true", "1": return true, nil case "false", "0": return false, nil } } integer, err := convertInt64(value) if err == nil && (integer == 0 || integer == 1) { return integer == 1, nil } return false, fmt.Errorf("无效布尔值: %v", value) } func convertInt64(value any) (int64, error) { if text, ok := integerText(value); ok { integer, err := signedInteger(text) if err != nil { return 0, fmt.Errorf("无效整数值: %v", value) } return integer, nil } reflected := reflect.ValueOf(value) if !reflected.IsValid() { return 0, fmt.Errorf("无效整数值: %v", value) } switch reflected.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return reflected.Int(), nil case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: if reflected.Uint() > math.MaxInt64 { return 0, fmt.Errorf("整数溢出: %v", value) } return int64(reflected.Uint()), nil case reflect.Float32, reflect.Float64: floating := reflected.Float() if !isFinite(floating) || math.Trunc(floating) != floating || math.Abs(floating) > 1<<53 { return 0, fmt.Errorf("无效整数值: %v", value) } return int64(floating), nil default: return 0, fmt.Errorf("无效整数值: %v", value) } } func convertUint64(value any) (uint64, error) { if text, ok := integerText(value); ok { integer, err := unsignedInteger(text) if err != nil { return 0, fmt.Errorf("无效无符号整数值: %v", value) } return integer, nil } reflected := reflect.ValueOf(value) if !reflected.IsValid() { return 0, fmt.Errorf("无效无符号整数值: %v", value) } switch reflected.Kind() { case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return reflected.Uint(), nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: if reflected.Int() < 0 { return 0, fmt.Errorf("无效无符号整数值: %v", value) } return uint64(reflected.Int()), nil case reflect.Float32, reflect.Float64: floating := reflected.Float() if !isFinite(floating) || math.Trunc(floating) != floating || floating < 0 || floating > 1<<53 { return 0, fmt.Errorf("无效无符号整数值: %v", value) } return uint64(floating), nil default: return 0, fmt.Errorf("无效无符号整数值: %v", value) } } func convertFloat64(value any) (float64, error) { var text string switch number := value.(type) { case string: text = number case json.Number: text = number.String() } if text != "" { rational, err := textRat(text) if err != nil { return 0, fmt.Errorf("无效浮点值: %v", value) } if rational.IsInt() && new(big.Int).Abs(rational.Num()).Cmp(big.NewInt(1<<53)) > 0 { return 0, fmt.Errorf("浮点整数超出精确范围: %v", value) } floating, err := strconv.ParseFloat(text, 64) if err != nil || !isFinite(floating) { return 0, fmt.Errorf("无效浮点值: %v", value) } return floating, nil } reflected := reflect.ValueOf(value) if !reflected.IsValid() { return 0, fmt.Errorf("无效浮点值: %v", value) } var floating float64 switch reflected.Kind() { case reflect.Float32, reflect.Float64: floating = reflected.Float() case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: if reflected.Int() < -(1<<53) || reflected.Int() > 1<<53 { return 0, fmt.Errorf("浮点整数超出精确范围: %v", value) } floating = float64(reflected.Int()) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: if reflected.Uint() > 1<<53 { return 0, fmt.Errorf("浮点整数超出精确范围: %v", value) } floating = float64(reflected.Uint()) default: return 0, fmt.Errorf("无效浮点值: %v", value) } if !isFinite(floating) { return 0, fmt.Errorf("无效浮点值: %v", value) } return floating, nil } func validateRange(propertyType string, value *big.Rat, valueRange []json.Number) error { if len(valueRange) == 0 { return nil } if len(valueRange) < 2 { return errors.New("无效的属性范围") } minimum, minErr := propertyNumberRat(propertyType, valueRange[0]) maximum, maxErr := propertyNumberRat(propertyType, valueRange[1]) if minErr != nil || maxErr != nil || value.Cmp(minimum) < 0 || value.Cmp(maximum) > 0 { return fmt.Errorf("%v 超出数值范围 [%s, %s]", value, valueRange[0], valueRange[1]) } if len(valueRange) >= 3 { step, err := propertyNumberRat(propertyType, valueRange[2]) if err != nil || step.Sign() <= 0 { return errors.New("无效的属性步长") } steps := new(big.Rat).Quo(new(big.Rat).Sub(value, minimum), step) if !steps.IsInt() { return fmt.Errorf("无效的值: %v,步长应为 %s", value, valueRange[2]) } } return nil } func integerText(value any) (string, bool) { switch number := value.(type) { case string: return number, true case json.Number: return number.String(), true default: return "", false } } func signedInteger(text string) (int64, error) { rational, err := textRat(text) if err != nil || !rational.IsInt() || !rational.Num().IsInt64() { return 0, errors.New("not an int64") } return rational.Num().Int64(), nil } func unsignedInteger(text string) (uint64, error) { rational, err := textRat(text) if err != nil || !rational.IsInt() || rational.Sign() < 0 || !rational.Num().IsUint64() { return 0, errors.New("not a uint64") } return rational.Num().Uint64(), nil } func numberRat(number json.Number) (*big.Rat, error) { return textRat(number.String()) } func propertyNumberRat(propertyType string, number json.Number) (*big.Rat, error) { switch propertyType { case "int": integer, err := signedInteger(number.String()) if err != nil { return nil, err } return new(big.Rat).SetInt64(integer), nil case "uint": integer, err := unsignedInteger(number.String()) if err != nil { return nil, err } return new(big.Rat).SetUint64(integer), nil default: return numberRat(number) } } func textRat(text string) (*big.Rat, error) { rational, ok := new(big.Rat).SetString(text) if !ok { return nil, errors.New("invalid number") } return rational, nil } func isFinite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }