Learning OPC UA with Mako Server
This tutorial builds the OPC UA Lua API in small steps. Each example runs with Mako Server and uses only local data, so no PLC, sensor, external OPC UA server, or hardware setup is required.
The goal is to learn the API shape:
create a server
add folders, variables, and methods to the address space
read and write values
connect a local client to the local server
Run the tutorial examples
The source files are in LSP-Examples/OPC-UA. From that directory, run:
mako tutorial/run_standalone.lua
The standalone runner executes the server-side examples that do not need a
long-running server process. It exits through mako.exit(). A non-zero
process exit means at least one example failed.
Run the PubSub tutorial examples
The PubSub examples use the optional Lua MQTT broker module. The easiest way to test them is to use the Mako Server mako.zip Developer Edition, which includes the broker. No external MQTT broker is required for these tutorial examples.
From the LSP-Examples/OPC-UA directory, run:
cd tutorial
mako run_pubsub.lua
The PubSub examples load shared tutorial code from
tutorial/.lua/pubsub_common.lua using mako.createloader(io) and
require(). They stop immediately and print an error message if the
mqttbroker module cannot be loaded. In that case, install the Developer
Edition mako.zip or copy the broker from MQTT-Broker.
1. Minimal server
Start with the smallest useful server. The server has one opc.tcp endpoint,
allows the None security policy, starts listening, and then shuts down.
local ua = require("opcua.api")
local config = {
endpoints = {
{
endpointUrl = "opc.tcp://localhost:4841"
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local server = ua.newServer(config)
server:initialize()
trace("Server initialized with one opc.tcp endpoint.")
server:run()
server:shutdown()
2. Add a folder
OPC UA data is organized in the address space. A common first step is to add a
folder under the standard Objects folder.
local ua = require("opcua.api")
local config = {
endpoints = {
{
endpointUrl = "opc.tcp://localhost:4842"
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local server = ua.newServer(config)
server:initialize()
local editor = server.model:edit()
local objects = editor:objectsFolder()
local tutorial = objects:addFolder("Tutorial")
tutorial.Attrs.Description = {
Text = "Folder used by the Mako Server learning examples"
}
editor:save()
trace("Added Tutorial folder under Objects.")
server:run()
server:shutdown()
3. Add variables
Variables expose values. This example adds two Double variables with stable
NodeIds so later examples can read and write them directly.
local ua = require("opcua.api")
local temperatureId = "ns=1;i=3001"
local setpointId = "ns=1;i=3002"
local config = {
endpoints = {
{
endpointUrl = "opc.tcp://localhost:4843"
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local server = ua.newServer(config)
server:initialize()
local editor = server.model:edit()
local tutorial = editor:objectsFolder():addFolder("Tutorial")
tutorial:addVariable("Temperature", {
Type = ua.VariantType.Double,
Value = 21.5
}, nil, temperatureId)
tutorial:addVariable("Setpoint", {
Type = ua.VariantType.Double,
Value = 24.0
}, nil, setpointId)
editor:save()
local resp = server:read({
{
NodeId = temperatureId,
AttributeId = ua.AttributeId.Value
}
})
assert(resp.Results[1].Value == 21.5)
trace("Added Temperature and Setpoint variables.")
server:run()
server:shutdown()
4. Write a variable
Server-side Lua can write a variable through the same service shape used by remote clients. This is useful when application logic updates the address space.
local ua = require("opcua.api")
local setpointId = "ns=1;i=4001"
local config = {
endpoints = {
{
endpointUrl = "opc.tcp://localhost:4844"
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local server = ua.newServer(config)
server:initialize()
local editor = server.model:edit()
local tutorial = editor:objectsFolder():addFolder("Tutorial")
tutorial:addVariable("Setpoint", {
Type = ua.VariantType.Double,
Value = 24.0
}, nil, setpointId)
editor:save()
local writeResp = server:write({
NodesToWrite = {
{
NodeId = setpointId,
AttributeId = ua.AttributeId.Value,
Value = {
Type = ua.VariantType.Double,
Value = 26.5,
StatusCode = ua.StatusCode.Good
}
}
}
})
assert(writeResp.Results[1] == ua.StatusCode.Good)
local readResp = server:read({
{
NodeId = setpointId,
AttributeId = ua.AttributeId.Value
}
})
assert(readResp.Results[1].Value == 26.5)
trace("Wrote Setpoint from server-side Lua.")
server:run()
server:shutdown()
5. Use a value callback
A value callback lets a variable read from and write to application state instead of storing only a fixed value in the model.
local ua = require("opcua.api")
local counterId = "ns=1;i=5001"
local counter = {
Type = ua.VariantType.UInt32,
Value = 0,
StatusCode = ua.StatusCode.Good
}
local config = {
endpoints = {
{
endpointUrl = "opc.tcp://localhost:4845"
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local server = ua.newServer(config)
server:initialize()
local editor = server.model:edit()
local tutorial = editor:objectsFolder():addFolder("Tutorial")
tutorial:addVariable("Counter", counter, nil, counterId)
editor:save()
server:setValueCallback(counterId, function(nodeId, newValue)
if newValue then
counter = newValue
return
end
counter.Value = counter.Value + 1
return counter
end)
local first = server:read({
{
NodeId = counterId,
AttributeId = ua.AttributeId.Value
}
})
local firstValue = first.Results[1].Value
local second = server:read({
{
NodeId = counterId,
AttributeId = ua.AttributeId.Value
}
})
assert(firstValue == 1)
assert(second.Results[1].Value == 2)
trace("Counter value is produced by a Lua callback.")
server:run()
server:shutdown()
6. Read with a local client
The client examples use a separate local learning server. Start it in one terminal:
mako -l::tutorial/learning_server
Then run the client scripts from another terminal. The first client example
opens a secure channel, creates a session, activates it, and reads one value.
Stop the learning server with Ctrl+C when you are done.
local ua = require("opcua.api")
local endpointUrl = "opc.tcp://localhost:4850"
local temperatureId = "ns=1;i=6001"
local setpointId = "ns=1;i=7001"
local controllerId = "ns=1;i=8001"
local methodId = "ns=1;i=8002"
local config = {
applicationName = "Mako learning server",
applicationUri = "urn:opcua-lua:mako-learning-server",
productUri = "urn:opcua-lua:mako-learning-server",
bufSize = 65536,
endpoints = {
{
listenPort = 4850,
listenAddress = "localhost",
endpointUrl = endpointUrl
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
},
userIdentityTokens = {
{
policyId = "anonymous",
tokenType = 0
}
}
}
local server = ua.newServer(config)
server:initialize()
local editor = server.model:edit()
local tutorial = editor:objectsFolder():addFolder("Tutorial")
tutorial:addVariable("Temperature", {
Type = ua.VariantType.Double,
Value = 21.5
}, nil, temperatureId)
tutorial:addVariable("Setpoint", {
Type = ua.VariantType.Double,
Value = 24.0
}, nil, setpointId)
local controller = tutorial:addObject("Controller", nil, controllerId)
local function scaleValue(objectId, calledMethodId, inputArguments)
local value = inputArguments[1].Value
local factor = inputArguments[2].Value
return {
{
Type = ua.VariantType.Double,
Value = value * factor
}
}
end
controller:addMethod(
"ScaleValue",
scaleValue,
{
{Name = "Value", DataType = ua.DataTypeId.Double},
{Name = "Factor", DataType = ua.DataTypeId.Double}
},
{
{Name = "Scaled", DataType = ua.DataTypeId.Double}
},
methodId
)
editor:save()
trace("Learning server ready at " .. endpointUrl)
server:run()
function onunload()
trace("Stopping learning server.")
server:shutdown()
end
local ua = require("opcua.api")
local endpointUrl = "opc.tcp://localhost:4850"
local temperatureId = "ns=1;i=6001"
local config = {
applicationName = "Mako learning client",
applicationUri = "urn:opcua-lua:mako-learning-client",
productUri = "urn:opcua-lua:mako-learning-client",
cosocketMode = false,
socketTimeout = 10000,
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local client = ua.newClient(config)
local err = client:connect(endpointUrl)
assert(err == nil, tostring(err))
local resp
resp, err = client:openSecureChannel(
120000,
ua.SecurityPolicy.None,
ua.MessageSecurityMode.None
)
assert(err == nil, tostring(err))
resp, err = client:createSession("mako_learning_read", 120000)
assert(err == nil, tostring(err))
resp, err = client:activateSession()
assert(err == nil, tostring(err))
resp, err = client:read({
NodesToRead = {
{
NodeId = temperatureId,
AttributeId = ua.AttributeId.Value
}
}
})
assert(err == nil, tostring(err))
assert(resp.Results[1].Value == 21.5)
trace("Client read Temperature from the learning server.")
client:closeSession()
client:disconnect()
7. Write with a local client
Writing from a client uses client:write() with one or more nodes to update.
The example verifies the result from the server side after the write completes.
local ua = require("opcua.api")
local endpointUrl = "opc.tcp://localhost:4850"
local setpointId = "ns=1;i=7001"
local config = {
applicationName = "Mako learning client",
applicationUri = "urn:opcua-lua:mako-learning-client",
productUri = "urn:opcua-lua:mako-learning-client",
cosocketMode = false,
socketTimeout = 10000,
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local client = ua.newClient(config)
local err = client:connect(endpointUrl)
assert(err == nil, tostring(err))
local resp
resp, err = client:openSecureChannel(
120000,
ua.SecurityPolicy.None,
ua.MessageSecurityMode.None
)
assert(err == nil, tostring(err))
resp, err = client:createSession("mako_learning_write", 120000)
assert(err == nil, tostring(err))
resp, err = client:activateSession()
assert(err == nil, tostring(err))
resp, err = client:write({
NodesToWrite = {
{
NodeId = setpointId,
AttributeId = ua.AttributeId.Value,
Value = {
Type = ua.VariantType.Double,
Value = 25.25,
StatusCode = ua.StatusCode.Good
}
}
}
})
assert(err == nil, tostring(err))
assert(resp.Results[1] == ua.StatusCode.Good)
resp, err = client:read({
NodesToRead = {
{
NodeId = setpointId,
AttributeId = ua.AttributeId.Value
}
}
})
assert(err == nil, tostring(err))
assert(resp.Results[1].Value == 25.25)
trace("Client wrote Setpoint on the learning server.")
client:closeSession()
client:disconnect()
8. Call a method
Methods are callable operations attached to objects. This example exposes
ScaleValue on a local Controller object and calls it from the client.
local ua = require("opcua.api")
local endpointUrl = "opc.tcp://localhost:4850"
local controllerId = "ns=1;i=8001"
local methodId = "ns=1;i=8002"
local config = {
applicationName = "Mako learning client",
applicationUri = "urn:opcua-lua:mako-learning-client",
productUri = "urn:opcua-lua:mako-learning-client",
cosocketMode = false,
socketTimeout = 10000,
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local client = ua.newClient(config)
local err = client:connect(endpointUrl)
assert(err == nil, tostring(err))
local resp
resp, err = client:openSecureChannel(
120000,
ua.SecurityPolicy.None,
ua.MessageSecurityMode.None
)
assert(err == nil, tostring(err))
resp, err = client:createSession("mako_learning_call", 120000)
assert(err == nil, tostring(err))
resp, err = client:activateSession()
assert(err == nil, tostring(err))
resp, err = client:call(controllerId, methodId, {
{Type = ua.VariantType.Double, Value = 10.0},
{Type = ua.VariantType.Double, Value = 2.5}
})
assert(err == nil, tostring(err))
assert(resp.Results[1].StatusCode == ua.StatusCode.Good)
assert(resp.Results[1].OutputArguments[1].Value == 25.0)
trace("Client called ScaleValue on the learning server.")
client:closeSession()
client:disconnect()
9. Define a structure type
OPC UA models can define custom data types. This example creates a small
MeasurementType structure and a variable type that uses it.
local ua = require("opcua.api")
local config = {
endpoints = {
{
endpointUrl = "opc.tcp://localhost:4849"
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
}
local server = ua.newServer(config)
server:initialize()
local editor = server.model:edit()
local measurementType = editor:addStructure("MeasurementType")
measurementType:addField("Temperature", ua.DataTypeId.Double, ua.ValueRank.Scalar)
measurementType:addField("Humidity", ua.DataTypeId.Double, ua.ValueRank.Scalar)
local measurementVariableType = editor:addVariableType(
"MeasurementVariableType",
nil,
measurementType
)
local tutorial = editor:objectsFolder():addFolder("Tutorial")
local measurement = tutorial:addVariable(
"Measurement",
nil,
measurementVariableType,
"ns=1;i=9001"
)
measurement.Attrs.Description = {
Text = "Structured measurement value"
}
editor:save()
local fields = measurementType:getFields()
assert(#fields == 2)
assert(fields[1].Name == "Temperature")
assert(fields[2].Name == "Humidity")
trace("Added a custom structure and a variable type that uses it.")
server:run()
server:shutdown()
Warning
The following PubSub examples require an MQTT broker. The easiest test setup is the Mako Server mako.zip Developer Edition, which includes the broker module used by these examples.
10. Publish JSON with MQTT PubSub
This example creates a local MQTT broker, connects an OPC UA MQTT publisher and subscriber through the broker’s in-process client API, publishes one JSON dataset message, and verifies that the subscriber decodes the OPC UA PubSub payload.
local ua = require("opcua.api")
local io = ba.openio("home")
mako.createloader(io)
local common = require("pubsub_common")
local brokerPort = 18891
local broker = common.createBroker(brokerPort)
local topic = "opcua/tutorial/json"
local transportProfileUri = ua.TranportProfileUri.MqttJson
local config = {
bufSize = 8192
}
local subscriber = ua.newMqttClient(config)
local received
common.connectLocal(subscriber, broker, transportProfileUri)
subscriber:subscribe(topic, function(message, err)
if err then
common.fail("Failed to decode MQTT JSON PubSub message: " .. tostring(err))
end
received = message
end)
ba.sleep(300)
local publisher = ua.newMqttClient(config)
local datasetId = publisher:createDataset({
{ name = "Temperature" }
})
common.connectLocal(publisher, broker, transportProfileUri)
publisher:setValue(datasetId, "Temperature", {
Type = ua.VariantType.Double,
Value = 21.5
})
publisher:publish(topic, "tutorial-json")
common.waitFor("JSON PubSub message", function() return received ~= nil end)
local value = received.Messages[1].Payload.Temperature
assert(value.Type == ua.VariantType.Double)
assert(value.Value == 21.5)
publisher:close()
subscriber:close()
broker:shutdown()
trace("JSON PubSub message received and decoded.")
11. Publish binary UADP with MQTT PubSub
The binary example uses the same broker setup but switches the transport profile to MQTT binary/UADP. It publishes two fields and verifies the decoded field indexes and values.
local ua = require("opcua.api")
local io = ba.openio("home")
mako.createloader(io)
local common = require("pubsub_common")
local brokerPort = 18892
local broker = common.createBroker(brokerPort)
local topic = "opcua/tutorial/uadp"
local transportProfileUri = ua.TranportProfileUri.MqttBinary
local config = {
bufSize = 8192
}
local subscriber = ua.newMqttClient(config)
local received
common.connectLocal(subscriber, broker, transportProfileUri)
subscriber:subscribe(topic, function(message, err)
if err then
common.fail("Failed to decode MQTT binary PubSub message: " .. tostring(err))
end
received = message
end)
ba.sleep(300)
local publisher = ua.newMqttClient(config)
local datasetId = publisher:createDataset({
{ name = "Speed" },
{ name = "State" }
})
common.connectLocal(publisher, broker, transportProfileUri)
publisher:setValue(datasetId, "Speed", {
Type = ua.VariantType.UInt32,
Value = 1200
})
publisher:setValue(datasetId, "State", {
Type = ua.VariantType.Boolean,
Value = true
})
publisher:publish(topic, "tutorial-binary")
common.waitFor("binary PubSub message", function() return received ~= nil end)
local fields = received.Messages[1].Fields
assert(fields[1].Index == 1)
assert(fields[1].Value.Type == ua.VariantType.UInt32)
assert(fields[1].Value.Value == 1200)
assert(fields[2].Index == 2)
assert(fields[2].Value.Type == ua.VariantType.Boolean)
assert(fields[2].Value.Value == true)
publisher:close()
subscriber:close()
broker:shutdown()
trace("Binary PubSub message received and decoded.")
12. Publish an OPC UA server node
The final PubSub example connects a publisher to an OPC UA server instance. A dataset field is bound to a server node, the server writes a new value, and the publisher sends the updated node value through MQTT PubSub.
local ua = require("opcua.api")
local io = ba.openio("home")
mako.createloader(io)
local common = require("pubsub_common")
local brokerPort = 18893
local broker = common.createBroker(brokerPort)
local topic = "opcua/tutorial/server-node"
local transportProfileUri = ua.TranportProfileUri.MqttJson
local variableId = "ns=1;s=TutorialLevel"
local config = {
bufSize = 8192
}
local server = ua.newServer({
endpoints = {
{
endpointUrl = "opc.tcp://localhost:4846"
}
},
securePolicies = {
{
securityPolicyUri = ua.SecurityPolicy.None
}
}
})
server:initialize()
local editor = server.model:edit()
local tutorial = editor:objectsFolder():addFolder("TutorialPubSub")
tutorial:addVariable("Level", {
Type = ua.VariantType.UInt32,
Value = 1
}, nil, variableId)
editor:save()
server:run()
local subscriber = ua.newMqttClient(config)
local received
common.connectLocal(subscriber, broker, transportProfileUri)
subscriber:subscribe(topic, function(message, err)
if err then
common.fail("Failed to decode MQTT server-node message: " .. tostring(err))
end
received = message
end)
ba.sleep(300)
local publisher = ua.newMqttClient(config, server)
local datasetId = publisher:createDataset({
{
nodeId = variableId,
name = "Level"
}
})
common.connectLocal(publisher, broker, transportProfileUri)
server:write({
NodesToWrite = {
{
NodeId = variableId,
AttributeId = ua.AttributeId.Value,
Value = {
Type = ua.VariantType.UInt32,
Value = 42
}
}
}
})
publisher:publish(topic, "tutorial-server-node")
common.waitFor("server-node PubSub message", function() return received ~= nil end)
local value = received.Messages[1].Payload.Level
assert(value.Type == ua.VariantType.UInt32)
assert(value.Value == 42)
publisher:close()
subscriber:close()
server:shutdown()
broker:shutdown()
trace("Server node value published through MQTT PubSub.")
More advanced PubSub examples
After the tutorial examples, see MQTT PubSub Examples for more compact PubSub API examples. They cover publishing server node changes, manual publishing without an OPC UA server, and subscribing to JSON and binary/UADP messages.
Next steps
After these examples, the reference pages are easier to read:
Address Space API for address-space editing
Client for client sessions and service calls
Server for server configuration and model setup
Publish Subscribe API for PubSub concepts and MQTT API details