DLI DC3 User's Guide
1.15.15.0
External APIs

The controller can be accessed programmatically using a number of protocols and APIS, including:

  • the REST-like API, in the following variants:
    • regular HTTP,
    • CoAP over WebSockets over HTTP,
    • command-line via SSH,
  • JSON-RPC (over HTTP),
  • SNMP,
  • MQTT,
  • OPC UA (IEC 62541; package installation required),
  • Modbus/TCP,
  • UPnP.

Additionally, a Lua access library is provided for standalone scripts.

Common external API settings

Common external API settings

Each of the external APIs can be enabled separately. Command-line REST-like API, or the standalone Lua access library cannot be disabled, except by e.g. disabling SSH access.

HTTP APIs perform cross-site request forgery checking to make sure they are not called by a misguided browser without JavaScript, bypassing browser security checks (a custom header needs to be present in the requests). Browsers can normally issue GET and POST requests with URL-encoded or multipart content types; you can tick the corresponding "relax ... checks" checkboxes to skip the checks in cases where the method or content type indicates that the request couldn't have been sent by a browser without JavaScript.

REST-like API over HTTP

REST-like API demo

The REST-style API is based on the REST architectural style. It presents the state and configuration as an hierarchy of resources, and relies on HTTP to perform action signaling and content negotiation. Requests with different HTTP headers yield different representations of resources (e.g. plain text, HTML, JSON, etc.). A type description system is used to outline the object model.

Refer to the REST-style API reference for details.

REST-like API over CoAP over WebSockets

This REST-style API variant shares the object hierarchy with the plain HTTP variant; however, it relies on a WebSocket connection for transport and CoAP for action signaling and content negotiation. The only supported content type is JSON; the only supported content patch type is JSON patch. To use it, a WebSocket connection with protocol 'coap' must be made to the '/.well-known/coap' URL. From there, CoAP requests can be sent via binary WebSocket packets; the CoAP URL structure matches one of the regular REST-style API (in particular, the first Uri-Path segment is 'restapi', and the trailing segment must be empty for regular data requests).

CoAP Observe option is implemented; change notification stream qualities, e.g. update period, are URI-dependent. In practice, subscribing to individual objects (e.g. relays), not whole trees or individual fields, should yield best results.

REST API matrix URI fragment support requires distinguishing between "percent-encoded" (regular) and "non-percent-encoded" (special) variants of the sub-delims characters, which CoAP doesn't have out of the box. As a non-standard extension, Uri-Path segment characters prefixed with the ASCII 16 'DLE' (Data Link Escape) character are interpreted as their "non-percent-encoded" (special) counterparts, and are otherwise interpreted as "percent-encoded" (regular), when mapping a Uri-Path segment to the RFC3986 URI path fragment. This affects the following characters:

  • sub-delims: "!" "$" "&" "'" "(" ")" "*" "+" "," ";" "="
  • colon: ":"
  • at sign: "@"
  • percent sign: "%"

Note that the sequence 'DLE "%"' then stands for the non-percent-encoded '' sign, and thus must be followed by two hex digits, and result in a single "percent-encoded" (regular) byte.

Depth-limited queries are not implemented; however, you can use the matrix URI mechanism to extract just the keys of a container to browse the hierarchy shallowly.

REST-like API on the command line

This REST-style API variant shares the object hierarchy with the plain HTTP variant; however, it is usable from the command line, e.g. over SSH. The only supported (and implied) content type is JSON, and the set of CRUD operations is a bit different from the conventional REST-like API. Resources are identified by paths similar to HTTP REST API ones, but with the '/restapi/' part omitted, and needn't be terminated by a '/'; instead, retrieving description of a resource is performed using a designated verb. The API is accessed through the 'uom' command, which has several subcommands. The 'get' and 'set' subcommands correspond roughly to 'GET' and 'PUT' requests, except that 'set' is not to be used to create new resources:

uom get <uom/path>
uom set <uom/path> <value>

For example:

# uom get /relay/name

yields:

"DLI Controller"

Both the 'get' response and the 'set' argument are proper JSON values, so need to be properly quoted independently of shell's own quoting:

# uom set /relay/name '"Power Controller"'

The 'insert' subcommand is intended for collection element creation; it is different from a 'POST' request as its path argument must point to the element intended to be created, and different from a 'PUT' request as it shifts elements when inserting in the middle of an array:

uom insert <uom/path> <value>

Use a '-' instead of the last path element for specifying "the element position after the last one" (i.e. appending to an array):

uom insert config/links/- '{"href":"http://example.com/","description":"Example link"}'

The 'remove' subcommand is intended for collection element deletion; it is similar to a 'DELETE' request; use a '-' instead of the last path element for specifying "the last element's position" (i.e. deleting the last array element):

uom remove <uom/path>

The 'invoke' subcommand is intended for calling object methods; it is similar to a 'POST' request; however, the argument list is specified as a number of positional arguments (as is common in shell scripts) instead of a JSON array.

uom invoke <uom/path> [arg [arg ...]]

The 'describe' subcommand retrieves an element's type description; it corresponds to a 'GET' request with a trailing '/description' URI fragment.

uom describe <uom/path>

The 'keys' subcommand retrieves a JSON array corresponding to the current set of an element's keys.

uom keys <uom/path>

It can be used on arrays and objects alike:

# uom keys relay/outlets/
[0,1,2,3,4,5,6,7]

# uom keys meter/values
["relay_voltage","environment.temperature",...]

The 'dump' subcommand prints out a deep text representation of the element, possibly including read or iteration error indicators:

uom dump <uom/path>

The 'subscribe' subcommand prints the current state of the element, as if obtained via 'get', and waits for changes, printing them as they are received:

uom subscribe <uom/path>

It does not return unless an error reading the object occurs (e.g. if it's gone).

Depth-limited queries, as well as matrix URI fragments, are not implemented (the 'keys' subcommand can be used to perform depth-limited queries manually).

If you maintain nontrivial command-line scripts, you may want to store them under the /storage directory, which is reserved for the unit administrator and is persisted across upgrades.

Refer to the REST-style API reference for details.

Lua API access library

A library for accessing the object model in standalone Lua scripts (run by e.g. cron or otherwise configured via SSH, i.e. not by the scripting server) is provided. It shares the object hierarchy with the REST-like API; however, note that Lua conventions have array indices start with 1, not 0. The library is accessed from Lua via a require("uom") expression, e.g.

local uom=require("uom")

As usual in Lua, parentheses are optional.

Members of the uom table correspond to top-level object model entities (including uom.cred representing the credential structure); additionally, the following members are provided:

  • uom.null represents the JSON null, and is true in a boolean context; for convenience, however, simple object fields instead evaluate to Lua nil instead of null, and are therefore false in a boolean context;
  • uom.weak_key_table(), uom.weak_value_table() and uom.weak_key_value_table() functions construct tables with weak keys and/or values as if by using setmetatable({},{__mode="k"}), setmetatable({},{__mode="v"}) and setmetatable({},{__mode="kv"}), respectively; unlike the user scripting environment, system-level scripts run with setmetatable accessible, so the functions are provided mostly for consistency;
  • uom.pairs(object), uom.ipairs(object), uom.insert(object[,idx],value) and uom.remove(object[,idx]) should be used on object model members instead of pairs, ipairs, table.insert and table.remove, respectively, and have similar semantics (they work on regular tables fine as well; uom.remove doesn't return the removed element as that would make no sense in our case; additionally, uom.pairs and uom.ipairs may return nil values corresponding to nulls);
  • uom.timer(callback, timeout) and uom.periodic_timer(callback, interval) schedule a callback to be run, respectively, after timeout milliseconds elapse and each interval milliseconds; both return a callback that can be used to cancel the timer, or otherwise must be held to keep the timer active;
  • uom.publish(object,...) allows process-wide publishing of, and uom.subscribe(object,callback) subscribing to, messages for a topic identified by a particular Lua value, including object model members; callback receives all the arguments of the corresponding uom.publish in the same order, starting with the object; object model members emit messages on member changes; uom.subscribe returns a callback that can be used to cancel subscription, or otherwise must be held to keep the subscription active;
  • uom.dump is an alias for the `dump` library, a function for dumping structured values;
  • uom.json.encode and uom.json.decode utility functions perform JSON encoding and decoding of tables (but not object model entities; use uom.copy first);
  • uom.re.match(str,regexp) returns the result of matching the string str against the POSIX-style regular expression regexp, and uom.re.gmatch(str,regexp) returns a generator which can be iterated over all matches (if the pattern contains capturing groups, the matching result is multivalued, with each value representing the matching group or false if the match succeeds but group doesn't occur in it, so consider comparing result with nil for general match success checking);
  • uom.copy(obj), uom.equals(a,b) and uom.merge(to,from) utility functions perform deep copying, equality check and merging of tables (including object model entities);
  • uom.context(partial, [cred]) creates a new entity similar to the top-level uom object (containing top-level object model entities) but having a separate update queue; if partial is true, the resulting entities will be optimized for code retrieving only a subset of an entity's properties; additionally, such entities will not receive uom.subscribe() notifications for properties you haven't previously read or written; the optional cred argument, if supplied, must be a table representing the credential structure to be used;
  • uom.update fetches any pending changes to the object model into the local proxy representation (the default one, or the context created by uom.context() and supplied as the argument), and runs handlers for any changes you may have subscribed for using uom.subscribe;
  • uom.vacuum runs a cleanup step for unused object model proxies;
  • uom.run() starts running an event loop, which is useful if you have previously subscribed to messages or set up timers;
  • uom.stop_running(), often called from a subscription or timer callback, causes the active uom.run() to stop.

The following example scripts are meant to be saved as files to the power controller's filesystem and executed with the standalone lua interpreter. If you want to just enter them into the interpreter line by line (possibly interactively as a means of experimentation or convenient configuration tool), you'll need to keep your variables global (e.g. omit local) as each interpreter line has its own locals context.

For example, running the following script:

local uom=require("uom")
print(uom.relay.name)

yields:

DLI Controller

Setting simple values works the same way:

local uom=require("uom")
uom.relay.name="Power Controller"

Insertion and removal work as well:

local uom=require("uom")
uom.insert(uom.config.links,{href="http://example.com/",description="Example link"})
uom.remove(uom.config.links)

You can hold object model members in local variables:

local uom=require("uom")
local relay1=uom.relay.outlets[1]
relay1.state=true
relay1.state=false

Note that attempts to change a table after it has been assigned to an object model field or inserted into an object model container will not work as expected:

local uom=require("uom")
local link={href="http://example.com/",description="Example link"}
uom.insert(uom.config.links,link)
link.href="http://bad.example.com/" -- Don't do this

It is possible to partially dump objects you don't have full access to, for instance:

local uom=require"uom"
print(uom.dump(uom.auth.users[1]))

results in:

{
  is_admin = true,
  is_allowed = true,
  name = "admin",
  outlet_access = {
    true,
    true,
    true,
    true,
    true,
    true,
    true,
    true
  },
  password = <read failed>
}

and an appropriate access log entry.

Object model members send change notification messages on eligible fields (generally, those not being marked as volatile in their type descriptions). Change notifications contain arguments of the form key, value, key_adjustment. For example, the following script outputs the physical state of relay 1:

local uom = require("uom")
local count = 10
local subscription = uom.subscribe(
    uom.relay.outlets[1],
    function(o,key,value,key_adjustment)
        if key == "physical_state" then
            print(value and "ON" or "OFF")
            if count == 0 then
                print("Enough!")
                uom.stop_running()
            else
                count = count-1
            end
        end
    end
)
print(uom.relay.outlets[1].physical_state and "ON" or "OFF")
uom.run()

uom.json.decode produces, and uom.json.encode accepts a second argument which is the 'empty array table' consulted to resolve the Lua empty table encoding ambiguity (i.e. should a particular {} be encoded as a JSON array or an object). For instance,

local array={}
local object={}
print(uom.json.encode({array,object},{[array]=true}))

produces the output:

[[],{}]

and the script:

print(uom.dump({uom.json.decode("[[],{}]")}))

produces output similar to:

{
  {{},{}},
  {[value[1][1]] = true}
}

Note that uom.dump can produce meaningful output for tables with table keys and even key-recursive tables, but uom.json.encode, uom.copy, uom.equals and uom.merge only work with JSON-compatible tables (string or integer keys only).

The result of matching a string against a POSIX-style regular expression is nil if there is no match, the whole match string if there are no capture groups (delimited by (,)), or multiple strings, one for each capture group, e.g. uom.re.match("abc","[ab].[cde]") returns "abc", and uom.re.match("abcde","[ab].(c|d|e)(d|e|f)") returns two strings "c" and "d"; uom.re.gmatch enumerates non-overlapping matches only, so the code:

for v1,v2,v3 in uom.re.gmatch("abcde","([abc])([bcd])([cde])") do
    print(v1,v2,v3)
end

prints only:

a b c

uom.vacuum and uom.update may only be useful for long-running scripts which don't rely on uom.run (as the latter performs updates and cleanups automatically).

If you maintain nontrivial OS-level Lua scripts, you may want to store them under the /storage directory, which is reserved for the unit administrator and is persisted across upgrades.

Refer to the REST-style API reference for details.

JSON-RPC

JSON-RPC demo

JSON-RPC allows to access an object model similar to the one of the REST-like API, but in a different manner which may be more suitable for some integration environments. All composite objects are visible using JSON-RPC, with their field values accessible using "get" (with the field name in the argument) and "set" methods (with the field name and value as arguments). Additionally, containers support "add", "remove" and "list" methods. The "describe" method can be used to output a type description for the object (similar to the REST API "description" relative URI).

UPnP settings

UPnP settings

The unit's relays can be exposed via UPnP as devices with different profiles. The currently supported profile is a Belkin WeMo socket.

Additional XML information elements can be exposed in the discovery responses for relays under the device element, if enabled:

<root ...>
  ...
  <device ...>
    <dli:X_dli xmlns:dli="...">
      <dli:parentDevice>
        <dli:name>My Controller</dli:name>
        <dli:model>DC3</dli:model>
        <dli:serial>DC3...</dli:serial>
      </dli:parentDevice>
      <dli:positionInParent>
        <dli:index>5</dli:index>
      </dli:positionInParent>
    </dli:X_dli>
  </device>
</root>

The relay index is zero-based.

SNMP settings

SNMP (simple network management protocol) exposes the control variables as a set of hierarchical resources identified by object identifiers (OIDs). An object identifier is roughly a sequence of non-negative integers (called arcs), separated by dots ('.'). A leading dot may be used to emphasize that it's an absolute OID; however, all of the OIDs configurable in DC3 are absolute unless otherwise stated explicitly, and the leading dot is not needed, therefore, it's not supported.

SNMP OID subtree properties

SNMP v3 introduces a user-based security model, where a number of different users can exist whose requests can be signed, and possibly encrypted, and who can have different access rights to the OID tree.

The specified root OIDs and their children will be exposed over SNMP. All OIDs must be absolute but not preceded by a dot.

The root OIDs are actually treated as masks, indicating to set of roots to apply the permission to. In addition to the standard OID syntax, all but the first two arcs of an OID mask may contain:

  • an asterisk "*", which means that any value in this position will match, e.g. "1.2.*.1" will match both "1.2.1.1" and "1.2.100.1";
  • a dash-delimited range, e.g. "1.2.8.1-3" will match both "1.2.8.1" and "1.2.8.2";
  • a comma-separated list of arcs, possibly including ranges, e.g. "1.2.8,9" will match both "1.2.8" and "1.2.9", and "1.2.1,6-8" will match both "1.2.1" and "1.2.7".

This can be used to implement fine-grained access to states of individual relays (see below).

SNMP user table

The engine ID identifies the device, and plays an important role in SNMPv3, in particular in authentication and encryption. It will normally be autodetected by management software (SNMP clients), but you may save it for future reference (the default value is based on the device factory MAC address). You can even change it; however, if you do, all passwords for SNMPv3 users will be invalidated as they are stored in a localized form to improve security.

SNMP v1 and v2c do not have a notion of 'users'. Instead, a 'community string', acting as a shared secret, is transferred in requests in plain text. The following table allows to configure how community strings are mapped to the above users.

SNMP community-to-user mapping configuration

In this example, requests with the 'private' community string will be serviced as though they were made by the 'powerAdmin' user if they come from the 192.168.0.x subnet, and denied otherwise. Likewise, requests with the 'public' community string coming from the same subnet will be served as the 'powerReader' user.

SNMP extensions

The Net-SNMP agent included in DC3 has built-in support for several well-known MIBs, e.g. the System group (RFC 3418) with the root at 1.3.6.1.2.1.1, enabling access to which may be required for integrating with management stations. Its elements, e.g. system name, location and contact, are configured in Network settings section.

SNMP extension protocol

The agent can be extended using a protocol based on the Net-SNMP pass_persist, which is line-oriented. An extension must have a root OID indicating where and a corresponding executable file on the DC3's filesystem which will be invoked for handling accesses to the root OID. You don't need to know the details of the protocol if you intend to implement your extension using a Lua system-level script and the helper library.

The extension must listen to commands from standard input and output results on standard output. The stock Net-SNMP commands are:

  • PING, to which a PONG response is expected;
  • get, followed by the OID to retrieve;
  • getnext, followed by the OID lexicographically preceding the one to retrieve;
  • set, followed by the OID to write to, and a string with the type and value to write;
  • an empty line, which indicates a graceful shutdown request.

Additionally, the following commands are DLI extensions:

  • TRAITS trait_mask, to which a TRAITS trait_mask response is expected; the integer trait bit mask corresponds to protocol features that are to be enabled (the extension needs to respond with the traits it requires or supports);
  • PING[ origin], an extension of PING where the optional origin indicates the origin of the request(s) that will follow.

The defined trait bits are:

  • bit 0: dynamic trait negotiation (support for the TRAITS command in particular);
  • bit 1: early startup (the extension needs to be started ahead of time, before the first request to the OID tree it's responsible for);
  • bit 2: transport information (the PING requests sent by the server need )

Other trait mask bits are reserved and should be zero.

get and getnext expect a successful response to contain 3 lines: the OID retrieved, the name of the type, and the value. On error, the string NONE should be returned (regardless of the error). set expects an empty line on success, and one of the Net-SNMP error strings on error.

Request OIDs may have a leading dot (.). Consult the Net-SNMP manual for more details.

SNMP extension Lua library

The above protocol is language-neutral, but some parts (e.g. the search for the lexicographically next OID) may be tricky to implement. To help implementations, a snmp_pass_persist Lua library for system-level scripts is provided. It supports serving an OID tree (in the shape of nested Lua tables) using its make_server function.

A table which contains a truthy has_value field indicates an OID which can be accessed (as opposed to tables which simply establish the hierarchy). Such a table must contain a value field which will be read on get/getnext accesses and written on set accesses. Any table in the tree may be dynamic by virtue of having a metatable, which enables custom actions to be performed on the reads and writes (see the Lua manual for further details); it is expected that has_value remains constant throughout the lifecycle of the tree. For convenience, the object function of the library provides convenience facilities for value OID construction:

  • object(value[, set_or_true]) creates an table representing an OID which has the supplied value;
  • object(fn[, set]) creates an table representing an OID the value of which is returned by supplied the function;
  • object({base, field}[, set_or_true]) creates an table representing an OID the value of which is base[field].

The optional set argument in the above enables write support for the OID and can be a function which is called with the value to write to the OID or a table {base, field}, in which case the write will result in an assignment to base[field]. The set_or_true argument can have, in addition to the values supported by the set argument, the special value true which enables default write support implementation depending on the first argument:

  • object({base, field}, true) means writes will result in assignment to the same base[field] expression from which read results are obtained;
  • object(value, true) means writes will result in assignment to the internal slot in which value is stored.

All SNMP values must have string (string if it's printable ASCII, octal otherwise) or integer (integer or unsigned depending on the value) types; additionally, explicit types are supported by supplying a table {type, value_repr} where type is the SNMP type string, and value_repr is its integer or string representation. Any conflict between the {base, field} and {type, value_repr} in the arguments is resolved as type must always be a string, while base can never be one.

The special nil value is supported and treated as (possibly transient) absence of an object; it returns NONE on get queries, is skipped on getnext queries, but can be set. Changing a non-nil value to a nil one and vice versa does not count as a structural OID tree change and does not require you to invalidate the lookup cache if you choose to enable it (see below).

snmp_pass_persist.make_server(oid_tree[, properties]) sets up the pass_persist protocol and returns a table which can be used to further manipulate the server. Note that you don't "start" the server on its own; after you've created it, actual processing happens inside the event loop, so you need to use snmp_pass_persist.run and snmp_pass_persist.stop_running which are convenient aliases to uom.run and uom.stop_running (see the Lua library description above).

local snmp_pass_persist = require("snmp_pass_persist")
...
local server
server = snmp_pass_persist.make_server({...tree...}, {...props...})
-- You may configure OID tree caching here, e.g.
-- server.enable_cache()
snmp_pass_persist.run()
server.shutdown()

The properties argument, if supplied, must be a table, and can be used to supply the following properties of the server:

  • "early_startup": a flag indicating whether Net-SNMP should start running the extension as early as possible instead of delaying its startup until a request for a matching OID;
  • "on_eof": the function to invoke when Net-SNMP signals shutdown to the handler; you will likely need it to be snmp_pass_persist.stop_running in most cases, so that's the default;
  • "on_request_origin": the function to invoke when Net-SNMP signals the origin of the following request(s) to the handler; you may need to perform permission checks on it and/or include it in logs.

The local server line on its own brings the server variable into scope, which is useful for manipulation by functions in the OID tree if you choose to place them there. The following member functions are provided on make_server results:

  • server.enable_cache() and server.disable_cache() enable and disable OID tree lookup caching, respectively; caching is disabled by default, but can improve performance; as long as you don't change the structure of the OID tree (i.e. each OID maps to one and the same object at all times), results remain valid;
  • server.invalidate_cache() must be used after changing the OID tree if you have enabled caching;
  • server.shutdown() shuts down the server.

Due to how the pass_persist protocol works, you may call make_server only once (claiming stdin and stdout), but nothing prevents you from modifying the OID tree that you supply to it, or making it dynamic using metamethods.

By default, an extension exposing the power-related objects using ENERGY-OBJECT-MIB is enabled.

SNMP energy object MIB support overview

The power-control-related ENERGY-OBJECT-MIB is described in RFC 7460, and is supported in the following manner:

  • the root of the OID tree is at 1.3.6.1.2.1.229 as per RFC;
  • the objects are relays, with indices starting at 1 ;
  • the current actual power states are indicated in the eoPowerOperState (1.3.6.1.2.1.229.1.2.1.9) table;
  • the assigned (expected) power states can be manipulated in the eoPowerAdminState (1.3.6.1.2.1.229.1.2.1.8) table;
  • supported power states are ieee1621Off (257) and ieee1621On (259) only

Additionally, among others, the following potentially useful parts of the above MIB are implemented:

  • eoPowerStateTotalTime;
  • eoPowerStateEnterCount.

These accumulate relay state statistics. Note that those don't persist across device reboots.

The following parts of the above MIB are NOT implemented:

  • eoEnergyParametersTable;
  • eoEnergyTable;
  • eoMeterCapabilitiesTable.

The following related MIBs are NOT supported:

  • ENTITY-MIB;
  • ENERGY-OBJECT-CONTEXT-MIB.

Additionally, modifying the user permissions via SNMP is NOT supported as they are generated from the configuration described above and the process is not easily reversible.

In the default configuration, the security level for accessing the energy object MIB subtree is high. You can set the access level to 'Minimal' to interact with the device using SNMPv2c and SNMPv1, or use SNMPv3 instead, which is the recommended and more secure alternative.

SNMP sample commands

These examples assume you have your DC3 at 192.168.0.100 with SNMPv3 user powerAdmin configured with SHA1 for authentication and AES for encryption, with password powerAdminPassword for both authentication and encryption. Requests with the private community string are assumed to be serviced as though they were made by the powerAdmin user.

You'll need Net-SNMP to run these samples; analogous commands should be available for other management software. The matching of requests vs SNMP protocol version is really arbitrary and is only used to demonstrate different ways of performing requests. Lines are broken using \\ for readability. We use -On to force numeric OID output, and omit the leading '.' in output OIDs for simplicity.

An SNMPv2 SET to turn relay #3 on:

$ snmpset -On -v 2c -c private 192.168.0.100 1.3.6.1.2.1.229.1.2.1.8.3 i 259

Output:

1.3.6.1.2.1.229.1.2.1.8.3 = INTEGER: 259

An SNMPv3 GET to get relay #5 status:

$ snmpget -On -v 3 -u powerAdmin -l authPriv -a SHA -x AES \
-A powerAdminPassword -X powerAdminPassword 192.168.0.100 \
1.3.6.1.2.1.229.1.2.1.9.5

Output:

1.3.6.1.2.1.229.1.2.1.9.5 = INTEGER: 257

257 is ieee1621Off, so now you know the relay is physically off.

An SNMPv3 SET to turn relay #5 on:

$ snmpset -On -v 3 -u powerAdmin -l authPriv -a SHA -x AES \
-A powerAdminPassword -X powerAdminPassword 192.168.0.100 \
1.3.6.1.2.1.229.1.2.1.8.5 i 259

Output:

1.3.6.1.2.1.229.1.2.1.8.5 = INTEGER: 259

Using SNMPv1 to enumerate the actual power states table:

$ snmpwalk -On -v 1 \
-c private \
192.168.0.100 \
1.3.6.1.2.1.229.1.2.1.9

Output:

1.3.6.1.2.1.229.1.2.1.9.1 = INTEGER: 257
1.3.6.1.2.1.229.1.2.1.9.2 = INTEGER: 257
1.3.6.1.2.1.229.1.2.1.9.3 = INTEGER: 259
1.3.6.1.2.1.229.1.2.1.9.4 = INTEGER: 257
1.3.6.1.2.1.229.1.2.1.9.5 = INTEGER: 259
1.3.6.1.2.1.229.1.2.1.9.6 = INTEGER: 257
1.3.6.1.2.1.229.1.2.1.9.7 = INTEGER: 257
1.3.6.1.2.1.229.1.2.1.9.8 = INTEGER: 257

You see that relays 3 and 5 are on, and all others are off ; you can change relay states as described above.

Note that the SNMP utilities mentioned are available as packages.

MQTT API

MQTT is an event-oriented protocol with a centralized publish/subscribe model, which makes it a bit awkward to use for controlling devices; however, an implementation is included due to its popularity.

MQTT client settings

General MQTT settings

DC3 can function as an MQTT client, so you need to have a configured MQTT broker which it could connect to; then, other MQTT clients connected to the same broker could communicate with it (multi-broker configurations are also possible but out of scope of this document). SSL and username/password authentication are supported (leave empty to disable). The default port is 8883 when SSL is enabled, and 1883 otherwise.

Setting and reporting relay state are performed by means of MQTT messages. MQTT messages carry topic markers to identify their type. The topics are arranged in a '/'-separated hierarchy.

The 'Topic root' setting allows to prepend a common string to topics related to all relays, e.g. to group messages related to the same controller. It is advisable that you set a different topic root for every controller that you connect to the same MQTT broker; otherwise, you'll get collisions unless you set different topic subtrees for their relays.

MQTT messages may have different QoS (quality of service) levels, which affect their handling by the broker:

  • "At most once" — no delivery guarantee or retransmission attempts;
  • "At least once" — acknowledgment is required, but the receiver may receive several copies of the message;
  • "Exactly once" — care is taken to make sure receiver receives exactly one copy of each message (highest overhead).

The following connection-related messages can be configured (i.e. have their topic, payload, QoS and retain bit set):

  • the connection message is sent after the client has connected to the broker and sent the initial relay state data, if any;
  • the reconnection message is sent after the client has reconnected to the broker after an unintentional disconnection (e.g. network outage), and sent the initial relay state data, if any (distinction between this and the above initial connection message may be used to check continuous connectivity over a period of time);
  • the disconnection message is sent before the client starts an intentional disconnection (in particular, before relay data clearing is performed), e.g. when MQTT client is disabled or the broker address is changed;
  • the Last Will and Testament is a message that will be sent by the broker to notify you if the DC3 unexpectedly goes offline.

Connection-related messages with an empty topic are not sent. Note that the topics of these messages are not prefixed by the topic root.

Changes to the connection, reconnection and disconnection message properties start affecting corresponding events immediately after the change; such changes do not by themselves cause changes to connection status. Unlike them, changes to the Last Will and Testament only take effect on the next (re)connection, i.e. if you have a connection active, change the Last Will and Testament and interrupt that connection, the old message will be sent in that particular occasion, but subsequent unexpected disconnections will result in the new Last Will and Testament sent. It may be useful to disconnect from the broker manually (e.g. by disabling MQTT) before changing connection-related message properties.

MQTT relay bindings

MQTT relay bindings

Every relay can be mapped to an MQTT topic subtree, which will be prefixed by the topic root and '/'; the outlet will report its state with a message with that topic if 'Allow read' is checked and honor requests to change its state if it receives a message with that topic suffixed by '/set' and 'Allow write' is checked. The quality of service determines the mode of delivery to request from the broker for messages on reporting relay state; not all brokers may support all delivery modes; the default should be sufficient for most purposes.

Note that there is no explicit way to request the states of relays. MQTT brokers are expected to keep track of the most recent payload of the outlet state topics, since the related messages have the retain bit set. Conversely, outlet state setting messages (ones with the '/set' topic suffix) must be sent by clients with the retain bit cleared: the API treats any such messages with the retain bit set as illegal and ignores them, since they could have been sent long ago and the current outlet state might have been overridden by other means since then.

MQTT payload formats

MQTT payload format is not defined by a standard, so we explicitly define it here. Everything that can be controlled via MQTT in DC3 is an relay state, which can be ON or OFF. We encode ON as '1' (the single ASCII character '1') and OFF as '0'. For compatibility, in addition to decoding '1' as ON and '0' as OFF, we accept '\0' (the ASCII NUL character) and the strings 'off' and 'false' as OFF, and '\1' (the ASCII SOH character) and the strings 'on' and 'true' as ON. Additionally, the empty string '' is used to indicate topic erasure (on topic changes or read access revocation).

MQTT sample commands

These examples assume you have an MQTT broker at 192.168.0.5, it doesn't require authentication and you have the DC3 set up like shown on screenshots above.

You'll need the mosquitto MQTT client to run these samples; analogous commands should be available for other clients. mosquitto also has an MQTT broker implementation.

If you run the command:

mosquitto_sub -h 192.168.0.5 -C 1 -t pcr12345/outlets/0

it'll print the current state of the first relay as known to the broker (as '0' or '1') and exit. The '-C 1' flags disable waiting for more state changes; if you run the command with them removed:

mosquitto_sub -h 192.168.0.5 -t pcr12345/outlets/0

you'll see the current state, but the program will wait for more state messages and print the states as they arrive; if you flip the relay, you'll see output like this:

0
1
0
1

etc.

To change the state of the first relay, use the mosquitto_pub command, e.g. to switch it on use:

mosquitto_pub -h 192.168.0.5 -t pcr12345/outlets/0/set -m 1

and to switch it off use:

mosquitto_pub -h 192.168.0.5 -t pcr12345/outlets/0/set -m 0

As mentioned above, alternate value forms, e.g.

mosquitto_pub -h 192.168.0.5 -t pcr12345/outlets/0/set -m true
mosquitto_pub -h 192.168.0.5 -t pcr12345/outlets/0/set -m on

will also work as expected.

Note that mosquitto broker and client are available as packages.

OPC UA (IEC 62541)

OPC UA (IEC 62541) enables communication and process control using a rich data model. Due to its great flexibility, providing a single server that would fit everyone is problematic. Instead, OS-level Lua scripts can be used to serve arbitrary OPC UA node collections if additional packages are installed: lua-opcua and one of the libopen62541-* packages. Sample scripts are provided for convenience.

Installation

The packages to install depend on the desired features and compliance level.

OPC UA organizes information in a collection of nodes; the system node collection, named NS0 or namespace zero, contains the standard types, objects, etc. Users are expected to use namespaces 1, 2, etc. for their own nodes. Having a more complete version of NS0 might affect the set of types available for user nodes, but also the library loading and server startup times as well as the RAM and flash footprints. The following options are available:

  • libopen62541-full-* packages contain the full namespace zero;
  • libopen62541-reduced-* packages contain a small namespace zero still passing conformance tests;
  • libopen62541-minimal-* packages contain a barebones namespace zero;
  • libopen62541-smallest-* packages contain a barebones namespace zero and has reduced functionality (discovery, error diagnostic strings, node descriptions, subscription support, etc. are elided).

Secure transport (TLS) support is also optional:

  • libopen62541-*-openssl packages provide secure connections using OpenSSL;
  • libopen62541-*-nossl packages do not provide secure connections.

TLS-enabled variants typically take up 20KB more flash than non-TLS-enabled ones.

If you install lua-opcua on its own, it will pull in libopen62541-smallest-openssl, so install a different libopen62541-* variant first if necessary.

Usage

You need to write and run an OS-level Lua script in order to create and serve an OPC UA node collection. Here's a minimal server, serving just the NS0:

-- Import the library
local ua = require("opcua")
-- Create the server
local server = ua.make_server()
-- Run the event loop
ua.run()

ua.make_server([configuration]) creates and returns a server object, to which nodes can be further added. It has an optional configuration argument, which, if specified, must be a Lua table. Supplying an explicit configuration is necessary to set the URI of user namespace(s). See the section on configuration for details.

ua.run and ua.stop_running are convenient aliases to service.run and service.stop_running (see the Lua service library description above).

The ua.dump() function is a convenience wrapper around the `dump` library, having similar semantics but with defaults specialized to handle various OPC UA objects. It is useful for finding your way through the data model.

Node identifier mapping

OPC UA nodes have identifiers which come in various shapes; they possibly contain references to other servers on which the node is located; in general, all of them have a namespace associated with them (specified by a URI or an integer index of a namespace known to the server). In general, any OPC UA node ID can be denoted by a string in the standard format. The identifier proper has one of the following types:

  • integer: i=<decimal integer>;
  • (printable) string: s=<string>;
  • GUID: g=<guid>;
  • opaque (binary): b=<base64-encoded byte string>.

As an extension to the above syntax, r=<raw byte string> is supported as input, and denotes an opaque (binary) key without the base64 encoding; it is enabled by the fact that Lua strings can contain arbitrary bytes including NUL; such node IDs are accepted but never generated (the b=<base64> form is used instead).

The namespace denoted by the integer index 0 is special, known as NS0, and is specified by the standard so that its members are well-known and can be relied upon to have the same meaning on all servers. The default user namespace has the index 1. Members of these namespaces receive special handling. String-formatted NS0 members omit the namespace URI and the index (as per standard OPC UA practice). Additionally, for performance, integer user namespace 1 node IDs (which are the default kind of IDs to be assigned to user objects) are naturally mapped to Lua integers; NS0 integer node IDs are mapped to a range of Lua light userdata values; the functions ua.ns0.encode_numeric_id(int_ns0_node_id) and ua.ns0.decode_numeric_id(userdata_ns0_node_id) provide the mapping.

Constant collections

The following constants are provided:

  • ua.null is an alias for uom.null; it is handled distinctly from Lua nil and has special meaning in certain contexts, e.g. denotes the null qualified name if used as a browseName or a relative path element, the null node ID if used as a nodeId, an empty variant if used as a variant value, etc.;
  • ua.undefined_array is a special value denoting an array of undefined length (corresponding to length -1 in binary encoding);
  • ua.const contains constant tables for various values, in particular:
    • ua.const.node_class: node class values (OBJECT, VARIABLE, METHOD, etc.);
    • ua.const.attribute_id: attribute identifier index values (NODEID, BROWSENAME, etc.).

You can use ua.dump to inspect the tables in further detail. It recognizes the NS0 integer IDs and special OPC-UA-related constants.

For example, the following OS-level script will dump the contents of NS0, which is useful when comparing different libopen62541 package variants:

-- Import the library
local ua = require("opcua")
-- Create the server
local server = ua.make_server()
-- Create a proxy to the root object of the server
local root = ua.make_proxy(server)
-- Dump the proxy
ua.dump(root, print)

This will generate a lot of output; to dump just a subset of the objects, pass a proxy of the subtree to dump:

ua.dump(root.Objects.Server.ServerStatus, print)

See the proxy subsection for details on proxies.

The generated dumps are expected to be valid Lua code which you can use as a template for creating your own OPC UA nodes via the proxy API; however, many fields are optional as they can be inferred from context or autogenerated, so don't let the amount of output scare you.

Variant value mapping

OPC UA nodes have attributes corresponding to the class of the node; those attributes contain typed data. Data types have a common base, BaseDataType, also known as Variant, which can represent data of any type. The various kinds of data items are mapped to different Lua structures; the same value may have different interpretation depending on the context.

Convenience mapping

In absence of a context restricting the data type, Lua values correspond to the following OPC UA values:

  • ua.null corresponds to the empty variant;
  • true and false correspond to the respective Boolean values;
  • numbers correspond to Double values;
  • strings correspond to String values;
  • plain (metatable-less) tables of shape {string, string} correspond to LocalizedText values (the first string is the locale ID, the second one is the text itself);
  • plain tables of shape {number, string} correspond to QualifiedName values (the number is the namespace index, the string is the name itself);
  • plain tables of shape {ns0_userdata|number|string, string, number} correspond to ExpandedNodeId values (the first member is the node ID, the second is the namespace URI, and the third is the server index).

For other cases, there exists a fully unambiguous representation.

Type-qualified mapping

A type-qualified value is a plain Lua table that contains the ua.data_type entry holding the node ID of the type of the contained value(s). If the table also contains a ua.array_dimensions entry (even with a false value), the value represents an array (regardless of what the ua.array_dimensions value says, the array contains exactly the sequence components of the table, which must all conform to the common ua.data_type value); otherwise, it's a scalar (and the value is in the table item with index 1).

Other Lua values are not considered to represent OPC UA values in absence of a context, and attempts to pass them to OPC UA will cause an error. Note that OPC UA arrays must always be represented in the fully unambiguous representation (a plain table with ua.data_type and ua.array_dimensions keys).

Implied mapping

Some contexts put restrictions on the type of data that can be passed, e.g. node attributes have predefined (or dynamic, in the case of the VALUE attribute) data types. This enables additional Lua value types to be parsed in a natural way:

  • in contexts where a concrete Number or Enumeration subtype or a StatusCode is expected (e.g. an Int32), Lua numbers that are within the target type's range are accepted;
  • in contexts where a QualifiedName is expected, Lua strings are accepted as the name part, with the namespace implicitly being 0;
  • in contexts where a LocalizedText is expected, Lua strings are accepted as the text part, with the locale implicitly being en-US;
  • in contexts where a ByteString is expected, Lua strings are accepted (in raw form; note that Lua strings can contain arbitrary byte sequences);
  • in contexts where a Guid is expected, Lua strings are accepted (in the common format, ‘'01234567-0123-4567-abcd-0123456789ab’`).

Datetime type

OPC UA timestamps are presented as numbers. Unlike UNIX time, which counts seconds since January 1, 1970, OPC UA timestamps count 100-nanosecond intervals since January 1, 1601 (also known as Windows FILETIME time). Functions ua.datetime_from_unix_time() and ua.datetime_to_unix_time() are provided to convert between the two representations. Additionally, in a context where a Datetime is expected, strings in the format ‘'YYYY-mm-ddTHH:MM:SS[.frac]Z’` are accepted.

OPC UA modelling using proxies

New OPC UA nodes can be created by interacting with the server created by ua.make_server(). The convenient way to do that is using proxy objects corresponding to existing OPC UA nodes.

Proxy objects are Lua userdata carrying server connection, node ID and implicit namespace information. Proxies are created using ua.make_proxy(server[, nodeId]) calls or indexing existing proxies.

Node attributes are accessed using ua.const.attribute_id.* keys (which are internally integers). It is common to bind Lua locals to corresponding ua.const.attribute_id.* elements, e.g.:

local ua = require("opcua")
local VALUE = ua.const.attribute_id.VALUE
local server = ua.make_server()
local root = ua.make_proxy(server) -- this is a proxy to the root object
-- This is a proxy to the current time object:
local currentTime = root.Objects.Server.ServerStatus.CurrentTime
print(ua.datetime_to_unix_time(currentTime[VALUE]))

The code prints the VALUE attribute of the current time object, converting it to a UNIX timestamp. Writing to node attributes is done similarly, e.g. proxy[VALUE] = 5.

Proxies also support access to child nodes by means of string keys or keys of the special {namespaceIdx, name} shape; in both cases, the keys signify browseNames, but in the former case, the string is the name part, and a special default namespace index stored in the proxy object itself is used.

Every time you index a proxy with a browseName-like key and the appropriate child node exists, a new proxy is created; all such proxy objects are distinct and do not compare equal; for instance, if you have:

local serverStatus = root.Objects.Server.ServerStatus
local currentTime1 = serverStatus.CurrentTime
local currentTime2 = serverStatus.CurrentTime

then currentTime1 ~= currentTime2 even though they refer to the same node. Proxy objects have internal properties other than the node ID.

The default namespace index of a proxy object has some rules which simplify natural browseName hierarchies with 'sticky' namespaces. For a new root proxy object it's 0. For a proxy object created using a browseName index expression, it's the one of the browseName, if it's a {namespaceIdx, name} table, or one of the indexed proxy, if the browseName index was a string AND the indexed node itself was not a Method, and 0 in the remaining case of indexing a Method with a string browseName index. The Method exception is there as there is usually no reason to create user-namespaced child nodes on Methods, but there is often a need to specify the special InputArguments and OutputArguments child nodes which are in NS0. The same logic applies to node templates used in node creation. The net result is that you usually only need to specify a browseName a limited number of times, when creating parent nodes of your custom hierarchies.

To create a new node (or a whole subtree), you need to assign a node template to an index expression of the form parentNodeProxy[browseName]. You usually want to create nodes in NS1, the first user namespace, not in NS0, where parentNodeProxy will often reside. Therefore, you'll need to use an index of the form {1, name}. A node template is a Lua structure which specifies necessary node properties and possibly child nodes. Its structure is similar to what you would see in a ua.dump() of a node, which is what makes dumping existing nodes for exploration useful. however, you usually needn't specify all of the node properties; many of them have sensible defaults inferred from the ones you supply.

local ua = require("opcua")
local DESCRIPTION = ua.const.attribute_id.DESCRIPTION
local DISPLAYNAME = ua.const.attribute_id.DISPLAYNAME
local VALUE = ua.const.attribute_id.VALUE
local server = ua.make_server()
local root = ua.make_proxy(server)
root.Objects[{1, 'Test'}] = {
    [DISPLAYNAME] = "Test",
    [DESCRIPTION] = "Test value",
    [VALUE] = "A string value"
}
ua.dump(root.Objects[{1, 'Test'}], print)

The assignment to root.Objects[...] results in a node being created from the supplied template. The resulting dump includes many more fields than specified in the template, which have been inferred or assigned automatically:

{
  [ua.const.attribute_id.NODEID] = 58192 --[[NodeId ns=1,i=58192]],
  [ua.const.attribute_id.NODECLASS] = ua.const.node_class.VARIABLE,
  [ua.const.attribute_id.BROWSENAME] = {1, "Test"},
  [ua.const.attribute_id.DISPLAYNAME] = {"en-US", "Test"},
  [ua.const.attribute_id.DESCRIPTION] = {"en-US", "Test value"},
  [ua.const.attribute_id.WRITEMASK] = 0,
  [ua.const.attribute_id.USERWRITEMASK] = 4294967295,
  [ua.const.attribute_id.VALUE] = "A string value",
  [ua.const.attribute_id.DATATYPE] = ua.ns0.encode_numeric_id(12) --[[NodeId ns=0,i=12]],
  [ua.const.attribute_id.VALUERANK] = ua.const.value_rank.SCALAR,
  [ua.const.attribute_id.ARRAYDIMENSIONS] = {
    [ua.array_dimensions] = false,
    [ua.data_type] = ua.ns0.encode_numeric_id(7) --[[NodeId ns=0,i=7]]
  },
  [ua.const.attribute_id.ACCESSLEVEL] = 1,
  [ua.const.attribute_id.USERACCESSLEVEL] = 255,
  [ua.const.attribute_id.MINIMUMSAMPLINGINTERVAL] = 0.0,
  [ua.const.attribute_id.HISTORIZING] = false
}

The browseName key explicitly indicates 1 as the namespace, so the default namespace for the node ID is 1. As we haven't specified a NODEID, a spare numeric node ID from that namespace has been assigned automatically. Note that we haven't specified a NODECLASS, but the proxy API infers it from the presence of VALUE. We haven't specified any of the DATATYPE, VALUERANK or ARRAYDIMENSIONS either; they are inferred from the value supplied; DISPLAYNAME and DESCRIPTION, supplied as strings, have had the default en-US locale applied to them. Other attributes have default values for a Variable.

OPC UA node proxy attribute inference rules

Rules for default attribute values are intended to allow minimal templates for naturally-occurring node structures.

When a node is created, its class is inferred using the following logic:

  • DataTypes are never inferred, and must be specified explicitly (they are not expected to be used very often);
  • if the value has the SYMMETRIC attribute, it must be a ReferenceType, otherwise it's never a ReferenceType;
  • if the value has the CONTAINSNOLOOPS attribute, it must be a View, otherwise it's never a View;
  • if the value has the ua.callback key, it must be a Method, otherwise it's never a Method;
  • if the value has the VALUE attribute, it must be a Variable if it has no ISABSTRACT attribute, or a VariableType if it has the ISABSTRACT attribute;
  • otherwise, the value must be an Object if it has no ISABSTRACT attribute, or an ObjectType if it has the ISABSTRACT attribute.

The next item to decide on is the type of the hierarchical reference that relates the parent node to the node being created. If the template contains the ua.reference_type key, its value is used as the reference type. Otherwise, it is inferred using the following logic (note that we know both the parent and the child node classes by now) intended to match natural usage and reduce the need for explicit specification:

  • to a DataType parent,
    • DataType children are related by the HasSubtype relation, and
    • Variable children are related by the HasProperty relation;
  • to a Method parent,
    • Variable children are related by the HasProperty relation;
  • to an Object parent,
    • Method children are related by the HasComponent relation,
    • Object children are related by either the Organizes relation if the parent node's type is a subtype of Folder, or the HasComponent relation otherwise,
    • Variable children are related by either the HasProperty relation if the child node looks like a Property, or the HasComponent relation otherwise,
    • all other children are related by the Organizes relation if the parent node's type is a subtype of Folder;
  • to an ObjectType parent,
    • Method children are related by the HasComponent relation,
    • ObjectType children are related by the HasSubtype relation,
    • Object children are related by the HasComponent relation,
    • Variable children are related by either the HasProperty relation if the child node looks like a Property, or the HasComponent relation otherwise;
  • to an ReferenceType parent,
    • ReferenceType children are related by the HasSubtype relation,
  • to an Variable parent,
    • Variable children are related by either the HasProperty relation if the child node looks like a Property, or the HasComponent relation otherwise,
  • to an VariableType parent,
    • VariableType children are related by the HasSubtype relation,
    • Variable children are related by either the HasProperty relation if the child node looks like a Property, or the HasComponent relation otherwise,
  • all other combinations result in an error being raised (and no nodes being constructed).

The definition of "looks like a `Property`" above is as follows (note that it needs to apply to node templates, which may themselves be incomplete):

  • if the template contains an ua.type_definition key, its value, which must be a node ID, is checked:
    • if it's a subtype of BaseDataVariableType, then the template represents a DataVariable, and therefore it does not represent a Property,
    • if it's a subtype of PropertyType, then the template does represent a Property;
  • if we're still undecided on whether the template represents a Property, its children (string- or table- typed keys) are inspected: the template represents a Property if and only if it has none.

Binding Lua code to OPC UA nodes

User Lua code can run when a Method is invoked or a Variable is being read from or written to.

A Method's ua.callback value can be a plain Lua function, or an augmented function. An augmented function is a plain table with the function as the first element and possibly other elements denoting its properties:

  • if a ua.function_accepts_qualified_arguments key is present in the table and its value is true, arguments will always be supplied to the function in the fully qualified form even if a shorter form is normally possible;
  • if a ua.function_closure_argument_count key is present in the table, its value, which must be a number, indicates the number of additional array elements of the augmented function to be passed to the function when calling it (essentially its closure components);
  • if a ua.function_accepts_session_node_id key is present in the table and its value is true, an additional argument which stores the node ID of the active OPC UA session, or nil if the function is performed outside of a session, is supplied to the function (after any closure arguments, but before any method arguments).

Instead of an OPC UA value, a Variable's VALUE value can be a hook table. In this case, it has the shape {read_hook, write_hook}. A read_hook can be one of the following:

  • nil, meaning the value cannot be read;
  • a Lua function or an augmented function (see above), which is called with no arguments (apart from those specified by augmentation) and must return the value of the Variable;
  • a plain {base, field} table, which is shorthand for function() return base[field] end.

    A write_hook can be one of the following:

  • nil, meaning the value cannot be written to;
  • a Lua function or an augmented function (see above), which is called with a single value argument (apart from those specified by augmentation) and must set the value of the Variable;
  • a plain {base, field} table, which is shorthand for function(value) base[field] = value end;
  • true, which, if the corresponding read_hook is a {base, field} table, is shorthand for the same table (and, therefore, for function(value) base[field] = value end).

Proxy-based reference management

OPC UA nodes have references which allow navigation between them. References are typed, with reference types themselves being OPC UA nodes and having references between them, in particular, the subtyping reference. At least one hierarchical reference to a given node exists from the logically containing 'parent' node; such references are created implicitly on node addition. To create additional references using the proxy-based API, you need to obtain proxy of the source node, a proxy to the reference type and ether a proxy to the target node or its node ID. Then the set of target nodes related to the sourceNode by references of the given referenceType is simply sourceNode[referenceType], and targetNode can be added to it by:

sourceNode[referenceType][targetNode] = true

Conversely, to remove the reference, targetNode is removed from the set:

sourceNode[referenceType][targetNode] = nil

Suppose you want to create a custom non-hierarchical reference type and add a reference of that type between object and attachment nodes. The code could look like this:

local nhRefs = server.Types.ReferenceTypes.References.NonHierarchicalReferences
nhRefs[{1, "IsExtendedBy"}] = {
    [DISPLAYNAME] = "IsExtendedBy",
    [DESCRIPTION] = "Is extended by the given entity",
    [SYMMETRIC] = false,
    [INVERSENAME] = "Extends"
}
local isExtendedBy = nhRefs[{1, "IsExtendedBy"}]
object[IsExtendedBy][attachment] = true

Similarly to proxy index values, sourceNode[referenceType] is a Lua value by itself; it's a reference table proxy. It can be iterated over using pairs to access node IDs of all target nodes which are related to sourceNode by a referenceType relation or its subtypes. Note that you will obtain node IDs, not proxies, as keys; you need to call ua.make_proxy(server, nodeId) explicitly to make proxies.

Being an index expression, sourceNode[referenceType] may also be assigned to, in which case the value assigned must be a plain table with node ID or proxy keys and true values. Such an assignment results in the appropriate references being added and/or removed.

Note that all reference type matches in the above code must be exact; for instance, even if object[IsExtendedBy][attachment] == true, object[NonHierarchicalReferences][attachment] needn't be true; in fact the latter is never true as NonHierarchicalReferences is an abstract reference type.

Low-level API OPC UA integration

The proxy API is based on a lower-level API modeled after libopen62541 interface, with all functions accepting a server instance created by ua.make_server() as their first argument:

  • ua.add_node_begin(server, nodeClass, nodeId, parentNodeId, browseName, typeDefinition, ...properties) initiates node creation and returns its ID (note that the set of properties depends on the nodeClass);
  • ua.add_node_finish(server, nodeId, ...callbacks) finalizes the creation of a node (note that the set of properties depends on the class of the node);
  • ua.delete_node(server, nodeId[, deleteReferences=true]) deletes the node by ID (and references to it as well unless deleteReferences is explicitly false);
  • ua.add_reference(server, source, refType, target[, isForward=true]) adds a reference of type refType from source to target; it returns true if the reference has been added, and false if it already existed;
  • ua.delete_reference(server, source, refType, target[, isForward=true[, deleteBidirectional=true]]) deletes the specified reference (and the opposite one unless deleteBidirectional is explicitly false); it returns true if the reference has been deleted, and false if it was not found;
  • ua.add_namespace(server, uri) adds a namespace by its uri and returns its index;
  • ua.get_namespace_by_name(server, uri) and ua.get_namespace_by_index(server, index) look up the namespaces known to the server by uri or index;
  • ua.read(server, nodeId, attributeId, indexRange, dataEncoding, timestampsToReturn) reads the attribute given by attributeId from the node;
  • ua.write(server, nodeId, attributeId, indexRange, value[, sourceTimestamp[, sourcePicoseconds[, serverTimestamp[, serverPicoseconds]]]]) writes the attribute given by attributeId to the node;
  • ua.browse(server, maxReferences, nodeId, browseDirection, referenceTypeId, includeSubTypes, nodeClassMask, resultMask) can be used to enumerate nodes starting with the given one and following references of the given type in the given direction; it returns a fn, state, var triple to be used in a generic for loop; each iteration yields a loop state value, referenceTypeId, isForward, nodeId, browseName, displayName, nodeClass, and typeDefinition (any of them can be nil if masked by resultMask);
  • ua.translate_browse_path_to_node_ids(server, expandedResponse, nodeId, ...pathElements) follows a path of relative pathElements starting from the given node and returns the destination node ID or nil if none found; each pathElement argument can be a string (a browseName) or a plain {refType, isInverse, includeSubtypes, browseName} table.

It is recommended that you use the proxy API instead. Please contact DLI for low-level API usage details if necessary.

Custom OPC UA server configuration

The configuration argument to ua.make_server(), if supplied, must be a Lua table structure roughly corresponding to libopen62541 JSON configuration. For example, the following code template allows you to configure the application URI and build information:

local server = ua.make_server({
    applicationDescription = {
        applicationUri = "https://example.com/my-opcua-namespace"
    },
    buildInfo = {
        buildDate = "2023-12-31T11:22:33Z",
        buildNumber = "1.2.3",
        manufacturerName = "Example Company",
        productName = "Example Product",
        productUri = "https://example.com/my-product-name",
        softwareVersion = "1.2.3.4"
    }
})

You can get the relevant data from uom.config if you wish to reflect the state of the controller.

OPC UA session attribute manipulation

The functions ua.get_session_attribute(server, sessionNodeId, name) and ua.set_session_attribute(server, sessionNodeId, name, value) expose access to per-session attributes. Apart from being able to attach arbitrary data elements to an OPC UA connection, this allows you to read the remoteAddress session attribute which corresponds to the client address, useful for logging. Note that augmented function callbacks with [ua.function_accepts_session_node_id]=true receive the session node ID as their first argument.

Modbus/TCP settings

Modbus/TCP relay settings

The unit's relays can be exposed via Modbus/TCP as coils. Writes affect the transient state, but the physical state, which may not change immediately, is what's read.

For each relay, read and write permissions can be configured. Reading an inaccessible coil returns OFF, and writing an inaccessible coil has no effect. Only nonexistent elements trigger an "illegal data address" Modbus error.

Additionally, discrete inputs, input and holding registers can be created to serve as a common interaction medium between Modbus/TCP and scripting. The configuration can be done using scripting as well as the UI and REST-like APIs.

Modbus/TCP custom discrete input settings
Modbus/TCP custom input register settings
Modbus/TCP custom holding register settings

See the default.modbus_demo, default.modbus_advanced_demo script snippets for usage examples.