topcua

A Tcl binding to OPC/UA

OPC Unified Architecture (OPC UA) is a machine to machine communication protocol for industrial automation developed by the OPC Foundation. Refer to https://en.wikipedia.org/wiki/OPC_Unified_Architecture for a detailed overview.

A proof-of-concept extension called topcua provides a Tcl binding to a C based OPC UA implementation from https://open62541.org/ and can be found in https://www.androwish.org/index.html/dir?name=jni/topcua

The documentation can be found in https://www.androwish.org/index.html/wiki?name=topcua

The code is very portable and might run on all Tcl supported platforms provided that the platform's C compiler supports C99.

A short example script

The source tree contains an example script implementing an OPC/UA server providing a webcam in a few lines of code. The most interesting piece is how variables/data/things can be mapped between Tcl and OPC/UA domains as shown in the _lastimg and _setparm procedures and how the corresponding items in the OPC/UA address space are created in the opcua add ... invocations. This allows to connect from an OPC/UA client tool like UAExpert or OPC UA Client and to display the camera image.

But be aware that this is early alpha quality stuff and may contain memory leaks and all other kinds of serious bugs. Although it might seem to be some kind of Tcl dream in Industry 4.0 and to have the theoretical capability of controlling drilling rigs, nuclear power plants, low earth orbital stations, and so on it is far from being complete, tested, verified, and certified.

# A little OPC/UA webcam server in about 100 LOC
#
# Requires Linux, MacOSX, or FreeBSD, due to tcluvc support,
# but can be easily modified for Windows to use tclwmf instead.

package require Tk
package require topcua
package require tcluvc

# hide Tk toplevel
wm withdraw .

# get first available camera
set cam [lindex [uvc devices] 0]
if {$cam eq {}} {
    puts stderr "no camera found"
    exit 1
}

# open camera
if {[catch {uvc open $cam capture} cam]} {
    puts stderr "open failed: $cam"
    exit 1
}

# set format to 320x240
foreach {i fmt} [uvc listformats $cam] {
    if {[dict get $fmt "frame-size"] eq "320x240"} {
        uvc format $cam $i 1
        break
    }
}

# photo image for capture
set img [image create photo]

# image capture callback
proc capture {cam} {
    # limit frame rate, otherwise it consumes too much CPU for
    # image processing and the OPC/UA server part starves
    lassign [uvc counters $cam] all done dropped
    if {$all % 20 == 0} {
        uvc image $cam $::img
        set ::png [$::img data -format png]
    }
}

# create OPC/UA server
opcua new server 4840 S

# implementation of OPC/UA data sources
namespace eval ::opcua::S {
    # data source callback
    proc _lastimg {node op {value {}}} {
        if {$op eq "read"} {
            return [list ByteString $::png]
        }
        # hey, this is a camera, not a screen
        return -code error "write shouldn't happen"
    }
    # data source callback
    proc _setparm {name node op {value {}}} {
        if {$op eq "read"} {
            array set p [uvc parameter $::cam]
            set v 0
            if {[info exists p($name)]} {
                set v $p($name)
            }
            return [list Int32 $v]
        }
        set v [dict get $value "value"]
        catch {uvc parameter $::cam $name $v}
        return {}
    }
}

# create our namespace in OPC/UA land
set ns [opcua add S Namespace LilWebCam]

# get Objects folder
set OF [lindex [opcua translate S [opcua root] / Objects] 0]

# create an object in our namespace in Objects folder
set obj [opcua add S Object "ns=$ns;s=LilWebCam" $OF Organizes "$ns:LilWebCam"]

# create some variables in our folder to deal with camera settings
set att [opcua attrs default VariableAttributes]
dict set att dataType [opcua types nodeid Int32]
dict set att accessLevel 3        ;# writable
foreach name {brightness contrast gain gamma hue saturation} {
    opcua add S Variable "ns=$ns;s=[string totitle $name]" $obj HasComponent "$ns:[string totitle $name]" {} $att [list ::opcua::S::_setparm $name]
}

# get node identifier of Image data type, a subtype of ByteString
set IT [lindex [opcua translate S [opcua root] / Types / DataTypes / BaseDataType / ByteString / Image] 0]

# create variable in our folder to return last photo image
set att [opcua attrs default VariableAttributes]
dict set att dataType $IT        ;# Image data type
dict set att valueRank -1        ;# 1-dimensional array
opcua add S Variable "ns=$ns;s=Image" $obj HasComponent "$ns:Image" {} $att ::opcua::S::_lastimg

# start server using Tk's event loop
opcua start S

# start camera
uvc start $cam 

The client for the short example script

For the above webcam (the OPC/UA server) a corresponding OPC/UA client can be found in the source tree, too.

# A little OPC/UA webcam client example
package require Tk
package require topcua

wm title . "Client of LilWebCam"
set img [image create photo]
label .label -image $img
pack .label

# create client
opcua new client C

# connect to server
opcua connect C opc.tcp://localhost:4840

# get the namespace
set ns [opcua namespace C LilWebCam]

# monitor callback proc
proc monitor {data} {
    $::img configure -format png -data [dict get $data value]
}

# make a subscription with 200 ms rate
set sub [opcua subscription C new 1 200.0]

# make a monitor to the camera image
set mon [opcua monitor C new $sub data monitor "ns=${ns};Image"]

# handle OPC/UA traffic (the subscription/monitor)
proc do_opcua_traffic {} {
    after cancel do_opcua_traffic
    if {[catch {opcua run C 20}]} {
        # this most likely is the server shutting down
        exit
    }
    after 200 do_opcua_traffic
}

do_opcua_traffic

Custom Data Types

It is possible to define custom data types in the form of data structures and enumerations while the current topcua extension currently allows only the former. A data structure is expressed as specific nodes in the OPC/UA address space and communicated to the outside world as so called extension object. In order for a generic client to interpret an extension object, a description for (de)serialization is stored as an XML string in another node in the OPC/UA address space. The process of definition of structures and serialization is performed with the opcua deftypes (structure definition) and opcua gentypes (generation of supplementary information) subcommands as shown in this example.

package require topcua

# create server
opcua new server 4840 S

# create our namespace
set NS http://www.androwish.org/TestNS/
set nsidx [opcua add S Namespace $NS]

# create structs
opcua deftypes S $NS {
    struct KVPair {
        String name
        String value
    }
    struct RGB {
        UInt16 red
        UInt16 green
        UInt16 blue
    }
    struct NamedColor {
        String name
        RGB color
    }
}

# import type defs
opcua gentypes S

# make some variables using the structs from above
set OF [lindex [opcua translate S [opcua root] / Objects] 0]
foreach {name type} {
    X1 KVPair
    X2 RGB
    X3 NamedColor
} {
    set att [opcua attrs default VariableAttributes]
    dict set att dataType [opcua types nodeid S $type]
    dict set att value [list $type [opcua types empty S $type]]
    opcua add S Variable "ns=${nsidx};s=$name" $OF Organizes "${nsidx}:$name" {} $att
}

# start server
opcua start S

# enter event loop
vwait forever

Map OPC/UA Variables To Files Using tcl-fuse

The following example script uses tcl-fuse to read-only map OPC/UA variables to files. The path names within the fuse file system are derived from the browse paths of the variables in the OPC/UA address space.

package require topcua
package require fuse

# Names and global variables
#
#  mountpoint   - mountpoint, native directory name
#  url          - OPCUA url to connect to
#  verbose      - flag controlling log output
#  C            - OPCUA client (name, not variable)
#  FS           - fuse filesystem object (name, not variable)
#  T            - array indexed by brpath, values are { nodeid clspath }
#  R            - reverse of T, indexed by nodeid, values are brpath
#  D            - data cache, array indexed by nodeid, values are OPCUA variable Value attributes
#  M            - timestamp of elements in D
#  U            - use (= open) count of elements in D

# Preparation

set mountpoint [lindex $argv 0]
if {$mountpoint eq ""} {
    puts stderr "no mountpoint given"
    exit 1
}
if {![file isdirectory $mountpoint]} {
    puts stderr "invalid mountpoint"
    exit 1
}
set url [lindex $argv 1]
if {$url eq ""} {
    set url opc.tcp://localhost:4840
}
set verbose 0
scan [lindex $argv 2] "%d" verbose

# Logging

proc log {msg} {
    if {$::verbose} {
        set ts [clock format [clock seconds] -format "%Y-%m-%d %H:%M:%S"]
        puts stderr "${ts}: $msg"
    }
}

# OPCUA connect and retrieve tree into variable ::T, key is browse path, value a list of node ID and
# class path, thus variables can be identified with the pattern "*/Variable" on the class path.
# Variable ::R is for reverse mapping node ID to browse path. Namespace prefixes are stripped
# from browse paths, as long as they are unique among the entire address space.

log "starting up"
opcua new client C
log "connecting to $url"
opcua connect C $url
log "connected"

# Fetch custom types, if any

catch {opcua gentypes C}
log "fetched types, if any"

apply {tree {
    foreach {brpath nodeid clspath refid typeid} $tree {
        set short $brpath
        regsub -all -- {/[1-9][0-9]*:} $short {/} short
        incr t($short)
    }
    foreach {brpath nodeid clspath refid typeid} $tree {
        set short $brpath
        regsub -all -- {/[1-9][0-9]*:} $short {/} short
        if {$t($short) == 1} {
            set brpath $short
        }
        set ::T($brpath) [list $nodeid $clspath]
        set ::R($nodeid) $brpath
    }
}} [opcua ptree C]
log "fetched tree"

# Fuse entry points; the "fs_getattr" function fills a cache when an OPCUA variable is referenced.
# Other functions work with cached entries later.

proc fs_getattr {context path} {
    log "getattr $path"
    if {$path eq "/"} {
        return [dict create type directory mode 0755 nlinks 2]
    }
    if {[info exists ::T($path)]} {
        lassign $::T($path) nodeid clspath
        if {[string match "*/Variable" $clspath]} {
            set now [clock seconds]
            # Fetch Value attribute into cache, if cache entry doesn't
            # exist at all, or is not open and older than 10 seconds.
            if {![info exists ::D($nodeid)] ||
                ($::U($nodeid) <= 0 && $now - $::M($nodeid) >= 10)} {
                log "refresh $path"
                if {[catch {set ::D($nodeid) [opcua read C $nodeid]}]} {
                    return -code error -errorcode [list POSIX EIO {}]
                }
                set ::M($nodeid) $now
                set ::U($nodeid) 0
            }
            return [dict create mode 0666 nlinks 1 \
                        mtime $::M($nodeid) \
                        size [string length $::D($nodeid)]]
        }
        return [dict create type directory mode 0755 nlinks 2]
    }
    return -code error -errorcode [list POSIX ENOENT {}]
}

proc fs_open {context path fileinfo} {
    log "open $path"
    if {[info exists ::T($path)]} {
        lassign $::T($path) nodeid clspath
        if {[string match "*/Variable" $clspath]} {
            # Cached Value attribute must exist
            if {"RDONLY" ni [dict get $fileinfo flags] ||
                ![info exists ::D($nodeid)]} {
                return -code error -errorcode [list POSIX EACCES {}]
            }
            # Success, increment use counter and return empty result.
            incr ::U($nodeid)
            return
        }
        return -code error -errorcode [list POSIX EACCES {}]
    }
    return -code error -errorcode [list POSIX ENOENT {}]
}

proc fs_readdir {context path fileinfo} {
    log "readdir $path"
    if {[info exists ::T($path)]} {
        lassign $::T($path) nodeid clspath
        if {[string match "*/Variable" $clspath]} {
            return -code error -errorcode [list POSIX ENOENT {}]
        }
        set pattern ${path}/*
    } elseif {$path eq "/"} {
        set pattern /*
    }
    set nsl [llength [split $pattern "/"]]
    set list [list "." ".."]
    foreach name [array names ::T] {
        if {[string match $pattern $name]} {
            set sl [llength [split $name "/"]]
            if {$sl == $nsl} {
                lappend list [file tail $name]
            }
        }
    }
    return $list
}

proc fs_read {context path fileinfo size offset} {
    log "read $path"
    if {[info exists ::T($path)]} {
        lassign $::T($path) nodeid clspath
        if {[string match "*/Variable" $clspath]} {
            if {![info exists ::D($nodeid)]} {
                # EOF?
                return
            }
            set val $::D($nodeid)
            set len [string length $val]
            if {$offset < $len} {
                if {$offset + $size > $len} {
                    set size $len
                }
                incr size -1
                return [string range $val $offset $size]
            }
            # Success, but nothing read
            return
        }
    }
    return -code error -errorcode [list POSIX ENOENT {}]
}

proc fs_release {context path fileinfo} {
    log "release $path"
    if {[info exists ::T($path)]} {
        lassign $::T($path) nodeid clspath
        # Decrement use counter for cache entry.
        incr ::U($nodeid) -1
    }
    return
}

proc fs_destroy {context} {
    log "shutdown, disconnecting"
    catch {opcua disconnect C}
    log "exiting"
    exit 0
}

# Create and serve fuse file system.

fuse create FS -getattr fs_getattr -readdir fs_readdir -open fs_open \
    -read fs_read -release fs_release -destroy fs_destroy

FS $mountpoint -s -ononempty -ofsname=OPCUA
log "created/mounted file system"

# Remove old cache entries after 60 seconds and do some keep-alive/reconnect handling.

proc fs_cleanup {url} {
    log "cleanup ..."
    set status /Root/Objects/Server/ServerStatus
    if {[info exists ::T($status)]} {
        if {[catch {opcua read C [lindex $::T($status) 0]} error]} {
            log "reading server status: $error"
            catch {opcua disconnect C}
            log "reconnecting to $url"
            if {[catch {opcua connect C $url} error]} {
                log "connect failed: $error"
            }
        }
    }
    set now [clock seconds]
    foreach nodeid [array names ::D] {
        if {$::U($nodeid) <= 0 && $now - $::M($nodeid) >= 60} {
            log "expire $::R($nodeid)"
            unset -nocomplain ::D($nodeid)
            unset -nocomplain ::M($nodeid)
            unset -nocomplain ::U($nodeid)
        }
    }
    after 10000 [list fs_cleanup $url]
}

fs_cleanup $url

# Start event loop

log "enter event loop"
vwait forever

Map OPC/UA Variables To Files Using tclvfs

Similar to the Fuse example, the following script uses tclvfs to read-only map OPC/UA variables to files. The path names within the fuse file system are derived from the browse paths of the variables in the OPC/UA address space. A mount is performed by

   package require vfs::opcua
   vfs::opcua::Mount opc.tcp://localhost:4840 OPCUA

where the OPC/UA address space appears below the local directory OPCUA, or

   package require vfs::urltype
   package require vfs::opcua
   vfs::urltype::Mount opcua

where the mount is automatically performed using an URL like notation, e.g.

   set f [open opcua://localhost:4840/Objects/LilWebCam/Image rb]
   image1 configure -data [read $f]
   close $f

for the webcam example above. Unmounting is done for the first form of mount by

   vfs::unmount OPCUA

and for the URL type form by

   vfs::filesystem unmount opcua://localhost:4840

Here is the implementation of the vfs::opcua filesystem:

# Read-only mapping of OPCUA variables using Tcl VFS

package require topcua
package require vfs

package provide vfs::opcua 0.1

namespace eval vfs::opcua {
    variable T        ;# array indexed by brpath, values are { nodeid clspath }
    variable R        ;# reverse of T, indexed by nodeid, values are brpath
    variable D        ;# data cache, array indexed by nodeid, values are
                ;# OPCUA variables' Value attributes
    variable U        ;# array of URLs for reconnect indexed by client handle

    array set T {}
    array set R {}
    array set D {}
    array set U {}

    proc _connect {C url} {
        variable T
        variable R
        variable U
        set U($C) $url
        ::opcua connect $C $url
        catch {::opcua gentypes $C}
        set tree [::opcua ptree $C]
        # omit "/" and "/Root" prefixes in brpath
        foreach {brpath nodeid clspath refid typeid parent} $tree {
            set brpath [string trimleft $brpath /]
            regsub -all -- {^Root/} $brpath {} brpath
            set short $brpath
            regsub -all -- {/[1-9][0-9]*:} $short {/} short
            incr t($short)
        }
        foreach {brpath nodeid clspath refid typeid parent} $tree {
            set brpath [string trimleft $brpath /]
            regsub -all -- {^Root/} $brpath {} brpath
            set short $brpath
            regsub -all -- {/[1-9][0-9]*:} $short {/} short
            if {$t($short) == 1} {
                set brpath $short
            }
            set T($C,$brpath) [list $nodeid $clspath]
            set R($C,$nodeid) $brpath
        }
    }

    proc _disconnect {C} {
        variable T
        variable R
        variable D
        variable U
        ::opcua disconnect $C
        foreach name [array names T $C,*] {
            unset T($name)
        }
        foreach name [array names R $C,*] {
            unset R($name)
        }
        foreach name [array names D $C,*] {
            unset D($name)
        }
        unset U($C)
    }

    proc Mount {url local} {
        variable T
        variable R
        variable U
        set urlc $url
        if {[string first $urlc opcua://]} {
            set urlc opc.tcp://[string range $url 8 end]
        }
        set C [::opcua new]
        if {![catch {vfs::filesystem info $url}]} {
            vfs::unmount $url
        }
        vfs::filesystem mount $local [list [namespace current]::handler $C]
        vfs::RegisterMount $local [list [namespace current]::Unmount $C]
        _connect $C $urlc
        return $C
    }

    proc _readvar {C nodeid} {
        variable U
        foreach attempt {0 1} {
            if {![catch {::opcua read $C $nodeid} val]} {
                return $val
            }
            if {$attempt < 1} {
                switch -- [lindex $::errorCode 3] {
                    BadSessionIdInvalid -
                    BadConnectionClosed {
                        # try to reconnect
                        set url $U($C)
                        catch {_disconnect $C}
                        catch {_connect $C $url}
                    }
                }
            }
        }
        return -code error $val
    }

    proc Unmount {C local} {
        vfs::filesystem unmount $local
        _disconnect $C
        ::opcua destroy $C
    }

    proc handler {C cmd root relative actualpath args} {
        if {$cmd eq "matchindirectory"} {
            [namespace current]::$cmd $C $relative $actualpath {*}$args
        } else {
            [namespace current]::$cmd $C $relative {*}$args
        }
    }

    proc attributes {C} {
        return [list "state"]
    }

    proc state {C args} {
        vfs::attributeCantConfigure "state" "readonly" $args
    }

    proc _getdir {C path actualpath {pattern *}} {
        variable R
        variable T
        if {$path eq "." || $path eq ""} {
            set path ""
        }
        if {$pattern eq ""} {
            if {[info exists T($C,$path)]} {
                return [list $path]
            }
            return [list]
        }
        set res [list]
        if {$path eq ""} {
            set sep /
            set strip 0
            set depth 1
        } elseif {[info exists T($C,$path)]} {
            set sep ""
            set strip [string length $path]
            set depth [llength [file split $path]]
            incr depth 1
        }
        if {[info exists depth]} {
            foreach name [array names R $C,*] {
                if {$strip && [string first $path $R($name)] != 0} {
                    continue
                }
                set flist [file split $R($name)]
                if {[llength $flist] != $depth} {
                    continue
                }
                if {[string match $pattern [lindex $flist end]]} {
                    lappend res \
                        $actualpath$sep[string range $R($name) $strip end]
                }
            }
        }
        return $res
    }

    proc matchindirectory {C path actualpath pattern type} {
        variable T
        set res [_getdir $C $path $actualpath $pattern]
        if {![string length $pattern]} {
            if {![info exists T($C,$path)]} {
                return {}
            }
            set res [list $actualpath]
            set actualpath ""
        }
        set newres [list]
        foreach name [::vfs::matchCorrectTypes $type $res $actualpath] {
            lappend newres [file join $actualpath $name]
        }
        return $newres
    }

    proc stat {C name} {
        variable T
        variable D
        if {$name eq ""} {
            return [list type directory mtime 0 size 0 mode 0555 ino -1 \
                        depth 0 name "" dev -1 uid -1 gid -1 nlink 1]
        }
        if {[info exists T($C,$name)]} {
            lassign $T($C,$name) nodeid clspath
            if {[string match "*/Variable" $clspath]} {
                if {![info exists D($C,$nodeid)]} {
                    if {[catch {set D($C,$nodeid) [_readvar $C $nodeid]}]} {
                        vfs::filesystem posixerror $::vfs::posix(EIO)
                    }
                }
                return [list type file mtime 0 mode 0444 ino -1 \
                            size [string length $D($C,$nodeid)] \
                            atime 0 ctime 0]
            }
            return [list type directory mtime 0 size 0 mode 0555 ino -1 \
                        depth 0 name $name dev -1 uid -1 gid -1 nlink 1]
        }
        vfs::filesystem posixerror $::vfs::posix(ENOENT)
    }

    proc access {C name mode} {
        variable T
        if {$mode & 2} {
            vfs::filesystem posixerror $::vfs::posix(EROFS)
        }
        if {[info exists T($C,$name)]} {
            return 1
        }
        vfs::filesystem posixerror $::vfs::posix(ENOENT)
    }

    proc open {C name mode permission} {
        variable T
        variable D
        switch -- $mode {
            "" - "r" {
                if {![info exists T($C,$name)]} {
                    vfs::filesystem posixerror $::vfs::posix(ENOENT)
                }
                lassign $T($C,$name) nodeid clspath
                if {![string match "*/Variable" $clspath]} {
                    vfs::filesystem posixerror $::vfs::posix(EISDIR)
                }
                if {[catch {set D($C,$nodeid) [_readvar $C $nodeid]}]} {
                    vfs::filesystem posixerror $::vfs::posix(EACCES)
                }
                set newchan [vfs::memchan]
                fconfigure $newchan -translation binary
                puts -nonewline $newchan $D($C,$nodeid)
                fconfigure $newchan -translation auto
                seek $newchan 0
                return [list $newchan]
            }
            default {
                vfs::filesystem posixerror $::vfs::posix(EROFS)
            }
        }
    }

    proc createdirectory {C name} {
        vfs::filesystem posixerror $::vfs::posix(EROFS)
    }

    proc removedirectory {C name recursive} {
        vfs::filesystem posixerror $::vfs::posix(EROFS)
    }

    proc deletefile {C name} {
        vfs::filesystem posixerror $::vfs::posix(EROFS)
    }

    proc fileattributes {C name args} {
        switch -- [llength $args] {
            0 {
                # list strings
                return [list]
            }
            1 {
                # get value
                return ""
            }
            2 {
                # set value
                vfs::filesystem posixerror $::vfs::posix(EROFS)
            }
        }
    }

    proc utime {C path actime mtime} {
        vfs::filesystem posixerror $::vfs::posix(EROFS)
    }
}

Exercises for the interested reader

  • make the camera using the tclwmf extension from http://www.androwish.org to run this on Windows
  • make the camera using the borg extension from http://www.androwish.org to run this on a tablet or smartphone
  • add more camera controls using appropriate mappings between tcluvc parameters and OPC/UA variables
  • use e.g. SQLite as persistent data store for variable values
  • create some methods to query e.g. an SQLite database (and avoid SQL insertion problems for the query's parameters)