diff --git a/README.md b/README.md index 3bdfc80..81357bc 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ _, err = device.RunAction(ctx, "toggle", nil) _ = value ``` +执行 action 前可通过 `device.Actions()["toggle"].Inputs` 检查可信 MIoT 描述中的参数类型、范围和值列表。 + 可通过实际导出的 `DeviceOption` 调整行为: ```go diff --git a/device.go b/device.go index 899a898..7711b03 100644 --- a/device.go +++ b/device.go @@ -50,10 +50,11 @@ type ValueListItem struct { } type ActionSpec struct { - Name string `json:"name"` - Description string `json:"description"` - SIID int `json:"siid"` - AIID int `json:"aiid"` + Name string `json:"name"` + Description string `json:"description"` + SIID int `json:"siid"` + AIID int `json:"aiid"` + Inputs []PropertySpec `json:"inputs,omitempty"` } type DeviceSelector struct { @@ -230,6 +231,7 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) { IID int `json:"iid"` Type string `json:"type"` Description string `json:"description"` + Inputs []int `json:"in"` } `json:"actions"` } `json:"services"` } `json:"tree"` @@ -245,6 +247,7 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) { 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") { @@ -265,7 +268,9 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) { for index, item := range property.ValueList { valueList[index] = ValueListItem{Value: item.Value, Description: item.Description, DescZhCN: page.Props.I18n.ZhCN[item.I18nKey]} } - info.Properties = append(info.Properties, PropertySpec{Name: name, Description: description, Type: propertyType, RW: accessString(property.Access), Range: property.ValueRange, ValueList: valueList, SIID: service.IID, PIID: property.IID}) + 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 @@ -274,7 +279,15 @@ func parseDeviceInfoHTML(body []byte) (DeviceInfo, error) { } actionNames[name] = struct{}{} description := localizedDescription(action.Description, page.Props.I18n.ZhCN[fmt.Sprintf("service:%03d:action:%03d", service.IID, action.IID)]) - info.Actions = append(info.Actions, ActionSpec{Name: name, Description: description, SIID: service.IID, AIID: 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 @@ -553,6 +566,11 @@ func validateDeviceInfo(info DeviceInfo, model string) error { 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) + } + } } return nil } @@ -620,9 +638,7 @@ func NewDevice(ctx context.Context, client *Client, selector DeviceSelector, opt func (device *Device) Properties() map[string]PropertySpec { properties := make(map[string]PropertySpec, len(device.properties)) for name, property := range device.properties { - property.Range = append([]json.Number(nil), property.Range...) - property.ValueList = append([]ValueListItem(nil), property.ValueList...) - properties[name] = property + properties[name] = clonePropertySpec(property) } return properties } @@ -630,11 +646,22 @@ func (device *Device) Properties() map[string]PropertySpec { 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 { diff --git a/device_test.go b/device_test.go index 4f69216..9961233 100644 --- a/device_test.go +++ b/device_test.go @@ -1,6 +1,7 @@ package mijia import ( + "bytes" "context" "encoding/json" "errors" @@ -123,6 +124,9 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) { if info.Properties[7].Name != "outlet-power" || info.Actions[1].Name != "outlet-toggle" { t.Fatalf("duplicates = %#v / %#v", info.Properties[7], info.Actions[1]) } + if got := info.Actions[0].Inputs; len(got) != 2 || got[0].PIID != 3 || got[1].PIID != 1 || got[0].Description != "Mode / 模式" || got[0].Type != "uint" || len(got[0].ValueList) != 2 { + t.Fatalf("action inputs = %#v", got) + } if testServer.specCalls != 1 || testServer.specPaths[0] != "/spec/test.light.v1" { t.Fatalf("spec requests = %v, want GET /spec/test.light.v1", testServer.specPaths) } @@ -151,6 +155,27 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) { if err != nil || cached.Name != info.Name || testServer.specCalls != 1 { t.Fatalf("cached = %#v, %v, calls=%d", cached, err, testServer.specCalls) } + if len(cached.Actions[0].Inputs) != 2 || cached.Actions[0].Inputs[0].PIID != 3 || cached.Actions[0].Inputs[1].PIID != 1 { + t.Fatalf("cached action inputs = %#v", cached.Actions[0].Inputs) + } +} + +func TestParseDeviceInfoRejectsUnknownActionInput(t *testing.T) { + fixture := bytes.Replace(loadSpecFixture(t), []byte(`"in":[3,1]`), []byte(`"in":[99]`), 1) + if _, err := parseDeviceInfoHTML(fixture); err == nil || !strings.Contains(err.Error(), "unknown property IID 99") { + t.Fatalf("parse error = %v", err) + } +} + +func TestDeviceActionsDeepCopyInputs(t *testing.T) { + device := Device{actions: map[string]ActionSpec{"toggle": {Inputs: []PropertySpec{{Range: []json.Number{"1", "2"}, ValueList: []ValueListItem{{Value: "1"}}}}}}} + actions := device.Actions() + actions["toggle"].Inputs[0].Range[0] = "changed" + actions["toggle"].Inputs[0].ValueList[0].Value = "changed" + got := device.actions["toggle"].Inputs[0] + if got.Range[0] != "1" || got.ValueList[0].Value != "1" { + t.Fatalf("internal action input mutated: %#v", got) + } } func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) { diff --git a/testdata/miot-spec.html b/testdata/miot-spec.html index e0e5a59..7e1ed11 100644 --- a/testdata/miot-spec.html +++ b/testdata/miot-spec.html @@ -2,6 +2,6 @@ "; - +