OPC-UA Driver Development Pointers

Hello,

I guess I’m being a bit slow…

I’m attempting to do an OPC driver, Chapter 6 in the SDK manual could use some work I believe. Maybe some simpler example sources than the Modbus project as well, possibly something that actually matches the SDK documentation? I’ve also downloaded and examined the Generic TCP Driver by chi.

I attempted to use the generate stubs functionality in ModuleSDK to produce something I could build upon, but that didn’t really generate OPC Driver stubs from what I could tell.

Questions about Chapter 6:

  1. I don’t see how the documented DriverMeta Interface is used in either of the above examples. I do not find DriverMeta referenced anywhere in the example source code.
  2. I don’t see AbstractNioDriver. I do see AbstractSocketDriver, but I’m using netty for my communications layer.
  3. I can’t seem to get my driver to show as an available device type when trying to add a new OPC device. I’m sure I’m missing something simple.

Please ask for any clarifications. All the code I’ve copied/produced for the driver module is available at this stage. Below are the GatewayHook, Driver and DriverType sources.

[code]public class GatewayHook extends AbstractDriverModuleHook {
public static final String BUNDLE_PREFIX = “WorkshopConnection”;
public final static int DEFAULT_BROADCAST_PORT = 49152;
private static final String[] HCON_MENU_PATH = {“workshopconnection”};
private static final String INFODRIVER_MODULE_ID = “informationdriver”;

private static final List<DriverType> DRIVER_TYPES = Lists.newArrayList();

static {
    DRIVER_TYPES.add(new InformationDriverType());
}

private GatewayContext context;

// private WorkshopController workshopController;
private GfmsWscSettings coreSettings;

private final Logger log = BuildLogger.getLog4JLogger(getClass());

@Override
public void setup(GatewayContext gatewayContext) {
	this.context = gatewayContext;

	log.debug("Beginning setup of WorkshopConnection Module " + DriverAPI.VERSION);

	// Register GatewayHook.properties by registering the GatewayHook.class with BundleUtils
	BundleUtil.get().addBundle(BUNDLE_PREFIX, GatewayHook.class, "WorkshopConnection");

    // Disable caching in development mode - Without this, the use of bundle util
    // keeps the jar file opened, even after a driver is reloaded by the dev module.
    // Not a big problem, as the temp jar files won't be deleted until the JVM shuts down,
    // but with this option the files can be deleted manually, if all classes are properly
    // unloaded. This allows a quick test for memory leaks.
    if (context.getWebApplication().getConfigurationType() == RuntimeConfigurationType.DEVELOPMENT) {
        context.getWebApplication().getResourceSettings().getLocalizer().clearCache();
        URLConnection con;
        try {
            con = new URLConnection(new URL("file://null")) {

                @Override
                public void connect() throws IOException {
                    // NOOP - This is just a dummy

                }
            };
            // This will affect all URLConnections - not sure about side effects
            con.setDefaultUseCaches(false);
        } catch (MalformedURLException e) {
            e.printStackTrace();
        }
    }

    // Clear Wicket's markup cache. Actually this is only necessary when the module is upgraded to a new version, but there's
    // no way to detect this situation.
    if (context.getWebApplication().getMarkupSettings().getMarkupFactory().hasMarkupCache()) {
        // When the gateway service is started, the is no markup cache yet.
        context.getWebApplication().getResourceSettings().getPropertiesFactory().clearCache();
        context.getWebApplication().getMarkupSettings().getMarkupFactory().getMarkupCache().clear();
    }

	//Verify tables for persistent records if necessary
	verifySchema(context);

	// create records if needed
	maybeCreateGfmsWscSettings(context);

	// get the settings record and do something with it...
	coreSettings = context.getLocalPersistenceInterface().find(GfmsWscSettings.META, 0L);
	log.info("Broadcast Port: " + coreSettings.getBroadcastPort());

	// listen for updates to the settings record...
	GfmsWscSettings.META.addRecordListener(new IRecordListener<GfmsWscSettings>() {
		@Override
		public void recordUpdated(GfmsWscSettings gfmsWscSettings) {
			log.info("recordUpdated()");
		}

		@Override
		public void recordAdded(GfmsWscSettings gfmsWscSettings) {
			log.info("recordAdded()");
		}

		@Override
		public void recordDeleted(KeyValue keyValue) {
			log.info("recordDeleted()");
		}
	});

	//initialize our Gateway nav menu
	initMenu();

    super.setup(context);

	log.debug("Setup Complete.");
}

private void verifySchema(GatewayContext context) {
	try {
		context.getSchemaUpdater().updatePersistentRecords(GfmsWscSettings.META);
	} catch (SQLException e) {
		log.error("Error verifying persistent record schemas for WorkshopConnection records.", e);
	}
}

public void maybeCreateGfmsWscSettings(GatewayContext context) {
	log.trace("Attempting to create WorkshopConnection Settings Record");
	try {
		GfmsWscSettings settingsRecord = context.getLocalPersistenceInterface().createNew(GfmsWscSettings.META);
		settingsRecord.setId(0L);
		settingsRecord.setBroadcastPort(DEFAULT_BROADCAST_PORT);

        /*
		 * This doesn't override existing settings, only replaces it with these if we didn't
		 * exist already.
		 */
		context.getSchemaUpdater().ensureRecordExists(settingsRecord);
	} catch (Exception e) {
		log.error("Failed to establish GfmsWscSettings Record exists", e);
	}

	log.trace("WorkshopConnection Settings Record Established");
}

private void initMenu() {
    log.info("InitMenu Called");

    /* header is the top-level title in the gateway config page, e.g. System, Configuration, etc */
	LabelConfigMenuNode header = new LabelConfigMenuNode(HCON_MENU_PATH[0], "WorkshopConnection.nav.header");
	header.setPosition(801);

	context.getConfigMenuModel().addConfigMenuNode(null, header);

    /* Create the nodes/links that will exist under our parent nav header */
	LinkConfigMenuNode settingsNode = new LinkConfigMenuNode("settings",
			"WorkshopConnection.nav.settings.title",
			GfmsWscSettingsPage.class);

    /* register our nodes with the context config menu model */
	context.getConfigMenuModel().addConfigMenuNode(HCON_MENU_PATH, settingsNode);
}

@Override
public void startup(LicenseState licenseState) {
    log.info("Startup Called");

// if (workshopController == null) {
// workshopController = new WorkshopController();
// }
if (coreSettings == null) {
coreSettings = context.getLocalPersistenceInterface().find(GfmsWscSettings.META, 0L);
}
// workshopController.startInfoBroadcastListener(coreSettings.getBroadcastPort());

    super.startup(licenseState);

// context.getModuleServicesManager().subscribe(LegacyDeviceManager.class, this);
}

@Override
public void shutdown() {
    log.info("Shutdown Called");
    /* shutdown I/O */

// if (workshopController != null) {
// workshopController.shutdownWorkshop();
// }

    /* remove our bundle */
    BundleUtil.get().removeBundle("WorkshopConnection");

    /* remove our nodes from the menu */
    context.getConfigMenuModel().removeConfigMenuNode(HCON_MENU_PATH);

    ResourceBundle.clearCache();

    super.shutdown();
}

@Override
public void serviceReady(Class<?> serviceClass) {
    log.info("ServiceReady Called for " + serviceClass.toString());

// if (serviceClass == LegacyDeviceManager.class) {
// driverManager = (LegacyDeviceManagerDevice)context.getModuleServicesManager().getService(LegacyDeviceManager.class);
// }
}

@Override
protected List<DriverType> getDriverTypes() {
    log.info("GetDriverTypes Called");
    return DRIVER_TYPES;
}

@Override
protected int getExpectedAPIVersion() {
    log.info("GetExpectedAPIVersion Called");
    return 4;
}

}
[/code]

[code]public class InformationDriver extends AbstractDriver {
public final static int DEFAULT_BROADCAST_PORT = 49152;
private InformationDriverSettings driverSettings;
private WorkshopController workshopController;

private final List<Node> uaNodes = new ArrayList<Node>();
private final Map<String, BrowseNode> nodeMap = new HashMap<String, BrowseNode>();
private final Map<String, String> mappedAddresses = new HashMap<String, String>();

private static final String ROOT_NODE_ADDRESS = "ROOT";
private final FolderNode rootNode = new FolderNode(ROOT_NODE_ADDRESS);

private final Logger log = BuildLogger.getLog4JLogger(getClass());

public InformationDriver(DriverContext driverContext, InformationDriverSettings driverSettings) {
    super(driverContext);
    this.driverSettings = driverSettings;
    Integer port = driverSettings.getBroadcastPort();

    log.info("Adding Diagnostinc Tag");
    addDriverTag(new StaticDriverTag("[Diagnostics]/Port",
            DataType.UInt16,
            new DataValue(new Variant(new UInt16(port)))));
}

@Override
protected void connect() {
    log.info("Connect Called");
    if (workshopController == null) {
        workshopController = new WorkshopController();
    }

    log.info("Broadcast Port: " + driverSettings.getBroadcastPort());
    log.info("Starting Broadcast");
    workshopController.startInfoBroadcastListener(driverSettings.getBroadcastPort());
}

@Override
protected void disconnect() {
    log.info("Disconnect Called");
    /* shutdown I/O */
    if (workshopController != null) {
        log.info("Stopping Broadcast");
        workshopController.shutdownWorkshop();
    }
}

@Override
protected Request createBrowseRequest(BrowseOperation browseOp) {
    List<String> results = new ArrayList<String>();

    log.info("Creating Browse Request");

    String address = browseOp.getStartingAddress();
    if (address == null || address.isEmpty()) {
        address = ROOT_NODE_ADDRESS;
    }
    BrowseNode node = nodeMap.get(address);

    if (node != null) {
        Iterator<BrowseNode> iter = node.getChildren();
        while (iter.hasNext()) {
            results.add(iter.next().getAddress());
        }
    }

    browseOp.browseDone(StatusCode.GOOD, results, currentGuid());

    return null;
}

@Override
protected Object getRequestKey(Object o) {
    log.info("Getting Request Key");
    return null;
}

@Override
protected boolean isBrowsingSupported() {
    return true;
}

@Override
protected boolean isOfflineBrowsingSupported() {
    return true;
}

@Override
protected Request createWriteRequest(List list) {
    log.info("Creating Write Request");
    return null;
}

@Override
protected Request createReadRequest(List list) {
    log.info("Creating Read Request");
    return null;
}

@Override
protected List<List<? extends ReadItem>> optimizeRead(List list) {
    log.info("Optimized Read");
    return null;
}

@Override
public void buildNode(String s, NodeId nodeId) throws AddressNotFoundException {
    log.info("Building Node");
    DataType dataType = DataType.String;
    BrowseNode node = nodeMap.get(s);
    if (node == null) {
        node = new DataVariableNode(
                s,
                s,
                new DataValue(StatusCode.BAD),
                dataType);
        nodeMap.put(s, node);
    }
    if (node instanceof FolderNode) {
        Node uaNode = builderFactory.newObjectNodeBuilder()
                .setNodeId(nodeId)
                .setBrowseName(new QualifiedName(1, node.getDisplayName()))
                .setDisplayName(new LocalizedText(node.getDisplayName()))
                .setTypeDefinition(NodeIds.FolderType_ObjectType.getNodeId())
                .buildAndAdd(nodeManager);

        uaNodes.add(uaNode);
        return;
    } else if (node instanceof DataVariableNode) {
        Node uaNode = builderFactory.newVariableNodeBuilder()
                .setNodeId(nodeId)
                .setBrowseName(new QualifiedName(1, node.getDisplayName()))
                .setDisplayName(new LocalizedText(node.getDisplayName()))
                .setDataType(dataType.getNodeId())
                .setTypeDefinition(NodeIds.VariableNode_DataType.getNodeId())
                .setAccessLevel(getAccessLevel(""))
                .setUserAccessLevel(getAccessLevel(""))
                .buildAndAdd(nodeManager);

        uaNodes.add(uaNode);
    } else {
        throw new AddressNotFoundException(String.format("Address \"%s\" not found.", s));
    }
}

private EnumSet<AccessLevel> getAccessLevel(String address) {
    log.info("Getting Access Level");
    EnumSet<AccessLevel> accessLevel = EnumSet.of(AccessLevel.CurrentRead);

// ModbusTable table = address.getTable();
// if (table == ModbusTable.HoldingRegisters || table == ModbusTable.Coils) {
accessLevel.add(AccessLevel.CurrentWrite);
// }

    return accessLevel;
}

}
[/code]

[code]public class InformationDriverType extends DriverType {

public static final String TYPE_ID = "Information";

private final Logger log = BuildLogger.getLog4JLogger(getClass());

public InformationDriverType() {
    super(TYPE_ID, "WorkshopConnection." + "InformationDriverType.Name", "WorkshopConnection." + "InformationDriverType.Description");
}

@Override
public RecordMeta<? extends PersistentRecord> getSettingsRecordType() {
    log.info("GetSettingsRecordType Called");
    return InformationDriverSettings.META;
}

@Override
public ReferenceField<?> getSettingsRecordForeignKey() {
    log.info("GetSettingsRecordForeignKey Called");
    return InformationDriverSettings.DeviceSettings;
}

@Override
public List<LinkEntry> getLinks() {
    log.info("GetLinks Called");
    return Lists.newArrayList(new DiagnosticsLink());
}

@Override
public Driver createDriver(DriverContext driverContext, DeviceSettingsRecord deviceSettings) {
    log.info("CreateDriver Called");

    InformationDriverSettings settings = findProfileSettingsRecord(driverContext.getGatewayContext(), deviceSettings);

    log.info("Settings " + settings.toString());
    return new InformationDriver(driverContext, settings);
}

}
[/code]

You’re right, the documentation in the programmers guide is woefully out of date. I’ll see if I can get something better put together in time for Ignition 7.8 and the ICC.

In the meantime, the Modbus example is the best thing we’ve got as an example.

I’m also working on some Maven archetypes for generating Ignition SDK based projects, so that should help people get started a lot easier in the future.

1 Like

As for getting your driver to show up - is your log message indicating getDriverTypes() was called going to the console? Is the module installing and loading successfully? Are there any indications in the logs that something might be wrong?

Stopped and started Ignition after removing module… Then installed module, the following was logged in the console.

Time Logger Message (I) 10:59:14 AM PropertiesFactory Loading properties files from jar:file:/var/lib/ignition/temp/gateway-api7045946475098555988.jar!/com/inductiveautomation/ignition/gateway/web/components/DeveloperPanel.properties with loader org.apache.wicket.resource.IsoPropertiesFilePropertiesLoader@5eb026cc (I) 10:59:14 AM GatewayHook ServiceReady Called for interface com.inductiveautomation.xopc.driver.api.configuration.DriverManager (I) 10:59:14 AM GatewayHook GetExpectedAPIVersion Called (I) 10:59:14 AM GatewayHook Startup Called (I) 10:59:14 AM ModuleManager Starting up module 'com.mwes.gfms.wsc' v1.0.0 (b0)... (I) 10:59:14 AM GatewayHook GetDriverTypes Called (I) 10:59:14 AM GatewayHook InitMenu Called (I) 10:59:14 AM GatewayHook Broadcast Port: 49152 (I) 10:59:13 AM ModuleManager Starting up module 'com.mwes.gfms.wsc' (v1.0.0 (b0))... (I) 10:59:13 AM ModuleManager Installing module: "com.mwes.gfms.wsc"

And the Module Status page shows…

Workshop Connection Driver enable live values Running Module Details Description GF+ MS Workshop Connection API Driver Version 1.0.0 (b0) Activation Mode Trial

I think the fact you’ve overridden serviceReady() without calling super.serviceRead() inside is preventing the registration from taking place.

Thank you… I’m now able to create an instance of the device. I’ve got other things wrong, but this was the current stumbling block. In my defense, I just did what it said to do in 6.2.3 in the SDK Doc!

Should I keep posting to this thread with further questions/problems?

If it’s specific enough a question to create a topic for let’s do that, otherwise keep posting here.

In the thread https://inductiveautomation.com/forum/viewtopic.php?f=89&t=13875, a snippet is given which will allow us to create a driver settings record. How do we check by device name if a settings record already exists? Or, only create the new device, if doesn’t already exist?

I would have asked in that thread, but it was locked.

Is your intention to automatically add a device for the user?

Basically yes.

The device I’m interfacing with has a UDP status broadcast that basically will announce devices and tell me basic status information. I have the UDP device working and generating tags for the information being presented. Not sure I’m doing the tags correctly, but…

After a device is announced, I want to create at least one disabled device entry for that device using the name and serial number fields to define the device name. The user will need to enable the devices that they are interested in after they’ve been discovered. The reason for this is because the machines only allow one connection on the Supervisor and Control ports. So I only want the server to connect to the machine when asked to not automatically.

I’m presently debating about a device for the supervisor port and another device for the control port, or one device encompassing both ports. I don’t know, multiple devices configurations for one machine could be confusing.

The supervisor port is basically for requesting additional status information, and getting processing events pushed to me.

The control port is for me to load jobs, open and shut the robot door, and run jobs.

I have a library coded and somewhat tested that does the interaction, encoding and decoding of the XML to Java objects already written.

I seriously considered doing this with the Java OPC-UA server code I found on GitHub, with your name attached, but thought it would be better to do it withing Ignition, rather than adding more stuff to the solution.

Hmm. Okay.

You can check for the existence of a device name using the persistence interface, something like this:

String deviceName = "...";

DeviceSettingsRecord record = context.getLocalPersistenceInterface().queryOne(
        new SQuery<>(DeviceSettingsRecord.META).eq(DeviceSettingsRecord.Name, deviceName));

if the resulting record is null, there’s no device configured by that name.

The stuff I’m working on that you found on Github is a significant step forward in terms of implementation and API, but it’s not done yet, so it’s probably best you didn’t go that route. As far as that stuff goes, the client SDK should be in a non-SNAPSHOT state before or by Ignition 7.8 launch, and the server SDK sometime late this year, probably after testing it in Nuremberg at the interop in November.