CMLabs · Psyclone AIOS

4. Modules

Modules do the work: cranks and libraries, one-shot vs continuous execution, private data, parameters, custom setup, passthrough modules, Python cranks and external modules.

Cranks and libraries

Every component has at least one crank — a function called when a trigger fires. For modules you declare cranks in the spec; whiteboards use a built-in crank; for catalogs the crank is the catalog type. A crank is a function name in a DLL/SO; built-in cranks (like Ping) need no library. Register a library and refer to its cranks by logical name:

<library name="MyExamples" library="Examples" />

<module name="MyModule">
  <trigger name="Input" type="input.data" />
  <crank name="Process" function="MyExamples::Ping" />
  <post name="Output" type="output.data" />
</module>

File lookup: Examples.dll (Windows) / libExamples.so (UNIX) in the current directory; debug builds first try ExamplesDebug.dll / libExamplesDebug.so. Libraries compile against CMSDK alone — Psyclone itself is not needed to compile or run a library. A crank function without a lib:: prefix refers to a built-in (Internal::) crank. For Python cranks no library is needed at all: <crank name="Process" language="python3" script="process.py" /> — see Python modules below.

The C++ crank pattern

A crank is an exported C function taking a single PsyAPI* — the module's whole world: messages, parameters, retrieves and queries.

// MyCrank.cpp — compiles against CMSDK alone, e.g.:
//   g++ -shared -fPIC -I<CMSDK>/include MyCrank.cpp <CMSDK>/lib/<arch>/libCMSDK.a -o libExamples.so
#include "PsyAPI.h"
using namespace cmlabs;

// export declaration
namespace cmlabs { extern "C" {
  DllExport int8 MyCrankFunction(PsyAPI* api);
} }

// implementation
int8 MyCrankFunction(PsyAPI* api) {
  while (api->shouldContinue()) {
    DataMessage* msg = api->waitForNewMessage(100, "Input");
    if (!msg) continue;
    api->logPrint(1, "Got a message...");
    DataMessage* outMsg = new DataMessage();
    outMsg->setInt("Count", 1);
    outMsg->setString("Status", "ok");
    api->postOutputMessage("Output", outMsg);
  }
  return 0;
}
# script-file crank: entry point is PsyCrank(apilink)
def PsyCrank(apilink):
    api = cmsdk.PsyAPI.fromPython(apilink)
    while api.shouldContinue():
        msg = api.waitForNewMessage(100, "Input")
        if msg is None: continue
        api.logPrint(1, "Got a message...")
        outMsg = cmsdk.DataMessage()
        outMsg.setInt("Count", 1)
        outMsg.setString("Status", "ok")
        api.postOutputMessage("Output", outMsg)
    return 0
Note. The DllExport classifier is required for the function to be visible to Psyclone. If a module declares no crank at all, it receives a built-in default crank — see passthrough modules below.

One-shot vs continuous

The same declaration supports two execution styles, decided by the crank code:

  • One-shot: the crank runs once per trigger, returns, and its thread goes back to the pool. Ideal for stateless processing.
  • Continuous: the crank never returns while the system runs — while (api->shouldContinue()) with a timeout on waitForNewMessage lets it do extra work every cycle (e.g. every 100 ms). It keeps its OS thread, which pays off for heavy initialisation or large in-memory state, but many continuous components mean many threads and possible performance impact.
One-shot trigger fires crank runs once posts output, returns thread back to pool re-invoked per message Continuous crank starts, keeps thread while (api->shouldContinue())waitForNewMessage(100, ...) process / periodic work clean exit on shutdown loop
One-shot cranks borrow a pool thread per trigger; continuous cranks own a thread and loop on shouldContinue().
Concurrency. Because a one-shot crank borrows a pool thread per trigger firing, the same crank function can run on several threads at once for different messages. Do not assume one-at-a-time execution: guard any shared or static state (and be careful with private data read-modify-write) against concurrent access. A continuous crank owns its single thread, so its own loop state is not shared — but data it shares with other components still needs protection.

Local persistent (private) data

Any component can save local data to persistent storage as a binary blob — especially useful for one-shot modules (state between invocations) and modules that may migrate between nodes. If you supply a mimetype, the data appears in the component's Data tab in PsyProbe.

api->setPrivateData("My Data", str.c_str(), str.length());
data = api->getPrivateDataCopy("My Data", size);
// visible in PsyProbe:
api->setPrivateData("My Report", html.c_str(), html.length(), "text/html");
api.setPrivateData("My Data", s)
data = api.getPrivateDataCopy("My Data")
# visible in PsyProbe:
api.setPrivateData("My Report", html, "text/html")

Component parameters

Parameters are typed values declared in the spec and read, set and tweaked from code and from PsyProbe:

<parameter name="MaxCount" type="Integer" value="[0:110:2]=55" />
<parameter name="Threshold" type="Float" value="[-2.5:2.5]" />
<parameter name="Mode" type="String" value="['fast';'accurate';'debug']" />
Value syntaxMeaning
42Plain value
[0:110]Range; initial value = lowest
[0:110]=55Range with explicit initial value
[0:110]=*Range with random initial value
[0:110:2]Stepped range (step 2); step on a plain numeric via interval="2" value="10"
[-2.5;1.5;1.8] / ['a';'b';'c']Value lists (numeric / string)

From code: getParameterString/getParameterInt (and the float getter), hasParameter(name), setParameter(name, …), resetParameter(name), and getParameterDataType(name) returning PARAM_STRING 1, PARAM_INTEGER 2, PARAM_FLOAT 3, PARAM_STRING_COLL 4, PARAM_INTEGER_COLL 5, PARAM_FLOAT_COLL 6. Nudge a stepped parameter with api->tweakParameter("MaxCount", 2) (±steps). All parameters are queryable and tweakable live from PsyProbe.

Custom configuration: <setup>

For richer configuration than flat parameters, embed any valid XML in a <setup> child. The component reads it back as a string and parses it with the bundled XML parser:

<module name="Mapper">
  <setup>
    <mapping entry="x" key="PosX" />
    <mapping entry="y" key="PosY" />
  </setup>
</module>
const char* xml = api->getParameterString("componentsetup");
XMLResults xmlResults;
XMLNode node = XMLNode::parseString(xml, "setup", &xmlResults);
if (xmlResults.error == eXMLErrorNone) { /* getChildNode / getAttribute ... */ }
import xml.etree.ElementTree as ET
xml = api.getParameterString("componentsetup")
node = ET.fromstring(xml)                # <setup> root
for mapping in node.findall("mapping"):  # child nodes / attributes ...
    entry, key = mapping.get("entry"), mapping.get("key")

Passthrough modules

A module with no crank gets a default crank that passes messages through: every trigger activates every post with a copy of the incoming message. This is surprisingly useful:

  • Retype a message (trigger on one type, post another).
  • Split one message into several posts carrying the same data.
  • Delay a path: <trigger ... after="5000" />.
  • Regular posting: <trigger name="Post" type="Regular.Post" interval="1000" /> plus a post.

Posts can add content on the way through, via <content> child entries:

<module name="Heartbeat">
  <trigger name="Tick" type="Regular.Post" interval="1000" />
  <post name="Out" type="system.heartbeat">
    <content key="Source" type="String" value="Heartbeat" />
  </post>
</module>

Python modules

Cranks can be written in Python; the full CMSDK is exposed via SWIG, so PsyAPI and DataMessage work as in C++. Three forms:

Script file

<crank name="Ping" language="python3" script="path/to/ping.py" />

The file's entry point is def PsyCrank(apilink):, with api = cmsdk.PsyAPI.fromPython(apilink); the cmsdk import is automatic.

Inline code

<crank name="p1" language="python3"><![CDATA[
while api.shouldContinue():
    msg = api.waitForNewMessage(20)
    if msg is None: continue
    count = msg.getInt("Count")
    outMsg = cmsdk.DataMessage()
    outMsg.setInt("Count", count + 1)
    api.postOutputMessage("Output", outMsg)
]]></crank>

For inline cranks the libraries are auto-loaded and an api PsyAPI object is auto-created. Python is indentation-sensitive; Psyclone attempts to adjust indentation on load, but keep inline code cleanly unindented.

Libraries and paths

Running Python cranks requires: (1) a matching Python interpreter (major version and bitness, DLLs on the search path); (2) the CMSDK binary library _cmsdk.pyd (Windows) / _cmsdk.so (Linux) plus the interface file cmsdk2.py/cmsdk3.py (debug: _cmsdkdebug.pyd/.so, cmsdk2debug.py/cmsdk3debug.py); (3) any extra imports your code needs. Psyclone searches: (a) the script's directory, (b) the Psyclone binary's directory, (c) a module parameter:

<parameter name="libpath" type="String" value="../CMSDK/bin/Win32" />
Note. language="python2" is still accepted but Python 2 is end-of-life — treat it as legacy and use python3 for new work. Python-crank modules automatically get their own process space.

External modules

A module can live inside a separate program that links CMSDK and connects to the running Psyclone. In the spec, declare an external space and a module whose crank has a name only (a placeholder the external process fills in):

<space name="GUISpace" type="external" />
<module name="CoCoMapsGUI" space="GUISpace">
  <crank name="CoCoMapsGUI" />
  <trigger name="Input" type="gui.command.*" />
  <post name="Output" type="gui.status" />
</module>

The external program creates the space, connects, and fetches an API per crank:

PsySpace* space = new PsySpace("GUISpace");
space->connect(sysid);
space->start();
PsyAPI* api = space->getCrankAPI("CoCoMapsGUI");   // retry loop on failure
// then use api exactly like an internal module
space = cmsdk.PsySpace("GUISpace")
space.connect(sysid)
space.start()
api = space.getCrankAPI("CoCoMapsGUI")   # retry loop on failure
# then use api exactly like an internal module

Here sysid is the system ID — a 16-bit number (type uint16) that identifies the running Psyclone instance an external process attaches to; it must match the ID of the Psyclone you are connecting to (a single local system defaults to 1, so space->connect(1) is the usual localhost value). The signature is connect(uint16 systemID, bool isMaster=false, const char* cmdline=NULL).

Monitor the link with space->isConnected() / space->hasShutdown(); on failure delete the space but not the PsyAPI (it is owned by the PsySpace), then retry periodically. External space crashes and restarts are tolerated just like internal ones.

A <module type="external"> may also carry an <executable> child naming the command line to auto-start the external process — but note this is currently parsed and stored without being launched: Roadmap (in progress). Start external processes yourself for now.

<module name="Ext1" type="external">
  <executable consoleoutput="yes" autorestart="yes">./mytool --connect</executable>
</module>

Useful module attributes

AttributeMeaningStatus
nameComponent name (required)Shipped
spaceProcess space (default Root); see Distributed SystemsShipped
nodeNode assignment; * = every nodeShipped
verbose / debug / logfilePer-module verbosity, debug level and log fileShipped
selftriggerallow | warn (default) | other = never trigger on own postsShipped
tagTag appended to the name; * = own name (see Tagging)Shipped
migration / priorityMigration permission / scheduling priority — parsed but not yet acted onStub
Psyclone AIOS · CMLabs