Milo OPC-UA Learning

This thread is not really asking for help, but more of documenting study so that others might benefit from seeing it. If others who have experience with the milo repository can offer examples about how to do certain things with it that can be helpful, please do share. The first post here will be to show the setup being used for test and how to browse, read, and write and are based on the examples provided in the milo repo.

Test Device
This is my trusty setup of an ESP32 feather with a relay on top. When you get the relay there are pads on the bottom that allow soldering options for which pin will control the relay. For convenience, mine uses the same pin as LED_BUILTIN which is pin 13.

At this point the feather board I am using is a bit dated, if you wanted to buy one now I'd get this one that has USB-C and can be programmed in python, which if you have not tried it yet, is really awesome.

ESP32 Feather
Feather Relay

Here is the code that is on the board. To know which IP address is allocated, you need to have the serial print console up so you can see it on boot-up. If you happen to miss it, just push the reset button and it will come again.

Device Code
All that is going on here is connect to Wi-Fi and then look at a modbus register and set the relay to be whatever value is in there.

#include <WiFi.h>
#include <ModbusIP_ESP8266.h>

//Wifi Crednetials
char ssid[] = "";
char pass[] = "";

ModbusIP modbusTCPServer;

//Modbus coil mapping
const int modAddOnOff = 0X00;

//Program global variables
int onOff;    //0 = off, 1 = on

void setup() {
  Serial.begin(9600);
  while (!Serial);
  connectToWiFi();
  pinMode(LED_BUILTIN, OUTPUT);

//Set this device as a server (slave)
//Declare modbus coils and holding registers
  modbusTCPServer.slave(502);
  modbusTCPServer.addCoil(0x00, 0);
}

void loop() {
  modbusTCPServer.task();
  onOff = modbusTCPServer.Coil(modAddOnOff);
  digitalWrite(LED_BUILTIN, onOff);
}

void connectToWiFi(){
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, pass);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
    }
  Serial.println("");
  Serial.println("WiFi connected.");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
}

Python OPC-UA Test Server
As a crude example, there is a server running in python that manages one single coil and whenever the value in OPC-UA differs from what is in the device, the OPC-UA value is then written to the device. In that way, a simple way to write back to the device is achieved.

from pyModbusTCP.client import ModbusClient
from opcua import Server
from datetime import datetime as dt
import time
import pytz

scanrate = 0.25

# create modbus client connection
host = "ip address from arduino serial console"
port = 502
coilAddress = 0
client = ModbusClient(host=host, port=port, unit_id=1, auto_open=True)

# create OPC-UA server and add a tracked value node
server = Server()
url = "opc.tcp://127.0.0.1:4840"
server.set_endpoint(url)

name = "OPC UA Test Server"
address_space = server.register_namespace(name)

node = server.get_objects_node()
Param = node.add_object(address_space, "Devices")

coil = Param.add_variable(address_space, "RelayCoil", False)
coil.set_writable()

server.start()
print(f"Server started at {url}.")

while True:
    opcValue = coil.get_value()
    deviceValue = client.read_coils(coilAddress, 1)[0]
    if opcValue != deviceValue:
        client.write_single_coil(coilAddress, opcValue)
        coil.set_value(opcValue)
        now = dt.now(pytz.timezone('US/Central'))
        print(f"{now} - Coil value updated to {opcValue}")
    else:
        coil.set_value(deviceValue)
    time.sleep(scanrate)

Modify Parameters in the examples
For simplicity of testing, security is set to None in the client example. I'm not building anything serious here, simply trying to learn. Will make sure to cover security later on in this thread.

    default SecurityPolicy getSecurityPolicy() {
        return SecurityPolicy.None;
    }

The end point URL is also modified to be that of the python OPC-UA server:

   default String getEndpointUrl() {
        return "opc.tcp://127.0.0.1:4840";
    }

Lastly the serverRequired variable is set to False in the Client Example Runner

  public ClientExampleRunner(ClientExample clientExample) throws Exception {
        this(clientExample, false);
    }

How to browse and get Name Space Index and Identifier
In order to do read and write, you need to know the node ID of what you are attempting to work with. At first I setup a OPC-UA client in Ignition and tried to use the following for a read:

That does not work. After some experimentation with the browse example, I modified it to filter down only to the things I was interested in and also show the nameSpaceIndex and identifier like this:

Modified Browse Example

public class BrowseExample implements ClientExample {
    public static void main(String[] args) throws Exception {
        BrowseExample example = new BrowseExample();
        new ClientExampleRunner(example).run();
    }
    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void run(OpcUaClient client, CompletableFuture<OpcUaClient> future) throws Exception {
        client.connect().get(); // synchronous connect
        browseNode("", client, Identifiers.RootFolder); // start browsing at root folder
        future.complete(client);
    }

    private void browseNode(String indent, OpcUaClient client, NodeId browseRoot) {
        BrowseDescription browse = new BrowseDescription(
            browseRoot,
            BrowseDirection.Forward,
            Identifiers.References,
            true,
            uint(NodeClass.Object.getValue() | NodeClass.Variable.getValue()),
            uint(BrowseResultMask.All.getValue())
        );

        try {
            BrowseResult browseResult = client.browse(browse).get();
            List<ReferenceDescription> references = toList(browseResult.getReferences());

            for (ReferenceDescription rd : references) {
                String name = rd.getBrowseName().getName();
                UShort nsIndex = rd.getNodeId().getNamespaceIndex();
                String id = rd.getNodeId().getIdentifier().toString();
                ArrayList<String> filter = new ArrayList<>();
                filter.add("Objects");
                filter.add("Devices");
                filter.add("RelayCoil");
                
                if  ( filter.contains(name) ) {
                    logger.info("{} Node={}, ns={};s={}", indent, name, nsIndex, id);
                }
                rd.getNodeId().toNodeId(client.getNamespaceTable()) // recursively browse to children
                        .ifPresent(nodeId -> browseNode(indent + "  ", client, nodeId));
            }
        } catch (InterruptedException | ExecutionException e) {
            logger.error("Browsing nodeId={} failed: {}", browseRoot, e.getMessage(), e);
        }
    }
}


The output looks like this. Not only does it proved that we are talking to the server, it also provides the information needed to address to the data of interest, which is the RelayCoil.

Modified Read Example
Using the node address information from the browse, I then modified the read example slightly to just read the single coil. The NodeId.parse method is extremely helpful here.

public class ReadExample implements ClientExample {

    public static void main(String[] args) throws Exception {
        ReadExample example = new ReadExample();
        new ClientExampleRunner(example, false).run();
    }
    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void run(OpcUaClient client, CompletableFuture<OpcUaClient> future) throws Exception {
        client.connect().get();  // synchronous connect

        // asynchronous read request
        readCoil(client).thenAccept(values -> {
            DataValue coil = values.get(0);
            logger.info("Coil state: " + coil.getValue().getValue());
            future.complete(client);
        });
    }

    private CompletableFuture<List<DataValue>> readCoil(OpcUaClient client) {
        List<NodeId> nodeIds = ImmutableList.of (
                NodeId.parse("ns=2;i=2"));
        return client.readValues(0.0, TimestampsToReturn.Both, nodeIds);
    }
}

To note, this came after several days of trying but at this point I was finally able to get back an actual value, which looked like this:

Modified Write Example
To test things going back in the other direction, I modified the Write Example to this:

public class WriteExample implements ClientExample {

    public static void main(String[] args) throws Exception {
        WriteExample example = new WriteExample();
        new ClientExampleRunner(example).run();
    }
    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void run(OpcUaClient client, CompletableFuture<OpcUaClient> future) throws Exception {
        client.connect().get();
        List<NodeId> nodeIds = ImmutableList.of(NodeId.parse("ns=2;i=2"));

            Variant v = new Variant(true);
            DataValue dv = new DataValue(v, null, null);

            CompletableFuture<List<StatusCode>> f =
                client.writeValues(nodeIds, ImmutableList.of(dv));

            List<StatusCode> statusCodes = f.get();
            StatusCode status = statusCodes.get(0);

            if (status.isGood()) {
                logger.info("Wrote '{}' to nodeId={}", v, nodeIds.get(0));
            }
        future.complete(client);
    }
}

And the relay on the board magically clicked on!

Looking at how much code is in the repo, this is not even scratching the surface but for me it was a good start and I'll keep adding to it over time.

Cheers,

Nick

3 Likes