feat: expose action input specifications

This commit is contained in:
2026-07-21 10:09:05 +08:00
parent 8fea0c3c11
commit 4ffd1b7eac
4 changed files with 64 additions and 10 deletions
+2
View File
@@ -139,6 +139,8 @@ _, err = device.RunAction(ctx, "toggle", nil)
_ = value
```
执行 action 前可通过 `device.Actions()["toggle"].Inputs` 检查可信 MIoT 描述中的参数类型、范围和值列表。
可通过实际导出的 `DeviceOption` 调整行为:
```go
+32 -5
View File
@@ -54,6 +54,7 @@ type ActionSpec struct {
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 {
+25
View File
@@ -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) {
+1 -1
View File
@@ -2,6 +2,6 @@
<html>
<head><script>window.noise = "<script>not JSON</script>";</script></head>
<body>
<script data-page="app" type="application/json">{"props":{"product":{"name":"Test Lamp","model":"test.light.v1"},"i18n":{"zh_cn":{"service:002:property:001":"电源","service:002:property:002":"亮度","service:002:property:003":"模式","service:002:property:004":"温度","service:002:property:005":"序列号","service:002:property:006":"只写","service:002:property:007":"只读","service:002:action:001":"切换","service:003:property:001":"插座电源","service:003:action:001":"插座切换","mode.off":"关闭","mode.on":"开启"}},"tree":{"services":[{"iid":2,"type":"light","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write","notify"]},{"iid":2,"type":"brightness","description":"Brightness","format":"uint8","access":["read","write"],"valueRange":[1,100,1]},{"iid":3,"type":"mode","description":"Mode","format":"uint16","access":["read","write"],"valueList":[{"value":0,"description":"Off","i18nKey":"mode.off"},{"value":1,"description":"On","i18nKey":"mode.on"}]},{"iid":4,"type":"temperature","description":"Temperature","format":"float","access":["read","write"],"valueRange":[0,1,0.1]},{"iid":5,"type":"serial-number","description":"Serial number","format":"string","access":["read","write"]},{"iid":6,"type":"write-only","description":"Write only","format":"int32","access":["write"]},{"iid":7,"type":"read-only","description":"Read only","format":"int64","access":["read"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle"}]},{"iid":3,"type":"outlet","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle"}]}]}}}</script>
<script data-page="app" type="application/json">{"props":{"product":{"name":"Test Lamp","model":"test.light.v1"},"i18n":{"zh_cn":{"service:002:property:001":"电源","service:002:property:002":"亮度","service:002:property:003":"模式","service:002:property:004":"温度","service:002:property:005":"序列号","service:002:property:006":"只写","service:002:property:007":"只读","service:002:action:001":"切换","service:003:property:001":"插座电源","service:003:action:001":"插座切换","mode.off":"关闭","mode.on":"开启"}},"tree":{"services":[{"iid":2,"type":"light","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write","notify"]},{"iid":2,"type":"brightness","description":"Brightness","format":"uint8","access":["read","write"],"valueRange":[1,100,1]},{"iid":3,"type":"mode","description":"Mode","format":"uint16","access":["read","write"],"valueList":[{"value":0,"description":"Off","i18nKey":"mode.off"},{"value":1,"description":"On","i18nKey":"mode.on"}]},{"iid":4,"type":"temperature","description":"Temperature","format":"float","access":["read","write"],"valueRange":[0,1,0.1]},{"iid":5,"type":"serial-number","description":"Serial number","format":"string","access":["read","write"]},{"iid":6,"type":"write-only","description":"Write only","format":"int32","access":["write"]},{"iid":7,"type":"read-only","description":"Read only","format":"int64","access":["read"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle","in":[3,1]}]},{"iid":3,"type":"outlet","properties":[{"iid":1,"type":"power","description":"Power","format":"bool","access":["read","write"]}],"actions":[{"iid":1,"type":"toggle","description":"Toggle","in":[1]}]}]}}}</script>
</body>
</html>