1148 lines
34 KiB
Go
1148 lines
34 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"`
|
|
}
|
|
|
|
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 {
|
|
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 != "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)
|
|
}
|