OPC-UA driver for Ignition 8: New "Device" interface?

Was trying to follow the example for developing a module for an OPC-UA device for Ignition 8.0. It’s confusing for a few reasons:

  • The examples for Ignition 8.0 use the com.inductiveautomation.ignition.gateway.opcua.server.api.Device interface, whereas previous examples I have for for Ignition 7.9 use com.inductiveautomation.xopc.driver.api.Driver and com.inductiveautomation.xopc.driver.api.tags.DriverTagManager interfaces
  • There is no documentation in the Ignition 8.0 JavaDocs for the Device interface, or for anything else in the com.inductiveautomation.ignition.gateway.opcua package hierarchy
  • The example for Ignition 8.0 is using a simulator that just generates values on demand, and doesn’t show how requests to read and write values from an actual device would work

Are there any other examples of how to go about writing an OPC-UA driver for Ignition 8.0? If not, are there any good starting points that I could use? Should I still implement Driver and DriverTagManager and hook them in somehow, or are those interfaces no longer used? Really looking for any guidance here, since the example and JavaDoc are really sparse or missing.

You could technically still build a driver against the old Driver interface and it will work because there’s a mechanism to bridge Driver to Device, but this API is now deprecated and unsupported.

There are some javadocs written for Device but it looks like none of the javadocs from the UA module are being published for some reason. I’ll have to look into this.

The OPC UA Device example is the recommended starting point if you’re building for 8.0. There isn’t actually much to the new device API because it’s mostly an extension of the Eclipse Milo address space APIs plus the additional context from being an Ignition module.

As always with the module SDK… it is what is. You’re welcome to keep asking questions in the forums and myself or others will answer what they can.

Thanks, that helps a lot! Knowing that Driver is deprecated, I’ll steer away from that and focus on the Milo address space API (ManagedAddressSpaceServices and related APIs are where I will look for now, based on the example).

Thanks for the quick response. I’m sure I’ll have more questions, and I’ll try to make some progress based on the example. The thing I’m struggling with most at the moment is trying to figure out where to hook in code that communicates with the actual device.

Extending ManagedAddressSpaceServices is probably the wrong approach for something that is meant to talk to a real device over the network. The “Managed” subclasses implement browse, read, and write under the assumption that all your Nodes will be managed by a NodeManager and that their values can be read from and written to those Nodes directly.

For a real device, you can still have Node instances in memory and use a NodeManager, but you likely want to intercept the read and write services when they target the Value attribute and actually read from or write to the device, rather than from the Node you have in memory. How you implement browse depends on whether the device supports browsing or not.

Bringing this topic back… Instead of trying to write a device module last time, I ended up a managed tag provider module since it was a good fit for that case. Now I’m trying to communicate with a different type of device, and writing a device module is more appropriate. I’m getting stuck and was hoping for some guidance.

My device module will communicate with individual hardware devices. These devices allow read and write of data points, as well as browsing their hierarchical structure. I’ve based my project on the OPC UA Device example, but without my Device implementing ManagedAddressSpaceServices – instead, I am implementing Device directly, as recommended above.

Here’s where I am so far:

  • I’m able to compile a module and install it
  • When module installed, logger says ModuleManager is Installing module and Starting up module, in both cases followed by my module ID and version number
  • I’ve added logging statements to the beginning of all the Device methods that I need to implement for the various AddressSpaceServices methods it inherits, so that I can see which methods are called by Ignition
    • When I add a device connection, my Device is instantiated, and Device.startup() is called
    • When I open the Ignition OPC UA Server folder in the OPC Quick Browser, my Device.getReferences() is called with a node ID of ns=0;i=85
  • Device.shutdown() is called when I uninstall the module

Some questions:

  • Do I need to maintain my own data structures to hold the tree of data values (state) of my device, or am I supposed to be directly manipulating the nodes on the Ignition OPC UA server in order to hold that information and represent the device? I assumed that the Ignition OPC UA server would maintain its nodes based on what is returned by my Device class, and that I would have to represent the state of my device as though it were an OPC UA device, via OPC UA Nodes and Variables. But, I may have made a bad assumption here.
  • If that’s what I need to do, do I need to create my own NodeManager and Nodes to represent the variables? If so, how should I best go about that?
  • What should I return when Ignition calls my Device.getReferences()? I’m not sure what the response of my Device class should be in order to have Ignition represent the hardware device correctly in its device tree.
  • Are there any examples that you know of that involve implementing the Device interface without using ManagedAddressSpaceServices? I poked around GitHub and could find no examples, so it’s hard to understand how my Device implementation should manage its state and respond to calls from Ignition.
  • Is there some easier way to implement this? I think that in Ignition 7.9 when the Driver interface still was around, there were abstract classes that helped to provide some of the implementation necessary to represent and maintain the state of the actual device. I poked around but did not find anything like that in the current libraries.

I feel like I’m misunderstanding the fundamental strategy involved to implement Device for reading, writing, and browsing a hardware device and could use some guidance on the broad concepts. Once I understand the general strategy that is intended for writing a Device implementation I should be OK to run with it, but I haven’t been able to find any examples online nor any similar discussions in the forums. Any and all advice, guidance, and pointers here would be much appreciated!

I may have given you some bad advice previously. It might be entirely appropriate to use the ManagedAddressSpaceServices for your device implementation, especially if it’s not going to have a large quantity of Nodes.

ManagedAddressSpaceServices takes care of a lot of the state management you’re asking about by using a UaNodeManager to manage nodes and implement read, write, and browse services.

You can still extend from this and use its node management facilities and override the read and write methods to be more performant if necessary. The default implementations will read from or write to the UaNode instances directly, which means you have to install a delegate or filter (depends on the Mio version) on these to “intercept” the call and actually act on it if needed. This can be too slow if, e.g. hundreds of values are being read at once and you need to batch these up into a single call to some underlying device. This is when it becomes appropriate to override the read method with something custom.

Ah, and just as a warning to you and anyone else who might be using this new Device API, there are some breaking changing coming down the line :frowning:

Whenever Milo 0.5.0 is released and Ignition updates to it there will be some required changes to your Device implementations. You can get a feel for what’s changing here: https://github.com/inductiveautomation/ignition-sdk-examples/pull/43

and the corresponding change in Milo here: https://github.com/eclipse/milo/pull/627

Thanks for the advice! I had recently been looking around at ManagedAddressSpaceServices and thought that it seemed to take on a lot of the work, and was wondering if I could leverage that. I think I’ll go that route and see what I can do, based on your OPC UA device example.

Also, thanks for the heads-up on the breaking changes, that’s very good to know! The changes look like a nice improvement. I see how you are using an AttributeFilter with the new Milo API to intercept the moment when the value is asked for. Looking at the current OPC UA example project, I see there is a similar technique using UaVariableNode.setAttributeDelegate() to intercept read calls using the current Milo API. I’ll try to do the same and report back on how it goes.

Yeah, AttributeDelegate and AttributeFilter are very similar concepts, the filter is mostly just an evolution of the concept after some experience using the delegate implementation. The delegate will eventually be removed when 1.0 is released.

I was also hoping to making the filter version async capable and possibly support batching but so far it has proven too difficult to design a reasonable API for.

Everything is much easier when you use “managed” UaNodes and the “managed” AddressSpace implementations, and for most cases this is good enough. It mostly becomes a problem when:

  1. your AddressSpace needs to represent more nodes than is reasonable to keep in memory.
  2. you need more sophisticated handling of reads, writes, and subscriptions, so that you can batch/group/optimize these instead of handling them on a node-by-node basis.

If you consider case 1 it should become apparent why the raw AddressSpace API is designed the way it is - every one of those calls can be answered without actually having a UaNode instance in memory, it’s just a lot more work depending on the call.

That all makes a lot of sense. I think my devices will be lightweight in the amount of nodes needed, and I think the traffic is low enough to not need more sophisticated batching or other optimizations. Once I’m able to get the ManagedAddressSpaceServices version of my Device working, I’ll keep the other ideas in mind in case optimization is needed.

Struggling with the OPC UA APIs and how they relate to Ignition. The OPC UA spec is so massive and freeform that it makes for a huge wall to climb to do simple things. And, I’m not exactly sure what Ignition is expecting me to do in order to provide a Device implementation that it likes.

Here is an example: In my Device class (which extends ManagedAddressSpaceServices), I’m trying to set a property on a node and then read it back.

UaNode node = <some node constructor, like UaFolderNode>

final QualifiedProperty<String> fooProperty =                                            
        new QualifiedProperty<>(                                                                      
                getNodeContext().getNamespaceTable().getUri(node.getNodeId().getNamespaceIndex()),    
                "foo",                                                                 
                Identifiers.String,                                                                   
                ValueRanks.Scalar,                                                                    
                String.class                                                                          
        );                                                                                            
node.setProperty(fooProperty, "test");                      
                                                                                                      
// now try to read it back immediately using the exact same QualifiedProperty 
// this logs "Read back the property as Optional.empty"                                                            
logger.debug("Read back the property as {}", node.getProperty(fooProperty));             
                                                                                                      
// maybe try to get the node instead of the property?
// this logs "Able to get the property node: false"                                                                     
logger.debug("Able to get the property node: {}", node.getPropertyNode("foo").isPresent());

Any idea what could be going wrong here? Unfortunately, I’m hitting my head against a number of similarly simple things that just don’t work the way I’m expecting.

Another thing I’m noticing is that there are some “magical” things that happen as far as node IDs go. The device name ends up being prepended to NodeId identifiers even though I don’t add it. And, property identifiers seem to take on their parent’s identifier suffixed with a dot and the property name. Some of these things make sense but are taking me by surprise when I set a particular node ID and get something else back. Without documentation, I don’t find out until I try to use a node ID I provided previously and see that it doesn’t work due to these modifications. Are there other things like that I should watch out for?

What is it you’re trying to accomplish here? Do you really want to be creating a Property and PropertyNode?

Either way, this should work, but maybe there’s something wrong with the way the parent Node is being created or with doing this in the context of a Device… not sure yet.

The device name is prepended to the NodeIds you get from DeviceContext.nodeId because that’s how the larger AddressSpace your device is a part of identifies your AddressSpace as the one that is responsible for it. Using [$deviceName] prepended is a convention used by Ignition’s device API and the helper function is just there to make it easier to create correct NodeIds for your Device instance.

Kevin, thanks for the help!

What is it you’re trying to accomplish here? Do you really want to be creating a Property and PropertyNode?

I’m creating UaFolderNode or UaVariableNode nodes to represent folders and tags from my device. Each folder and tag on my device has an address that uniquely identifies it. The address is made up of two arbitrary strings. I wanted to store each string in a property of its corresponding folder or variable node. That way, when I get a request to read a node’s value, I could retrieve the node’s two address properties and use them to access the corresponding tag on the device.

I could do the same thing by keeping a Map of node IDs to device addresses. However, since OPC UA already has a facility for keeping track of arbitrary meta-information that is related to a node (a property), I thought I could leverage that and keep the data together.

However, I don’t seem to be able to read any properties that I create. As in the code above, even using the same QualifiedProperty to set and get a property doesn’t work – the “set” seems to work, but the “get” comes back empty. In general, if a value can be set via object.set(key, value) I would expect object.get(key) to retrieve the value, but there must be something different going on here.

Is using properties in this way a bad idea? Would you recommend using my own Map instead as mentioned above, or is there some better way this usually is done?

Using [$deviceName] prepended is a convention used by Ignition’s device API and the helper function is just there to make it easier to create correct NodeIds for your Device instance.

This makes sense in hindsight, just was unexpected that the node ID I specified would be changed. I guess this is one of the “unwritten rules” of writing a device driver for Ignition. Looks like this happens when creating a NodeId via DeviceContext.nodeId(). Would be good to have basic JavaDoc on DeviceContext and what its purpose is, when to use it, how it modifies node IDs, etc. However, the com.inductiveautomation.ignition.gateway.opcua.server.api package is missing from the JavaDoc, so the only thing to go on is the method signatures and class names from the bytecode in the opc-ua-gateway-api JAR.

Perhaps there is other documentation on using the classes in that API package, but I haven’t found it.
If it existed, I would assume it would be in the Ignition SDK Programmer’s Guide. However, the section on writing an OPC UA driver still references the old 7.x Driver API. The missing documentation the for the new 8.x API leads to a difficult first-time experience, especially combined with having to learn OPC UA to make use of it.

So, there probably are some misunderstandings I’m having that seem quite basic to you, but for someone having to learn all of this via method signatures and OPC UA spec documents, it’s quite difficult to know what path to follow in the sea of options. I appreciate all of the guidance you’ve been able to provide so far. :slight_smile:

No, this is actually a completely appropriate use for them. Sorry I assumed you understand less about OPC UA than you do.

I’m not sure why your property code isn’t working. Can you share some of the surrounding code, e.g. the creation of the parent nodes and the NodeIds being used there?

I copied and pasted your property set/get code into the device example from the GitHub SDK examples repo, adding the property to the rootNode folder, and it worked as expected.

    @Override
    public void onStartup() {
        super.onStartup();

        // create a folder node for our configured device
        UaFolderNode rootNode = new UaFolderNode(
            getNodeContext(),
            deviceContext.nodeId(getName()),
            deviceContext.qualifiedName(String.format("[%s]", getName())),
            new LocalizedText(String.format("[%s]", getName()))
        );

        // add the folder node to the server
        getNodeManager().addNode(rootNode);

        final QualifiedProperty<String> fooProperty =
            new QualifiedProperty<>(
                getNodeContext().getNamespaceTable().getUri(rootNode.getNodeId().getNamespaceIndex()),
                "foo",
                Identifiers.String,
                ValueRanks.Scalar,
                String.class
            );

        rootNode.setProperty(fooProperty, "test");

        logger.info("Read back the property as {}", rootNode.getProperty(fooProperty));
        logger.info("Able to get the property node: {}", rootNode.getPropertyNode("foo").isPresent());

Mr. Herron.

I just erased a long post wherein I tried your code inside my onStartup(), and it didn’t work. I also tried moving my code to onStartup() to try it there, and it didn’t work.

I’m scratching my head, thinking, “hmm, I mean, he’s doing the exact same thing, creating a new node, setting a property… where exactly did Kevin add that code… let’s see… no code before it except for super.onStartup()…”

That’s where smacked myself in the forehead audibly after realizing I had not called super.onStartup(). It turns out that the one thing the superclass does, which is kind of important, is to register the address space.

Seems like a bit of rubber duck debugging helped a lot. Anyway, things at the moment are going a bit better. My code snippet now works, as does yours. Let’s see if I can make some more headway in a more sane world, where address spaces are registered as the OPC UA gods intended. I’ll keep you posted on progress whether good or bad, and probably will have some other questions. Thanks again for your help so far!

3 Likes

A happy side effect of the breaking changes that I mentioned are coming down the line is it won’t be possible to make this kind of simple mistake - the superclass will run its startup logic regardless of whether subclasses remembered to call super.onStartup(). If subclasses want to have startup logic too, they can, by registering it with the lifecycle manager, but they don’t override it any more.

Not that this helps you now :slight_smile:

2 Likes

Just closing the loop on this… Was able to create a driver, read/write tags, browse, get device status, etc.

I ended up not using OPC UA properties, solely due to the visual aspect of having properties rendered as separate tags. For some reason, I was thinking that properties would render inside of the tag properties that are seen when a tag is expanded in the UI. Instead, I encoded the information I needed to keep for each tag into the node ID, as the information was unique for each node and so could serve as an ID.

I have some other questions that I’ll raise in another thread. Thanks for your help so far – I have a functional driver!

1 Like