CMLabs · Psyclone AIOS

14. Tutorials

Step-by-step recipes: from your first running system to custom catalogs, external processes and your own PsyProbe views.

Tutorial 1: Your first Psyclone system

The classic pingpong needs no user code — both modules use the built-in Ping crank. Save as pingpong.xml:

<psySpec>
  <module name="Ping">
    <trigger name="Ready" type="Psyclone.Ready" />
    <trigger name="Ball" type="ball.1" />
    <crank name="Ping" function="Ping" />
    <post name="Ball" type="ball.2" />
  </module>
  <module name="Pong">
    <trigger name="Ball" type="ball.2" />
    <crank name="Pong" function="Ping" />
    <post name="Ball" type="ball.1" />
    <post name="Done" type="Psyclone.Shutdown" />
  </module>
</psySpec>

Run it:

Psyclone spec=pingpong.xml

At startup Psyclone posts Psyclone.Ready, which triggers Ping; Ping posts ball.2, Pong answers with ball.1, and the ball bounces 100,000 times — a count baked into the built-in Ping crank code, not set in the XML. Note the crank declarations: in <crank name="Pong" function="Ping"/>, name is your label for this crank instance while function selects which crank code runs (here the built-in Ping in both modules). Every 10,000 messages the console logs throughput (average message time/age in µs); Pong then posts Psyclone.Shutdown. Watch the console for “SYSTEM READY”, the stats batches, and “SYSTEM SHUTDOWN” — and open http://localhost:10000/ while it runs to see the messages fly in PsyProbe.

Tutorial 2: Adding your own C++ module

Step 1 — write the crank. A crank is an exported function in a library that compiles against CMSDK alone (Psyclone is not needed to build it). Header:

#include "PsyAPI.h"
namespace cmlabs {
  extern "C" {
    DllExport int8 MyCrankFunction(PsyAPI* api);
  }
}
# mycrank.py — no header or exports needed; the cmsdk import is automatic.
# The entry point Psyclone calls is:
def PsyCrank(apilink):
    api = cmsdk.PsyAPI.fromPython(apilink)
    ...

Step 2 — implement it. A continuous crank loops while the system runs:

// MyCrank.cpp — complete, compilable crank library
// build: g++ -shared -fPIC -I<CMSDK>/include MyCrank.cpp <CMSDK>/lib/<arch>/libCMSDK.a -o libExamples.so
#include "PsyAPI.h"
using namespace cmlabs;

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

int8 MyCrankFunction(PsyAPI* api) {
  while (api->shouldContinue()) {
    DataMessage* inMsg = api->waitForNewMessage(100);
    if (!inMsg) continue;
    std::string triggerName = api->getCurrentTriggerName();
    api->logPrint(1, "Got a %s message", triggerName.c_str());
    DataMessage* outMsg = new DataMessage();
    outMsg->setInt("Count", 1);
    outMsg->setString("Status", "ok");
    api->postOutputMessage("OutputFrame", outMsg);
  }
  return 0;
}
def PsyCrank(apilink):
    api = cmsdk.PsyAPI.fromPython(apilink)
    while api.shouldContinue():
        inMsg = api.waitForNewMessage(100)
        if inMsg is None: continue
        triggerName = api.getCurrentTriggerName()
        api.logPrint(1, "Got a %s message" % triggerName)
        outMsg = cmsdk.DataMessage()
        outMsg.setInt("Count", 1)
        outMsg.setString("Status", "ok")
        api.postOutputMessage("OutputFrame", outMsg)
    return 0

Step 3 — build the library (e.g. Examples.dll / libExamples.so in the current directory; debug builds look for ExamplesDebug.dll / libExamplesDebug.so first). Step 4 — declare it:

<library name="MyExamples" library="Examples" />
<module name="MyModule">
  <trigger name="InputFrame" type="input.video.raw" />
  <crank name="MyCrank" function="MyExamples::MyCrankFunction" />
  <post name="OutputFrame" type="output.video.processed" />
</module>

Python variant. The same module declaration works with a Python crank — just swap the crank line for <crank name="MyCrank" language="python3" script="mycrank.py" /> (see Tutorial 3); no <library> element is needed.

Note. The DllExport classifier is required for symbol visibility, and the crank takes exactly one PsyAPI* parameter — your handle to messages, parameters, retrieves and queries.

Tutorial 3: Creating Python modules

Cranks can be written in Python; the full CMSDK is exposed via SWIG, so PsyAPI works as in C++. Two forms:

Inline crank — code straight in the spec; the api object is created for you:

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

Script file — point the crank at a file with a PsyCrank entrypoint:

<crank name="Ping" language="python3" script="path/to/ping.py" />
def PsyCrank(apilink):
    api = cmsdk.PsyAPI.fromPython(apilink)   # cmsdk import is automatic
    while api.shouldContinue():
        ...

Libraries and paths. The interpreter needs (1) the right Python (major version and bitness); (2) the CMSDK binary lib _cmsdk.pyd (Windows) / _cmsdk.so (Linux) plus the interface file cmsdk2.py/cmsdk3.py (debug: _cmsdkdebug.*, cmsdk2/3debug.py); (3) any extra imports. Search locations: the script's directory, the Psyclone binary's directory, or an explicit module parameter:

<parameter name="libpath" type="String" value="../CMSDK/bin/Win32" />
Warning. Python 2 crank support (language="python2") exists but Python 2 is end-of-life — treat it as legacy and use Python 3.

Tutorial 4: Creating external modules

Run a module inside your own program using an external space. In the spec, declare the space and a placeholder module whose crank has a name only:

<space name="GUISpace" type="external" />
<module name="MyGUI" space="GUISpace">
  <trigger name="Update" type="gui.update" />
  <crank name="MyGUI" />
  <post name="Click" type="gui.click" />
</module>

Your program links CMSDK, includes PsyAPI.h, and connects:

PsySpace* space = new PsySpace("GUISpace");
if (!space->connect(sysid)) { /* retry later */ }
space->start();
PsyAPI* api = NULL;
while (!(api = space->getCrankAPI("MyGUI")))
  utils::Sleep(500);            // retry loop
// use api exactly like an internal crank:
while (api->shouldContinue()) { ... }
space = cmsdk.PsySpace("GUISpace")
if not space.connect(sysid):
    pass  # retry later
space.start()
api = space.getCrankAPI("MyGUI")
while api is None:              # retry loop
    time.sleep(0.5)
    api = space.getCrankAPI("MyGUI")
# use api exactly like an internal crank:
while api.shouldContinue():
    ...

Monitor the link with space->isConnected() and space->hasShutdown(). On failure delete the space and reconnect periodically — but never delete the PsyAPI yourself (the PsySpace owns it). Call getCrankAPI once per crank if the space hosts several.

Tutorial 5: Creating your own catalog

A catalog is a crank with state plus query handling. Declare it and a query to reach it:

<catalog name="MyStore" type="MyLib::MyStoreCatalog">
  <trigger name="Ingest" type="data.sample" />
</catalog>

<module name="Client">
  <query name="Store" source="MyStore" />
  ...
</module>

Catalog crank skeleton — handle both ordinary triggers and queries:

int8 MyStoreCatalog(PsyAPI* api) {
  std::map<std::string, std::string> store;   // your state
  while (api->shouldContinue()) {
    DataMessage* inMsg = api->waitForNewMessage(100);
    if (!inMsg) continue;
    if (inMsg->getType() == PsyAPI::CTRL_QUERY) {
      uint32 id = (uint32) inMsg->getReference();
      std::string q = inMsg->getString("Query");
      DataMessage* reply = new DataMessage();
      reply->setString("Value", store[q]);
      api->queryReply(id, PsyAPI::QUERY_SUCCESS, reply);
    } else {
      store[inMsg->getString("Key")] = inMsg->getString("Value");
    }
  }
  return 0;
}
def PsyCrank(apilink):
    api = cmsdk.PsyAPI.fromPython(apilink)
    store = {}                              # your state
    while api.shouldContinue():
        inMsg = api.waitForNewMessage(100)
        if inMsg is None: continue
        if inMsg.getType() == cmsdk.PsyAPI.CTRL_QUERY:
            id = inMsg.getReference()
            q = inMsg.getString("Query")
            reply = cmsdk.DataMessage()
            reply.setString("Value", store.get(q, ""))
            api.queryReply(id, cmsdk.PsyAPI.QUERY_SUCCESS, reply)
        else:
            store[inMsg.getString("Key")] = inMsg.getString("Value")
    return 0

Callers use api->queryCatalog(&resultMsg, "Store", queryMsg, 5000) (message form) or the string form with result/datasize/query/operation, and check the returned QUERY_* status. For inspiration at production scale — world models, roles/tasks, proxy federation — see Catalogs and the CoCoMaps case study.

Tutorial 6: Adding custom data in PsyProbe

Any component can publish private data that PsyProbe shows in its Data tab and serves over HTTP. Store it with a mimetype from your crank:

std::string json = "{ \"frames\": 1024, \"fps\": 29.7 }";
api->setPrivateData("Stats", json.c_str(), json.length(), "application/json");
// binary works too, e.g. "image/bmp"; omit the mimetype to keep it invisible
json = '{ "frames": 1024, "fps": 29.7 }'
api.setPrivateData("Stats", json, "application/json")
# binary works too, e.g. "image/bmp"; omit the mimetype to keep it invisible

Fetch it from a browser or AJAX:

GET /api/getcomponentdata?compid=15&name=Stats&format=json

(formats: text, xml, json, html, binary — match how you stored it). Read it back in code with api->getPrivateDataCopy("Stats", size). For message-driven data, a RequestStore Catalog gives you GET /api/query?from=RequestStore&query=VideoFrame without writing any code.

Tutorial 7: Adding a custom tab in PsyProbe

Step 1 — put a template HTML file in the PsyProbe HTML directory (e.g. elements/mymodule.html). The minimum contract is a script defining an ID, a name, and two callbacks:

<script>
var TemplateCompID;
var TemplateCompName;
function loadTemplate($target, compID) {
  // called once; componentMap[compID] has the component info
}
function updateTemplate($target, compID) {
  // called every update cycle; poll /api/getcomponentdata or /api/query here
}
</script>

Step 2 — register the view from the component's crank:

api->addPsyProbeCustomView("My View", "elements/mymodule.html");
api.addPsyProbeCustomView("My View", "elements/mymodule.html")

The tab appears on that component in PsyProbe. The bundled elements/whiteboardmessages.html is the reference example; CoCoMaps' Cognitive Map (jCanvas 2-D map), Time Series (live streaming chart) and Control Panel (catalog-rendered HTML) tabs are three real-world styles built on this exact mechanism.

Tutorial 8: A larger multi-component example

Putting it together: a small pipeline with a whiteboard, a catalog, a Python module, a timer, and PsyProbe conveniences. This mirrors (at reduced scale) the structure of the CoCoMaps robot spec:

<psySpec>
  <variable name="DataDir" value="./data" />
  <include file="common.inc" />
  <library name="MyLib" library="Examples" />

  <!-- sensor source: posts a frame every 200 ms once kicked off -->
  <module name="Sensor">
    <trigger name="Ready" type="Psyclone.Ready" />
    <trigger name="Tick" type="Regular.Post" interval="200" />
    <crank name="Sensor" function="MyLib::SensorRead" />
    <post name="Frame" type="input.video.raw" />
  </module>

  <!-- analysis in Python, in its own space -->
  <module name="Analyser">
    <trigger name="Frame" type="input.video.raw" maxage="500" />
    <crank name="Analyser" language="python3" script="analyse.py" />
    <post name="Detection" type="analysis.detection" />
    <parameter name="Threshold" type="Float" value="[0.0:1.0]=0.8" />
  </module>

  <!-- keep detections queryable -->
  <whiteboard name="Detections" key="Confidence" keytype="float">
    <trigger name="Det" type="analysis.detection" />
  </whiteboard>

  <!-- record the run for replay -->
  <catalog name="Recording" type="ReplayCatalog" root="%DataDir%/rec1" maxcount="5000">
    <trigger name="Frame" type="input.video.raw" />
    <trigger name="Det"   type="analysis.detection" />
  </catalog>

  <!-- clickable manual test post in PsyProbe -->
  <psyprobe>
    <post name="FakeDetection" type="analysis.detection" />
  </psyprobe>
</psySpec>

Run it, open PsyProbe, watch input.video.raw flow into the Analyser and detections land on the whiteboard; use the Subscriptions tab's [test] buttons or the clickable post to inject synthetic detections; then replay %DataDir%/rec1 in a second spec that swaps the Sensor for the Recording catalog with matching post names. For the full-size version of this pattern — two robots, a federated world model, dialogue and navigation — read the CoCoMaps case study.

Tip. Every element and attribute used above is specified precisely, with status badges, in the PsySpec XML Reference.
Psyclone AIOS · CMLabs