diff --git a/client.go b/client.go index f3a5329f..c0f82792 100644 --- a/client.go +++ b/client.go @@ -923,6 +923,64 @@ func (c *Client) closeSession(ctx context.Context, s *Session) error { }) } +func (c *Client) RegisterExtensionObjectSuperSmart(ctx context.Context, ids ...*ua.NodeID) error { + // check if the node is a data type or some variable/method argument that has a data type + nodesToRead := []*ua.ReadValueID{} + for _, id := range ids { + nodesToRead = append(nodesToRead, &ua.ReadValueID{NodeID: id, AttributeID: ua.AttributeIDDataType}) + } + resp, err := c.Read(ctx, &ua.ReadRequest{ + NodesToRead: nodesToRead, + }) + if err != nil { + return err + } + + // now find the list of data type nodes + // if the result has a data type attribute then it was a variable + // otherwise already is the data type node + datatypes := []*ua.NodeID{} + for i, r := range resp.Results { + if r.Value == nil { + return fmt.Errorf("failed to read data type attribute for node %s", ids[i].String()) + } + v := r.Value.Value() + if did, ok := v.(*ua.NodeID); ok { + datatypes = append(datatypes, did) + } else { + datatypes = append(datatypes, ids[i]) // here we assume that v == nil + } + } + return c.RegisterExtensionObjectFromServer(ctx, datatypes...) +} + +func (c *Client) RegisterExtensionObjectFromServer(ctx context.Context, dataTypeIDs ...*ua.NodeID) error { + typedefs := []*ua.ReadValueID{} + for _, id := range dataTypeIDs { + typedefs = append(typedefs, &ua.ReadValueID{NodeID: id, AttributeID: ua.AttributeIDDataTypeDefinition}) + } + resp, err := c.Read(ctx, &ua.ReadRequest{ + NodesToRead: typedefs, + }) + if err != nil { + return err + } + + types := map[string]*ua.StructureDefinition{} + for i, r := range resp.Results { + if r.Value == nil { + return fmt.Errorf("failed to find structure definition for node %s", dataTypeIDs[i].String()) + } + v := r.Value.Value() + sd, ok := v.(*ua.StructureDefinition) + if !ok { + return fmt.Errorf("failed to find structure definition for node %s, found %T instead", dataTypeIDs[i].String(), v) + } + types[dataTypeIDs[i].String()] = sd + } + return ua.RegisterExtensionObjectFromStructure(types) +} + // DetachSession removes the session from the client without closing it. The // caller is responsible to close or re-activate the session. If the client // does not have an active session the function returns no error. diff --git a/examples/read/read.go b/examples/read/read.go index e33763ff..cdad4ec0 100644 --- a/examples/read/read.go +++ b/examples/read/read.go @@ -21,6 +21,7 @@ func main() { var ( endpoint = flag.String("endpoint", "opc.tcp://localhost:4840", "OPC UA Endpoint URL") nodeID = flag.String("node", "", "NodeID to read") + attrID = flag.Int("attr", int(ua.AttributeIDValue), "attribute to read (default to value)") ) flag.BoolVar(&debug.Enable, "debug", false, "enable debug logging") flag.Parse() @@ -45,7 +46,7 @@ func main() { req := &ua.ReadRequest{ MaxAge: 2000, NodesToRead: []*ua.ReadValueID{ - {NodeID: id}, + {NodeID: id, AttributeID: ua.AttributeID(*attrID)}, }, TimestampsToReturn: ua.TimestampsToReturnBoth, } @@ -89,5 +90,15 @@ func main() { log.Fatalf("Status not OK: %v", resp.Results[0].Status) } - log.Printf("%#v", resp.Results[0].Value.Value()) + switch v := resp.Results[0].Value.Value().(type) { + case *ua.NodeID: + log.Print(v.String()) + if err := c.RegisterExtensionObjectFromServer(ctx, v); err != nil { + log.Fatalf("%v", err) + } + log.Print("registered ", v.String()) + default: + log.Printf("%#v", v) + } + } diff --git a/go.mod b/go.mod index f75924d4..0ffe671f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/pascaldekloe/goe v0.1.1 + github.com/pascaldekloe/name v1.0.1 github.com/pkg/errors v0.9.1 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 golang.org/x/term v0.8.0 diff --git a/go.sum b/go.sum index ac17d5df..d15fc458 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/pascaldekloe/goe v0.1.1 h1:Ah6WQ56rZONR3RW3qWa2NCZ6JAVvSpUcoLBaOmYFt9Q= github.com/pascaldekloe/goe v0.1.1/go.mod h1:KSyfaxQOh0HZPjDP1FL/kFtbqYqrALJTaMafFUIccqU= +github.com/pascaldekloe/name v1.0.1 h1:9lnXOHeqeHHnWLbKfH6X98+4+ETVqFqxN09UXSjcMb0= +github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= diff --git a/ua/extension_object.go b/ua/extension_object.go index 4ee7dafd..8d6951fd 100644 --- a/ua/extension_object.go +++ b/ua/extension_object.go @@ -5,6 +5,8 @@ package ua import ( + "reflect" + "github.com/gopcua/opcua/debug" "github.com/gopcua/opcua/id" ) @@ -20,6 +22,27 @@ func RegisterExtensionObject(typeID *NodeID, v interface{}) { } } +/* +func RegisterExtensionObjectFromJSON(r io.Reader) error { + var types map[string]*StructureDefinition + if err := json.NewDecoder(r).Decode(&types); err != nil { + return err + } + return RegisterExtensionObjectFromStructure(types) +} +*/ + +func RegisterExtensionObjectFromStructure(types map[string]*StructureDefinition) error { + for id := range types { + gotype, err := UAType(id, types) + if err != nil { + return err + } + RegisterExtensionObject(MustParseNodeID(id), reflect.New(gotype)) + } + return nil +} + // These flags define the value type of an ExtensionObject. // They cannot be combined. const ( @@ -81,6 +104,14 @@ func (e *ExtensionObject) Decode(b []byte) (int, error) { } body.ReadStruct(e.Value) + + // if e.Value is an anonymous type (maybe reflect.Type(e.Value).Name() == "") then convert to map[string]interface{} + // encode e.Value to JSON and decode back to map[string]interface{} + // quick, robust but maybe not super efficient + // if there is no type def make e.Value a []byte + // this way the read never fails but you may not be able to interpret the result + // TODO + return buf.Pos(), body.Error() } @@ -89,6 +120,12 @@ func (e *ExtensionObject) Encode() ([]byte, error) { if e == nil { e = &ExtensionObject{TypeID: NewTwoByteExpandedNodeID(0), EncodingMask: ExtensionObjectEmpty} } + + // lookup type id, if anonymous type do the json dance + // v := eotypes.New(typeID) + // encode value to JSON and decode JSON into v + // TODO + buf.WriteStruct(e.TypeID) buf.WriteByte(e.EncodingMask) if e.EncodingMask == ExtensionObjectEmpty { diff --git a/ua/schema.go b/ua/schema.go new file mode 100644 index 00000000..41fe7a60 --- /dev/null +++ b/ua/schema.go @@ -0,0 +1,137 @@ +package ua + +import ( + "fmt" + "reflect" + "time" + + "github.com/pascaldekloe/name" +) + +// Serializable checks if the Go struct can be serialized into the UA type name. +func Serializable(t reflect.Type, id string, types map[string]*StructureDefinition) error { + uatyp, err := UAType(id, types) + if err != nil { + return err + } + uadef, godef := GoString(uatyp), GoString(anonType(t)) + if uadef != godef { + return fmt.Errorf(`schema: Go type "%s" is not serializable into UA type "%s" (%s)`, godef, uadef, id) + } + return nil +} + +// GoString returns a Go-syntax representation of the type of the value. +func GoString(t reflect.Type) string { + return fmt.Sprintf("%T", reflect.New(t).Interface()) +} + +// anonType returns the struct without the name. +func anonType(t reflect.Type) reflect.Type { + switch { + // do not unpack time.Time + case isTimeType(t): + return t + case t.Kind() == reflect.Slice: + return reflect.SliceOf(anonType(t.Elem())) + case t.Kind() == reflect.Struct: + return reflect.StructOf(structFields(t)) + default: + return t + } +} + +// structFields returns the fields of a struct as an array. +// it panics if t is not a struct. +func structFields(t reflect.Type) []reflect.StructField { + if t.Kind() != reflect.Struct { + panic(fmt.Sprintf("%s is not a struct", t.Name())) + } + var fields []reflect.StructField + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + f.Name = name.CamelCase(f.Name, true) + f.Type = anonType(f.Type) + fields = append(fields, f) + } + return fields +} + +// uaType returns an anonymous Go representation of the OPC/UA struct definition with the NodeID 'id'. +func UAType(id string, types map[string]*StructureDefinition) (reflect.Type, error) { + t := types[id] + if t == nil { + return nil, fmt.Errorf("schema: ua type %q not defined", id) + } + + // we don't support structs with optional fields or unions (yet or maybe never) + if t.StructureType != StructureTypeStructure { + return nil, fmt.Errorf("schema: ua type %q has unsupported structure type %s", id, t.StructureType) + } + + var fields []reflect.StructField + for _, f := range t.Fields { + if len(f.ArrayDimensions) > 1 { + return nil, fmt.Errorf("schema: ua type %s.%s is a multi-dimensional array: %v", id, f.Name, f.ArrayDimensions) + } + + var elem reflect.Type + switch f.DataType.String() { + case "i=1": + elem = reflect.TypeOf(bool(false)) + case "i=2": + elem = reflect.TypeOf(int8(0)) + case "i=3": + elem = reflect.TypeOf(uint8(0)) + case "i=4": + elem = reflect.TypeOf(int16(0)) + case "i=5": + elem = reflect.TypeOf(uint16(0)) + case "i=6": + elem = reflect.TypeOf(int32(0)) + case "i=7": + elem = reflect.TypeOf(uint32(0)) + case "i=8": + elem = reflect.TypeOf(int64(0)) + case "i=9": + elem = reflect.TypeOf(uint64(0)) + case "i=10": + elem = reflect.TypeOf(float32(0)) + case "i=11": + elem = reflect.TypeOf(float64(0)) + case "i=12": + // todo(fs): maybe encode length constrained strings as []rune + // if f.MaxStringLength > 0 { + // elem = reflect.TypeOf(make([]rune, f.MaxStringLength)) + // } + elem = reflect.TypeOf(string("")) + case "i=13": + elem = reflect.TypeOf(time.Time{}) + default: + fid := f.DataType.String() + ft := types[fid] + switch { + case ft.BaseDataType.String() == "i=22": + var err error + elem, err = UAType(fid, types) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("schema: invalid data type %s", fid) + } + } + + typ := elem + for rank := f.ValueRank; rank > 0; rank-- { + typ = reflect.SliceOf(typ) + } + + fname := name.CamelCase(f.Name, true) + fields = append(fields, reflect.StructField{Name: fname, Type: typ}) + } + + return reflect.StructOf(fields), nil +} + +func isTimeType(t reflect.Type) bool { return t == reflect.TypeOf(time.Time{}) } diff --git a/uatest/read_unknow_node_id_server.py b/uatest/read_unknow_node_id_server.py index 71f91edc..96186ac1 100755 --- a/uatest/read_unknow_node_id_server.py +++ b/uatest/read_unknow_node_id_server.py @@ -26,8 +26,9 @@ def __str__(self): server.set_endpoint("opc.tcp://0.0.0.0:4840/") ns = server.register_namespace("http://gopcua.com/") - + uatypes.register_extension_object('IntVal', ua.StringNodeId("IntValType", ns), IntVal) + # definitely not clear why this is needed, but without it does not work setattr(ua.ObjectIds, 'IntVal', 'IntValType') diff --git a/uatest/read_unknow_node_id_test.go b/uatest/read_unknow_node_id_test.go index b5fd4249..426f618b 100644 --- a/uatest/read_unknow_node_id_test.go +++ b/uatest/read_unknow_node_id_test.go @@ -32,6 +32,9 @@ func TestReadUnknowNodeID(t *testing.T) { // read node with unknown extension object // This should be OK nodeWithUnknownType := ua.NewStringNodeID(2, "IntValZero") + //unknownType := ua.NewStringNodeID(2, "IntValType") + + // read should fail b/c node uses an unknown extension object resp, err := c.Read(ctx, &ua.ReadRequest{ NodesToRead: []*ua.ReadValueID{ {NodeID: nodeWithUnknownType}, @@ -41,10 +44,31 @@ func TestReadUnknowNodeID(t *testing.T) { t.Fatal(err) } + // expect data type unknown if got, want := resp.Results[0].Status, ua.StatusBadDataTypeIDUnknown; got != want { t.Errorf("got status %v want %v for a node with an unknown type", got, want) } + // register the data type from the type + err = c.RegisterExtensionObjectFromServer(ctx, nodeWithUnknownType) + if err != nil { + t.Fatal(err) + } + + resp, err = c.Read(ctx, &ua.ReadRequest{ + NodesToRead: []*ua.ReadValueID{ + {NodeID: nodeWithUnknownType}, + }, + }) + if err != nil { + t.Fatal(err) + } + + // should work now + if got, want := resp.Results[0].Status, ua.StatusOK; got != want { + t.Errorf("got status %v want %v", got, want) + } + // check that the connection is still usable by reading another node. _, err = c.Read(ctx, &ua.ReadRequest{ NodesToRead: []*ua.ReadValueID{