Example Driver Walk-through

The WOCustomDrvr.ts file demonstrates how to write a driver for a device that provides a TCP "socket" based control protocol.

This example driver communicates with Dataton WATCHOUT production software, controlling some basic functions. It's provided only as an example, since Blocks already has full support for controlling WATCHOUT built-in. Using WATCHOUT as an example device has some distinct advantages:

  1. Many of you are already familiar with WATCHOUT.
  2. The production software UI clearly shows what's going on.
  3. It's available as a free download, so anyone can use it to try out the driver.

Open the driver using a code editor, and follow along in the code as you read this walk-through.

Import Statements

At the very top of the file, there are import statements, referencing other files and their content.

import {NetworkTCP} from "system/Network";
import {Driver} from "system_lib/Driver";
import * as Meta from "system_lib/Metadata";

These other files provide definitions of system functions used to communicate with the device, such as the NetworkTCP interface imported from the "system/Network" file. You can navigate to referenced classes or files by command-clicking (Mac) or control-clicking (Windows) these items in the editor. The referenced files contain the relevant declarations, and often also contain comments that further describe the referenced functions.

Not only does this provide relevant documentation. It also provides information for the TypeScript compiler as well as for the code editor. The code editor uses this information to guide you when writing code, often providing hints as you type, showing available functions, parameters, etc. If you make a mistake, this is often highlighted in red by the code editor and/or flagged by the TypeScript compiler.

Driver Class Declaration

Following the import statements you'll find a driver class declaration:

@Meta.driver('NetworkTCP', { port: 3040 })
export class WOCustomDrvr extends Driver<NetworkTCP> {

This declares the driver named WOCustomDrvr. This class name must match the name of the file it is stored in (minus its .ts extension). All names are case sensitive. This class must have the export keyword in order to be accessible from the outside by Blocks. It must extend the Driver base class, which must be parameterized by either the NetworkTCP or NetworkUDP type, indicating the type of socket being used.

The socket type must also be specified as the first (string) parameter to the @Meta.driver decorator, which must be applied to the class. The second parameter is an object, which for a network driver may specify the default port number typically used by the device being controlled. If specified, this port will be automatically entered in the Blocks user interface when selecting this driver, removing the need for the user to figure this out.

Instance Variables

At the top of the class, you typically define any variables used by the driver to keep track of its state.

private pendingQueries: Dictionary<Query> = {};
private mAsFeedback = false;    // Set while processing feedback, to only fire events

private mConnected = false;    // Connected to WATCHOUT
private mPlaying = false;    // Most recent state (obtained from WO initially)
private mStandBy = false;
private mLevel = 0;        // Numeric state of Output

private mLayerCond = 0;

Variables are typed; either explicitly, as the pendingQueries which is of type Dictionary<Query>, or implicitly by assigning them a value.

A driver may be used by multiple devices (for instance, controlling three projectors, all of the same brand and model). In this case, each driver instance will get its own private set of instance variables.

Constructor

The driver must have a constructor function.

public constructor(private socket: NetworkTCP) {
    super(socket);
    ...

The driver constructor has a single parameter of the type specified as part of the class declaration. In this example, this parameter is the NetworkTCP object, subsequently used to communicate with the TCP socket.

This parameter must be passed to the Driver base class using the super keyword, as shown.

Event Subscriptions

The constructor is a good place for one-time initializations, such as event subscriptions. The example driver subscribes to the 'connect' and 'textReceived' events.

socket.subscribe('connect', (sender, message)=> {
    this.connectStateChanged();
});

Event subscriptions are similar to how addEventListener is used in web browsers to listen to DOM events. The 'connect' event indicates that the connection status of the socket has changed or failed (as indicated by the type field of the message parameter). All events are described in the Network.ts file, so always look there for full details. Jump directly to the relevant declaration by command/control-clicking a subscribe call.

When the subscribed-to event occurs, the function body following the lambda ⇒ operator will be invoked. Either do what needs to be done right here, if its only a line or two, or call a function defined elsewhere in the driver.

Properties

A key concept of a driver is to encapsulate the intricate details of a device-specific communications protocol, and instead expose the device's functionality as relevant properties. For a projector, relevant properties may be:

  • Power on/off
  • Current input selection

The power property of a projector, just like the connected property of the example driver is an on/off state, naturally represented by a boolean data type.

Property Accessors

A property is internally represented by one or two accessor functions. These use the keywords get and set, followed by the name of the property. The set function will be called when the property is set, and the get function will be called when its current value is requested.

@Meta.property("Layer condition flags")
public set layerCond(cond: number) {
    if (this.mLayerCond !== cond) {
        this.mLayerCond = cond;
        this.tell("enableLayerCond " + cond);
    }
}
public get layerCond(): number {
    return this.mLayerCond;
}

The example defines a property named layerCond, which is numeric. When set it is stored in the mLayerCond instance variable, so the driver can keep track of the most recently set state, and return this in the get function also shown above. In addition to storing it, the setter also calls the tell function to tell WATCHOUT about this using the enableLayerCond protocol command.

Some properties may not be controllable, but could still make sense to read out. For a projector, a "lamp failure" indication fits the bill here. In this case, you only need to provide the get function, since it makes no sense to set this state from outside. This makes the property read-only, and you will not be able to assign a button to set it from the outside.

In some cases, you may want to create a property that can be set from inside the driver itself, but should be read-only from the outside. In this case, provide a set function and then mark it explicitly as re-only using the second parameter to the @Meta.property decorator. This is used for the connected property in the example driver, since the driver uses Blocks autoConnect feature (this is requested in the constructor function).

Property Decorators

To expose a property in the Blocks user interface, mark it using the @Meta.property decorator. This allows you to provide a short text, describing the property, that will be shown in the Blocks user interface when accessing the property. The second parameter of the @Meta.property decorator can be set to true to explicitly mark the property as read-only, as mentioned above

For a numeric property, you can also use min and max decorators, as shown for the input property in the example driver, specifying the minimum and maximum numeric value the property may take.

@Meta.property("Generic input level")
@Meta.min(0)
@Meta.max(1)
public set input(level: number) {
    this.tell("setInput In1 " + level);
    this.mLevel = level;
}
public get input() {
    return this.mLevel;
}

Callable Functions

Whenever possible, use properties to expose device features. This allows the feature to be directly bound to a button, slider, or similar panel control. Properties can also be controlled from tasks, and can be used to trigger tasks, or as part of task conditions.

However, some features of a device or protocol may not allow themselves to be exposed as a simple property value. Such features can instead be exposed as a callable function.

@Meta.callable("Play or stop any auxiliary timeline")
public playAuxTimeline(
    @Meta.parameter("Name of aux timeline to control") name: string,
    @Meta.parameter("Whether to start the timeline") start: boolean
) {
    this.tell((start ? "run " : "kill ") + name);
}

This example exposes the ability to start and stop an arbitrary timeline by name. While it would be possible to expose a property for each timeline to control this behavior, that would require you to:

  • Know the name of each timeline ahead of time, as you write the driver.
  • Provide separate get/set functions for the running state of each timeline.

In some cases this is neither possible nor desirable. Then you can instead provide a function, taking the desired number of parameters of the desired types. The playAuxTimeline function shown above accepts two parameters:

  1. name, a string specifying the name of the timeline.
  2. start, a boolean specifying whether to start (true) or stop (false) the timeline.

A function can take any number of parameters of different types. A function marked as callable using the decorator shown above will be exposed for use from tasks. The description you provide for the callable decorator, as well as the parameter decorators will be shown in Blocks when editing the task.

:!: While a function can be called from a task and scripts, it can not be directly linked to a button or slider, or used as a task trigger or condition.

Internal Functions

You may want to define functions that are only used inside the driver, as a way of making the code more modular or self documenting, or to define some internal functionality not directly exposed to buttons and tasks. Such functions are marked as private.

/**
 Connection state changed. If became connected, poll WO for some initial
 status. Called as a result of the 'connect' subscription done in the
 constructor.
 */
private connectStateChanged() {
    this.connected = this.socket.connected; // Propagate state to clients
    if (this.socket.connected)
        this.getInitialStatus();
    else
        this.discardAllQueries();
}

The connectStateChanged function is called from the 'connect' subscription mentioned earlier in this document. The code in this function could instead have been placed directly inside the 'connect' subscription's body. But breaking it out into its own function like this makes the code more readable and potentially reusable.

Parsing Data

A device driver typically does three basic things.

  • Exposing desired functionality of the device through properties and callable functions, allowing them to be easily accessed from other parts of Blocks.
  • Sending commands to the device to control it according to how those properties are set or functions are called,
  • Receiving data coming back from the device, indicating its current state, error conditions, or similar.

This last point is often the hardest to get right, and often involves the following steps:

  1. Provoke the device to send the data you're interested in. In most cases, devices only send data in response to commands. This can either be an acknowledgement of the command (or an error message if the command failed for some reason), or a reply to a question posed by the command.
  2. Parse out the part of the data coming from the device you're interested in.
  3. Act intelligently on the data. E.g., handle any errors, or update exposed properties according to a change of some state in the device.

For devices that communicate using a text based protocol, regular expressions often come in handy when parsing the data.

private static kReplyParser = /\[([^\]]+)\](\w*)[\s]?(.*)/;

Regular expressions are directly supported by the scripting language. Just as strings are delimited by single or double quotes, regular expression are delimited by a forward slash.

While the regular expression syntax is quite powerful, it is also often hard to read and understand. When developing and testing a regular expression, you may therefore want to use an online service such as this one. Here you can enter sample data, representative of what's sent by the device, and work on the regular expression until it does what you want. Then copy the resulting expression and paste it into the driver.

The textReceived function uses the regular expression shown above to parse replies received from WATCHOUT, handing the results off to their associated queries for further processing.

private textReceived(text: string) {
    const pieces = WOCustomDrvr.kReplyParser.exec(text);
    if (pieces && pieces.length > 3) {
        const id = pieces[1];
        const what = pieces[2];
        const query = this.pendingQueries[id];
        if (query) {
            delete this.pendingQueries[id]; // Now taken
            query.handleResult(what, pieces[3]);
        } else // No corresponding query found
            console.warn("Unexpected reply", text);
    } else
        console.warn("Spurious data", text);
}

The fact that WATCHOUT allows you to tag each question, and then returns that same tag in the associated reply makes it easy to match them up. That's not always the case. Also, WATCHOUT accepts numerous commands sent back to back, possibly intermixed with questions. Many devices have limits on how fast you can send commands. Sometimes you need to wait for a command to be acknowledged by some returned data before you can send further commands. In other cases, you may have to limit how fast you send commands, and the maximum command rate may not be well documented.

Promises

Communicating with a device often means sending a command at one point in time that results in a reply or other action later on. For example:

  • Asking a question that will be replied to by the device in due course.
  • Giving a command that is expected to cause some result, and then doing something else if the result hasn't arrived in time.

Since you can not (and must not) have your code sit tight and wait for the answer to come back, or for the maximum time to elapse, you must instead use a Promise to deal with such situations.

private ask(question: string): Promise<string> {
    if (this.socket.connected) {
        const query = new Query(question);
        this.pendingQueries[query.id] = query;
        this.socket.sendText(query.fullCmd);
        return query.promise;
    } else
        console.error("Can't ask. Not connected");
}

A promise is an object that tracks the future outcome of the action. When this future outcome becomes clear, the promise will call a function provided by you. Alternatively, if the action fails, it calls another function to deal with that situation.

The getInitialStatus function shows how a promise can be used.

private getInitialStatus() {
    this.ask('getStatus').then(reply => {
        this.mAsFeedback = true; // Calling setters for feedback only
        const pieces = reply.split(' ');
        if (pieces[4] === 'true') {    // Show is active
            // Go through setters to notify any change listeners out there
            this.playing = (pieces[7] === 'true');
            this.standBy = (pieces[9] === 'true');
        }
        this.mAsFeedback = false;
    });
}

Here you can see how the then function is called to pick up the outcome of the question asked. Note that the body inside the function won't be invoked until the reply to the getStatus question comes back from WATCHOUT.

Custom Classes

For more complex functionality, you can define your own classes inside a driver, or in a library module (a separate TypeScript file store in the script/lib directory). Define the class inside the driver file if it's only used by that driver. Store more general purposes classes in ther own files in the script/lib directory, allowing them to be used from multiple drivers.

class Query {
    ...

Just like the driver itself, such classes define their own instance variables, constructor and functions. You instantiate objects of your custom class using the new keyword, as shown above in the example under Promises.