CTRL Interoperation

A Python manager can run CTRL code. This gives access to the CTRL function set and to the script libraries that already exist in a project, without having to port them.

CTRL code that a Python manager runs is not sandboxed and is not limited to what the Python API offers. Read Security before you pass code or file paths that come from anywhere but your own source tree.

Creating a Script

create_ctrl_script() parses CTRL source code that is passed as a string:

script = manager.create_ctrl_script(
    "int add(int a, int b) { return a + b; }",
    name="calculator",
)

The name appears in the error messages that WinCC OA writes for this script. It has no other effect, but an empty name makes a runtime error hard to locate.

create_ctrl_script_from_file() loads the code from a file instead:

script = manager.create_ctrl_script_from_file("myLib")

A relative path is searched under scripts/ in all project and installation directories, so subprojects and the installation itself can supply the file. The extension .ctl is appended when the path has none, and encrypted .ctc files are found as well. An absolute path is read directly.

The code is parsed while the script is created, not when a function is called. A syntax error therefore surfaces at this point, see Errors.

Calling a Function

call_function() runs one function of the script and returns an OaCtrlFunctionResult:

result = script.call_function("add", params=[2, 3])
print(result.return_value.value)  # 5

Without a function name, main is called. The call blocks until the CTRL function has finished.

Arguments are matched positionally and forgivingly, which is worth knowing because neither case raises:

  • Additional arguments beyond the parameters of the CTRL function are ignored.
  • Missing arguments are not assigned at all, so the parameter keeps the value it starts with, which is the null value of its type unless the function declares a default.

If the argument count of a call is wrong, the function still runs and returns a plausible result. Verify the signature rather than relying on an error.

Passing and Receiving Values

Plain Python values are converted automatically, so params=[2, 3] is enough where the CTRL function expects two integers.

Each argument is then assigned to its parameter under the usual CTRL cast rules. A value is converted to the declared parameter type wherever a conversion exists, so an int arrives at a float parameter as a float, and even a string is converted where CTRL allows it. Where no conversion exists, the parameter keeps its null value and WinCC OA writes a warning to the log, which is another case that does not raise.

The type you pass therefore matters only where CTRL keeps it, above all for an anytype parameter. Use an OaVariant to be explicit there:

result = script.call_function(
    "store",
    params=[OaVariant.from_int(4), OaVariant.from_float(2.5)],
)

Data Types describes the mapping and the factory methods.

The return value follows the CTRL type of the function:

CTRL return type return_value
a scalar type an OaVariant, use .value for the Python value
a typed dyn_ such as dyn_string an OaVariant whose .value is a tuple of Python values
a typed dyn_dyn_ such as dyn_dyn_string an OaVariant whose .value is a tuple of such tuples
dyn_anytype and dyn_mixed an OaVariant whose .value is a tuple of OaVariant
dyn_dyn_anytype and dyn_dyn_mixed an OaVariant whose .value is a tuple of DYN_ANYTYPE_VAR variants
a vector such as vector<string> the same as the matching dyn_, so a DYN_TEXT_VAR variant here
a mapping an OaVariant whose .value is a dict
void None

An untyped array keeps the type of every single element, which is why its elements stay wrapped. A dyn_dyn_ is an array of arrays rather than a matrix, so its rows may differ in length. A dyn_mixed arrives as a DYN_ANYTYPE_VAR, because the two differ in the assignment semantics only, which an immutable OaVariant does not have. A vector whose element type has no dyn_ counterpart, a nested vector for example, arrives as a DYN_ANYTYPE_VAR, which keeps the type of every element. An anytype that holds no value arrives as an OaVariant of type NO_VAR, wherever it appears. In the other direction both None and a NO_VAR variant become an empty CTRL anytype.

In a returned mapping, both keys and values are OaVariant objects. A lookup with a plain string still works, because an OaVariant hashes and compares like the value it holds.

Unsupported Types

A CTRL type that has no counterpart in the Python API is not transported, and a function returning one does not raise. What arrives instead is not defined. In most cases it is an OaVariant that holds the name of the CTRL type as a string, for example "file", so the value is gone without an error.

Four groups are affected, and none of the lists is exhaustive:

  • the CTRL++ types, among them class, enum and class_ptr.
  • the types that exist only while a CTRL interpreter is loaded, among them dbCommand, dbConnection, dbRecordset, shape and idispatch. The last two are of use in a UI manager only.
  • file and errClass, which every manager knows but the Python API does not map.
  • the arrays of such types, for example dyn_shape and dyn_dyn_shape.

Keep such a value inside the CTRL code and return a supported type instead.

Reference Parameters

CTRL functions frequently return their results through reference parameters:

void split(string input, string &left, string &right)

The Python API does not support this. The values a CTRL function writes to its reference parameters are not transported back to Python, and OaCtrlFunctionResult carries the return value only. The call itself still succeeds and reports no error, so the loss is silent. This is a permanent limitation rather than a pending feature.

Wrap such a function in a small CTRL script that calls it and returns the values instead. The wrapper includes the library with #uses, exactly as any other CTRL script would. For a single value, return it directly:

WRAPPER = """\
#uses "myLib"

string firstPart(string input)
{
  string left, right;
  split(input, left, right);
  return left;
}
"""

script = manager.create_ctrl_script(WRAPPER)
left = script.call_function("firstPart", params=["a;b"]).return_value.value

For several values there are two shapes, and both have their place. A dyn_ return is read by index and suits values that form a sequence or vary in number. A mapping is read by name and suits values that each carry a distinct meaning, because the call site then documents itself.

The array form:

WRAPPER = """\
#uses "myLib"

dyn_string splitBoth(string input)
{
  string left, right;
  split(input, left, right);
  return makeDynString(left, right);
}
"""

script = manager.create_ctrl_script(WRAPPER)
left, right = script.call_function("splitBoth", params=["a;b"]).return_value.value

The mapping form:

WRAPPER = """\
#uses "myLib"

mapping splitBoth(string input)
{
  string left, right;
  split(input, left, right);
  mapping result;
  result["left"] = left;
  result["right"] = right;
  return result;
}
"""

script = manager.create_ctrl_script(WRAPPER)
parts = script.call_function("splitBoth", params=["a;b"]).return_value.value

left = parts["left"].value
right = parts["right"].value

Script Lifetime

A script stays loaded as long as the script object exists, so it can be called more than once. Release it when you no longer need it:

CODE = "int step(int value) { return value * 2; }"
script = manager.create_ctrl_script(CODE)
first = script.call_function("step", params=[1]).return_value.value   # 2
second = script.call_function("step", params=[2]).return_value.value  # 4
script.unload()

unload() rejects the futures that are still open and stops the running CTRL threads.

The context manager is the reliable form, because it also releases the script when a call raises:

CODE = "int step(int value) { return value * 2; }"
with manager.create_ctrl_script(CODE) as script:
    first = script.call_function("step", params=[1]).return_value.value   # 2
    second = script.call_function("step", params=[2]).return_value.value  # 4

Errors

Errors reach Python at two different moments.

Situation Result
syntax error in the source code ValueError while the script is created
file not found, unreadable, or with a syntax error ImportError while the script is created
the called function does not exist ValueError
a call on a script that is no longer loaded ValueError
a severe error inside the CTRL code OaError

Warnings that CTRL writes, for example about an unknown datapoint, do not raise. They appear in the log, and the call returns normally. A CTRL function that fails silently therefore looks like a successful call with an unexpected result.

Error Handling describes OaError and the other exception types.

Threads

call_function() may be called from the script thread, from a thread you started yourself, and from inside a subscription callback. It blocks the calling thread until the CTRL function has finished, while the communication with the runtime system continues. Threads describes the threads of a Python manager.

Security

CTRL code runs with the permissions of the manager and without a sandbox, as the introduction states. One consequence deserves repeating, because it outlives the call: setUserId() changes the user of the entire manager and therefore the permissions of every API call that follows. Security covers the rest.