tnunnink / L5Sharp

A library for intuitively interacting with Rockwell's L5X import/export files.
MIT License
55 stars 6 forks source link

Question/Issue using custom data types with existing tags. #5

Closed facechase closed 1 year ago

facechase commented 1 year ago

First off, great work on the project. I was dabbling with parsing the l5x using the Rockwell provided XML schema, and I found this repo and realized you were lightyears ahead of me.

I have a project with a lot of UDTs that are often nested within each other. I'm attempting to use L5Sharp to import tags/datatypes from another project (the Studio5000 built-in tools are very clunky), and I'm encountering an issue with creating a Tag object that uses a custom data type class that I've defined. Below is what I've attempted so far.


        var content = LogixContent.Load("Aspiration_Module.L5X");

        var structTest = content.Tags().Find("Members").Member("Configuration.Development.StructTest");

        var testTag = new Tag
        {
            Name = structTest.TagName,
            Data = structTest.Data.As<StructTestUDT>()
        };

Ideally I would like to be able to lookup a tag defined in the L5X and cast it directly to my custom defined object which matches the tag data type.

I wrote a simple method to generate C# classes from existing datatypes in the L5X project.

using L5Sharp.Enums;
using L5Sharp.Types.Atomics;
using L5Sharp.Types.Predefined;
using L5Sharp.Types;
using L5Sharp;
namespace XML_Infer
{
    /// <summary>
    /// 
    ///</summary>
    public class StructTestUDT : StructureType
    {
        public StructTestUDT() : base(nameof(StructTestUDT))
        {
        }
        public override DataTypeClass Class => DataTypeClass.User;
        public BOOL Bool1 { get; set; } = new();
        /// <summary>
        /// 
        ///</summary>
        public BOOL Bool2 { get; set; } = new();
        /// <summary>
        /// 
        ///</summary>
        public DINT RegularInt { get; set; } = new();
        /// <summary>
        /// 
        ///</summary>
        public BOOL Bool2_1 { get; set; } = new();
        /// <summary>
        /// 
        ///</summary>
        public BOOL Bool2_2 { get; set; } = new();
        /// <summary>
        /// 
        ///</summary>
        public BOOL Bool2_3 { get; set; } = new();
        /// <summary>
        /// 
        ///</summary>
    }
}

For reference, here is the xml for the datatype I generated the StrucTestUDT class from.

<DataType Name="StructTestUDT" Family="NoFamily" Class="User">
<Members>
<Member Name="ZZZZZZZZZZStructTest0" DataType="SINT" Dimension="0" Radix="Decimal" Hidden="true" ExternalAccess="Read/Write"/>
<Member Name="Bool1" DataType="BIT" Dimension="0" Radix="Decimal" Hidden="false" Target="ZZZZZZZZZZStructTest0" BitNumber="0" ExternalAccess="Read/Write"/>
<Member Name="Bool2" DataType="BIT" Dimension="0" Radix="Decimal" Hidden="false" Target="ZZZZZZZZZZStructTest0" BitNumber="1" ExternalAccess="Read/Write"/>
<Member Name="RegularInt" DataType="DINT" Dimension="0" Radix="Decimal" Hidden="false" ExternalAccess="Read/Write"/>
<Member Name="ZZZZZZZZZZStructTest4" DataType="SINT" Dimension="0" Radix="Decimal" Hidden="true" ExternalAccess="Read/Write"/>
<Member Name="Bool2_1" DataType="BIT" Dimension="0" Radix="Decimal" Hidden="false" Target="ZZZZZZZZZZStructTest4" BitNumber="0" ExternalAccess="Read/Write"/>
<Member Name="Bool2_2" DataType="BIT" Dimension="0" Radix="Decimal" Hidden="false" Target="ZZZZZZZZZZStructTest4" BitNumber="1" ExternalAccess="Read/Write"/>
<Member Name="Bool2_3" DataType="BIT" Dimension="0" Radix="Decimal" Hidden="false" Target="ZZZZZZZZZZStructTest4" BitNumber="2" ExternalAccess="Read/Write"/>
</Members>
</DataType>

I've reviewed the source pretty extensively hoping for an example, but haven't had any luck. If what I'm attempting is supported/implemented, I'd appreciate any guidance you can provide. If not, I'm happy to contribute to the project in order to help add that functionality.

tnunnink commented 1 year ago

@facechase Thanks for the kind words!

I know this issue well. You are correct that this is not currently possible with the library. Right now, any complex type is deserialized as a generic StructureType instance since there is no way to know they derived type at compile time (at least with the current implementation). Therefore, you can't cast it down to a more derived type.

However, my thoughts are in line with yours, and in fact I have a new branch I'm working on now that aims to solve this. The solution will probably just involve either pre-registration of your type or use of reflection. I'm also sort or reworking my approach the serialization/deserialization which I think will make things a lot easier when updating properties of components. But the overall API will still be very similar.

I hope to have something ready in the next couple weeks or so, but if you are in a time crunch and need a work around now, I suppose you could create a constructor on your user type that accepts a StructureType. In the constructor, you could initialize the members of your type with the members of the input structure by accessing them dynamically via the Members collection of the structure.

So maybe something like the following.

public class StructTestUDT : StructureType
{
    public StructTestUDT() : base(nameof(StructTestUDT))
    {
    }

    public StructTestUDT(ILogixType type) : base(nameof(StructTestUDT))
    {
        Bool1 = type.Members.First(m => m.Name == nameof(Bool1)).DataType.As<BOOL>();
        Bool2 = type.Members.First(m => m.Name == nameof(Bool2)).DataType.As<BOOL>();
        RegularInt = type.Members.First(m => m.Name == nameof(RegularInt)).DataType.As<DINT>();
        Bool2_1 = type.Members.First(m => m.Name == nameof(Bool2_1)).DataType.As<BOOL>();
        Bool2_2 = type.Members.First(m => m.Name == nameof(Bool2_2)).DataType.As<BOOL>();
        Bool2_3 = type.Members.First(m => m.Name == nameof(Bool2_3)).DataType.As<BOOL>();
    }

    public override DataTypeClass Class => DataTypeClass.User;

    public BOOL Bool1 { get; set; } = new();
    /// <summary>
    /// 
    ///</summary>
    public BOOL Bool2 { get; set; } = new();
    /// <summary>
    /// 
    ///</summary>
    public DINT RegularInt { get; set; } = new();
    /// <summary>
    /// 
    ///</summary>
    public BOOL Bool2_1 { get; set; } = new();
    /// <summary>
    /// 
    ///</summary>
    public BOOL Bool2_2 { get; set; } = new();
    /// <summary>
    /// 
    ///</summary>
    public BOOL Bool2_3 { get; set; } = new();
}
var content = LogixContent.Load("Aspiration_Module.L5X");

var structTest = content.Tags().Find("Members").Member("Configuration.Development.StructTest");

var testTag = new Tag
{
    Name = structTest.TagName,
    Data = new StructTestUDT(structTest.Data)
};

testTag.Data.As<StructTestUDT>().Bool1 = true;

If you have other ideas or suggestions let me know. Thanks!

facechase commented 1 year ago

Glad to hear you're working on expanding support for complex structures, I've found that functionality missing from a lot of similar libraries out there. If you're looking at modifying your serialization method, I'd consider taking a look at this repo. It's obviously not directly applicable, but I've found it's approach to handling complex types fairly intuitive. It avoids the typing issue by making everything a dictionary with the xml attributes as keys, which allows structure members to be accessed by name.

I think pre-registration is a valid option, since Studio5000 projects tend to be fairly static. My current workflow to read complex types (directly from the controller) is to make a pass on the project file to generate C# classes for each defined datatype and then perform a marshaled copy directly from the raw structure data read from the controller. Being able to have it all done at compile time would be nice but my grasp of reflection is limited at best.

On another note, I was wondering how you're handling boolean members, since Studio5000 uses hidden tags to store the data. My solution when defining structures was to exclude a values of type BIT from the struct and add getter/setter methods for each boolean property to deference individual bits in the base tag. When you re serialize a tag with boolean members, how are the target/bit number attributes populated? I don't see those attributes in the generated XML, but the file can be imported fine. Does Studio5000 handle that bit mapping during the import?

My goal with using l5x is to develop something that approximates library management in Studio5000. Currently, there are several "Common Libraries" which are just ACD projects which define datatypes, tags, AOIs, etc. which each need to be manually imported. I hope to be able to take these members from several source l5x files and add them to a single target project. I believe the features you're already working on are in line with this goal, just wanted to give you an example of a use case for the library.

tnunnink commented 1 year ago

This is great feedback thank you.

I have seen this repo you referenced, but I will take a closer look when I find time. I think overall my implementation should look very similar. If after the next merge you still think it could be improved, please let me know.

Your method for generating your user defined data types sounds interesting. I'm not sure I fully understand how that works. Are you generating a .cs file representing the user data type and then compiling your code, or you are generating the class dynamically at runtime? I'm not familiar with a marshaled copy.

And now that I think more about it, just to make sure I understand the original question, you want to cast a tag's data type to your custom object in order to access the members of the type statically, correct? For instance, you want to be able to write tag.Data.Bool1 = true? I think that would be the only benefit of casting the type? Otherwise, you can always access the members by name using tag.Member("Bool1").Value = new BOOL(true). Let me know if I'm still missing something.

As a side note, in my new branch, I am combining Data and Value on the tag to just be Value, which is just the ILogixType containing the data of the tag. You will be able to cast Value to the concrete predefined/registered types. Seems much simpler and more intuitive. The Member access methods will still be the same.

For the Boolean/bit/hidden backing members, I found by trial and error that Studio5000 does indeed handle the bit mapping internally. You can export a data type, delete all the hidden members, import it without error, and then re-export and it will have the hidden members regenerated. Once I found this out, I decided to ignore them.

I completely agree with your goal and thanks for the example. We do the same thing in terms of generating new projects. It's good to hear I'm not the only one out there. Gives me more motivation to improve this library so that it can help with these types of tasks/tools.

facechase commented 1 year ago

My current method for generating datatypes relies on generating the .cs files and adding them to the project before compiling. From the little research I've done, it should be possible to do dynamic class generation in C# but it quickly went beyond my experience with the language and would be a lot of work for little functional gain. Marshaled copy is just a way to emulate the behavior of unions in C. Tag data can be pulled from the controller using CIP, and the resulting data (a byte array) can be copied into a defined struct (generated from the L5X) in order to allow complex data structures to be read/written directly from the controller. Not particularly relevant for this project, just context for what I'm currently using the generated classes for.

There wasn't a particular use case for my original question, I was just trying to get a better understanding of how the library handles complex types. Your suggestion is correct, tag.Member("Bool1").Value = new BOOL(true) would work fine for the example I provided. What I was hoping to accomplish was to modify and expand complex data types. A more realistic example would be something like tag.Members.Add(new CustomStructure(importedTag), where tag is a UDT containing constants defined by different libraries that I am expanding to include a new structure defined by another project. Of course this would also require updating the DataType of 'tag' to include the DataType of 'importedTag' as a member in order for the L5X to be properly compiled by Studio5000.

Great observation on the boolean members, I never would have thought to try excluding the hidden members. There are some cases however where I am setting the "hidden" attribute to false in order to be able to use true "bit mapping". In these cases, I'm specifically enumerating bits in a DINT, but still using the base DINT for some operations. This functionality is not supported by studio 5000, but if a datatype with a bitmap is imported from l5x, it will be properly included in the project.

image

Thanks for the informative responses and I look forward to getting a chance to use the library after your next merge!

tnunnink commented 1 year ago

@facechase Just wanted to give you an update. Sorry it's taken a bit for me to respond. I have merged the updated branch I have been working on over the last few months. It still needs more testing and clean up before generating a new release but will hopefully be there soon. Feel free to take a look.

I just wanted to cover/respond to the points brought up in this thread to try and close the loop. Someday I will find time to make better documentation on how everything works.

Data Type Casting

With the new update, you can now cast tag's Value property, which is a generic LogixType object, to the appropriate derived type as originally requested. This includes deserialized objects. For instance, say we have a tag of data type "TIMER" that we have deserialized from the L5X. Performing the following cast to the corresponding data type will allow static access to the members of the type.

tag.Value.As<TIMER>().PRE = 10000;

This works essentially for any type. Obviously, casting to an invalid type will throw a InvalidCastException. For example, casting a DINT to a TIMER does not work.

The main requirement now for all types is that they need to have a public constructor accepting a single XElement object. See the custom type called MyNestedType in the test library as an example. Deserialization uses this constructor via reflection. The LogixSerializer class now scans the assembly and caches these constructors for future use to be efficient. It will by default also scan all assemblies in the current app domain, so there is no need to manually register your types unless you want to for some reason.

As mentioned before, casting is mostly helpful to avoid having to reference members by name via tag.Member("tagName"); It is mostly optional in my mind, as I think typically people want to just dynamically access the members of a data type, which sort of brings me to the next point.

Mutable Structure Type

I thought more about your request to have the ability to change a tag's structure even after it has been instantiated. I came to the conclusion that this was a good idea and even an important feature, as even if you spent the time to create custom types to model UDTs, those can change from project to project or even over time, so having a way to update these structures on the fly seem crucial. So, I added a new type called ComplexType. It simply inherits StructureType and makes methods for mutating the members collection available. All types that are not predefined will be created as a ComplexType, allowing you to modify the structure dynamically.

This could look something like the following. (I may still add methods to tag to make this more concise but not sure yet). tag.Value.As<ComplexType>.Add(new Member("MemberName", otherTag.Value));

I left the predefined types like TIMER to inherit from the immutable StructureType as these should not change (I would think) but feel free to change your UDT classes to inherit from ComplexType if you wish to update them on the fly.

Hidden Boolean Members

I also thought more about this request and decided to put the properties for the hidden members back into the DataTypeMember component. I removed these a long time ago thinking they would not be important, but I agree that they should be there and just up to the user to decide whether or not to pay attention to them. One of the main goals of this library is to just wrap the XML schema with strongly typed classes to make interacting with the L5X intuitive, so not including these properties seem wrong from that perspective.

Implicit LogixType Conversions

One last thing I want to mention here is that I added some implicit type conversions for the base LogixType class to make setting values more concise. You may notice now that you can write tag.Value = 100; instead of tag.Value = new DINT(100); Here, 100 is getting implicitly converted to a DINT. This also works for arrays and dictionaries, which get converted to ArrayType and ComplexType, respectively. Therefore, the syntax is similar to the library you shared previously, except for the fact that creating dictionaries in .NET is not as concise as python.

var tag = new Tag { Name = "Test", Value = new TIMER() };

tag.Value = new Dictionary<string, LogixType>
{
    { "PRE", 5000 },
    { "ACC", 1234 },
    { "DN", true },
};

Setting a complex type will join each member based on name and set the corresponding value, so you would only need to include the members you wish to set in the dictionary.

I think that covers everything for now. Hope to expand on all this when I find time to build more thorough documentation. Let me know what you think or if you have any new issues/requests/ideas. Again, I hope to have a new release out in the coming weeks. Thanks!

tnunnink commented 1 year ago

pushed new release package 0.11.0 with changes mentioned above.