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 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 syntax | Meaning
--- | ---
`42` | Plain value
`[0:110]` | Range; initial value = lowest
`[0:110]=55` | Range 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

Attribute | Meaning | Status
--- | --- | ---
`name` | Component name (required) | Shipped
`space` | Process space (default `Root`); see Distributed Systems | Shipped
`node` | Node assignment; `*` = every node | Shipped
`verbose` / `debug` / `logfile` | Per-module verbosity, debug level and log file | Shipped
`selftrigger` | `allow` | `warn` (default) | other = never trigger on own posts | Shipped
`tag` | Tag appended to the name; `*` = own name (see Tagging) | Shipped
`migration` / `priority` | Migration permission / scheduling priority — parsed but not yet acted on | Stub

PreviousCore Concepts NextMessaging & Signals