Files
mijia-go-api/device.go
T

1168 lines
35 KiB
Go

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("</script>"))
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("<script"))
if relative < 0 {
break
}
start := offset + relative
nameEnd := start + len("<script")
if nameEnd < len(body) && !isHTMLSpace(body[nameEnd]) && body[nameEnd] != '>' {
offset = nameEnd
continue
}
tagEnd, attributes, ok := parseStartTag(body, nameEnd)
if !ok {
return 0, errors.New("device spec JSON script start tag is malformed")
}
if attributes["data-page"] == "app" && attributes["type"] == "application/json" {
return tagEnd + 1, nil
}
offset = tagEnd + 1
}
return 0, errors.New("device spec JSON script not found")
}
func parseStartTag(body []byte, offset int) (int, map[string]string, bool) {
attributes := make(map[string]string)
for offset < len(body) {
for offset < len(body) && isHTMLSpace(body[offset]) {
offset++
}
if offset >= 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: filepath.Dir(client.authPath), 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)
}