Communication Between Controllers

Because a controller can operate in the Captivate browser and also in a local browser, it’s quite possible for you to have multiple instances of the same controller in two different places both communicating with Captivate. This can cause problems unless the two instances of the controller know about each other and are able to communicate.

In fact, there are many reasons why one controller would want to communicate directly with another controller, so Captivate provides two methods designed to address this specific situation. For example, if you have one controller that has one UI running in Captivate and another UI designed for a remote operator, you can keep both interfaces in sync by sending messages through Captivate.

Using messageIn and messageOut

Controllers can pass messages back and forth to other controllers or to other instances of themselves by using the messageOut method in combination with the messageIn signal.

To use, call the messageOut method with four arguments:

  • from - A string identifying the source of the message (usually the controller name, but may be anything).

  • to - A string identifying the target of the message (usually the other controller name, but may be anything).

  • data - A JavaScript object of key, value pairs. This may be an arbitrarily deeply nested object, but it must be an object. Strings, arrays, and other primitives will result in nothing being sent.

  • callback - A JavaScript function that will be called with the string Sent to ... when the message has been sent successfully or null when the message failed.

Note: When using messageOut, ALL subscribers to the messageIn event emitter will receive the message. If you need to send data securely, consider encrypting the data before sending.

Example:

const my_id = '12345678';
const target_id = '87654321';
const scheduler = ServiceHandler.scheduler;
scheduler.messageIn.connect((from, to, json) => {
  // filter out unwanted messages
  if (to != my_id) return;
  console.log('messageIn received');
  console.log({
    from,
    to,
    data: JSON.parse(json),
  });
});

scheduler.messageOut(
  my_id,
  target_id,
  {
    hello: 'world',
    list: [1, 2, 3],
    nested: { level: { deep: 0 } },
  },
  (e) => console.log((e === `Sent to receiver`) ? 'success!' : 'failed to send');
);

Using Targeted Commands

Instead of messageIn and messageOut, you can also use scheduleCommand to send targeted commands between controllers.

To turn a command message into a targeted command, add a to field to your parameters object. It’s also helpful to use a sender field as you'll see below.

Sending

Ordinarily, this command will return a large amount of data about the current project:

await ServiceHandler.scheduler.scheduleCommand('getTitleControlInfo', {}, {});

However, by adding a to field to the parameters object and using it to specify the name of another input, Captivate will ignore the command internally and just pass the entire payload around to the other inputs (with a few modifications, see below). If the targeted input can’t be found, Captivate will reply with an error.

Whether the other input is found or not, the command message will always be ignored by Captivate and the payload will always get sent to all other controllers listening on the notification stream.

const me = 'My Controller: Instance 2';
const target = 'My Controller: Instance 1';
await ServiceHandler.scheduler.scheduleCommand('getTitleControlInfo', { to: target, sender: me }, {});

If the specified input was found, this is the result:

{
  "command": "getTitleControlInfo",
  "reply": "getTitleControlInfo",
  "success": true
}

If the input was not found, this is the result:

{
  "command": "getTitleControlInfo",
  "reply": "getTitleControlInfo",
  "error": "No such input",
  "success": false
}

Regardless of whether the input is found, the command is ignored by Captivate and the following payload will be sent through the notification system:

{
  "command": "getTitleControlInfo",
  "to": "My Controller: Instance 1",
  "sender": "My Controller: Instance 2",
  "useJson": "1"
}

As a result, you usually want to use a custom command name that only your controller cares about:

Example:

async function requestControl() {
  const me = 'My Controller: Instance 2';
  const target = 'My Controller: Instance 1';
  await ServiceHandler.scheduler.scheduleCommand('request_control', { to: target, sender: me }, {});
}

Receiving

To receive targeted messages, you must be listening to the notification stream. Subscribing to notifications is described elsewhere, but here's a quick example again:

Example:

const me = 'My Controller: Instance 1';
let controlling = true;
ServiceHandler.scheduler.scheduleCommand('subscribe', {}, {});
ServiceHandler.scheduler.onNotify.connect((msg) => {
  /* handle notification messages here */
  const data = JSON.parse(msg);
  if (data.to === me) {
    switch (data.command) {
      case 'request_control':
        // the other instance is requesting control
        controlling = false;
      default:
        console.log(data);
    }
  }
});

In short, when using the to field, the command field is ignored by Captivate even if it matches a built-in command. So make you use custom command names, and that you check the to field in each notification message.

Payload Restrictions

The payload sent to the notification stream is assembled according to the following rules:

  • The first argument passed (command name) will populate the command field in the JSON payload.

  • All key/value pairs from the second argument, what we call parameters, will be included as-is in the final payload with the exception that non-string values will be converted to strings.

  • If any value from the parameters payload is a JavaScript object, it will not be turned into a string but dropped and replaced by an empty string: "".

  • The third argument, what we call variables, will be wrapped in a list of key/value pairs identified by the key variables.

  • The variables payload is handled differently from the parameters payload in that non-string values will be encoded to a JSON string first.

Because of this, controllers can send and receive messages with arbitrary data in the form of key/value pairs by simply adding more fields to the original message payload or by including these values in the variables argument. Here is a more thorough example:

ServiceHandler.scheduler.scheduleCommand(
  'greetings',
  {
    to: 'API Tour: JSON Command Tester',
    key1: 'hello',
    key2: 'world',
    key3: {
      hello: 'world',
    },
    key4: 1,
  },
  {
    data1: {
      hello: 'world',
    },
    data2: 'happiness',
  },
  console.log
);

This will be printed to the console (assuming the target controller exists in the project):

{
  "command": "greetings",
  "reply": "greetings",
  "success": true
}

...and this is the JSON payload that will propagate through the notification system:

{
  "command": "greetings",
  "key1": "hello",
  "key2": "world",
  "key3": "",
  "key4": "1",
  "to": "API Tour: JSON Command Tester",
  "useJson": "1",
  "variables": [
    {
      "data1": "{\n    \"hello\": \"world\"\n}\n"
    },
    {
      "data2": "happiness"
    }
  ]
}

Take note of the following:

  • key4 was a numerical value that was converted to a string.

  • key3 was a JavaScript object that was completely lost, replaced by an empty string.

  • The data from the variables argument was included as two separate, key-value objects.

  • The values for the variables objects might be encoded as pretty-printed JSON.

Therefore, to properly decode this, you will need to perform two JSON.parse operations. Once for the object as a whole, and again for the data1 value.

Last updated