Creating a custom widget for experts

This tutorial covers the creation of advanced custom widgets, using user-provided libraries.

This tutorial assumes that you are familiar with writing HTML, CSS and JavaScript. Additionally, some knowledge about CORS and server configuration is necessary if self-written libraries, which are not available on optiCloud shall be used.

In this tutorial, we will implement the following widget:
 dashboard
You will learn the following:
  • Dynamically load all keys from defined devices / datasources. (You don't have to define each key in the configuration)
  • Setup the advanced configuration tab
  • Load and use an external library  
The widget will load all keys from the defined datasources, which begin with the term defined in the advanced configuration tab. It will then dynamically build the GUI.
 

Creating a new widget and group

Open Administration -> Widget Library and click on the create button. Define the requested information.  
From there on, proceed to create a new widget and set it's name. The type we will be using is "Latest value". This is not as important, as we are able to access timeseries, latest data and attributes from there on. We will however only be accessing the latest datapoint, meaning we select latest.

Adding an advanced configuration

As described in the intro, we will configure the shown keys with the advanced widget configuration. To enable advanced configurations, open General in the widget editor and make sure that Settings form selector is blank.  
settings_form_selector
Continue to open the Settings schema tab. For this widget, we configure a string that lets us select the beginning of a key (for example, all keys that start with tutorial)
{
    "schema": {
        "type": "object",
        "title": "Tutorial",
        "required": [],
        "properties": {
            "keyBegin": {                       // The key of the setting, which is later needed to access inside code
                "title": "Beginning of key",    // The title shown above the textbox
                "type": "string",               // The type, also contains some verification (i.E. for numbers)
                "default": ""                   // A default value which is there on creation
            }
        }
    },
    "form": [ "keyBegin" ]
}

Loading selfwritten libraries

The gauge library is a small, selfwritten library consisting of a plain Javascript and a CSS file. Both of these files must be uploaded to a server which provides these files. Be sure to add a opticloud.io to your Access-Control-Allow-Origin header.

Preparing the widget to be loaded by optiCloud

optiCloud does not yet support module loading via the import() function call. This means, that the library cannot be loaded dynamically easily. The library can however be attached to window, which is globally available. In example of the gauge library, this would look like the following:
// Start of gauge implementation
export class Gauge { /* ... */ }
// End of gauge implementation
window.GaugeLibrary = Gauge; // <- this line is required
When the widget is loaded with below code, it is executed and a gauge instance is attached to window. A new instance can be instantiated using const gauge = new window.GaugeLibrary(...);.

Loading the library to optiCloud

To load the widget in optiCloud it, a script element in HTML is created. Additionally, we also load the respective CSS to the file.
self.loadLibrary = (onLoad) => {
    // JAVASCRIPT
    const script = document.createElement('script');
    script.src = GAUGE_LIBRARY_URL;
    script.type = 'module';
    script.onload = () => {
        self.ctx.Gauge = window.GaugeLibrary;
        onLoad();
    };
    document.head.appendChild(script);

    // CSS
    const CSS  = document.createElement('link');
    CSS.rel  = "stylesheet";
    CSS.href = GAUGE_CSS_URL;
    document.head.appendChild(CSS);
};

This loads the widget from GAUGE_LIBRARY_URL, adds it to self.ctx and finally calls the passed callback onLoad. Saving the implementation to self.ctx is optional, however makes the code more readable. onLoad is required. We can only begin to build the widget when the library is actually loaded. Otherwise, the gauge would not be defined yet and an error would be thrown.  

Finally, the CSS is loaded and appended to the document as a link in the head, like a normal CSS would.

 

Loading the library can now be added to the onInit method.

self.onInit = () => {
    self.loadLibrary(() => {/* continue further work here */});
};

 

Dynamically subscribe to keys

With the library ready, we can proceed to dynamically load all the keys of interest and build the user interface.

Read advanced settings

To do this, we first need to read the advanced setting, as this defines which keys we want to use. All settings are available under self.ctx.settings. The setting of interest is available under the property's object name defined, which in our case is keyBegin.

Read all keys of all defined devices

Afterwards, we access all keys of the assigned devices (called datasources) and check which keys the datasource has that start with the in keyBegin defined string. To receive all keys of all configured datasources, we make use of the telemetryService's getTelemetryKeys(datasource) method.
self.ctx.datasources.forEach(datasource => {
    self.ctx.telemetryService.getTelemetryKeys([datasource.entity.id])
        .subscribe((keys) => /* do work */)
});
Because the method returns an observable (not a promise), we have to use subscribe. This will execute the defined callback as soon as the function returns all values.

 

Filtering and processing the keys

The key filtering uses plain Javascript. Since the keys are arrays, we can use filter to only allow keys which start with keyBegin:
    keys = keys.filter(k => k.startsWith(self.ctx.settings.keyBegin));
The keys have to be further processed in order to be accepted by the subscription API. It additionally requires the datasource ID and the type of the key values. This is saved into an object and later passed as array to the subscription api.
const keysProcessed = {
    type:       datasource.type,
    entityType: datasource.entity.id.entityType,
    entityId:   datasource.entity.id.id,
    dataKeys:   [].concat(keys.map(k => ({ "name": k , "type": "timeseries" })))
    // "name": Actual key
    // "type": Type, which is usually timeseries (could also be attribute)
}

Setting up the user interface

This will be explained further in the tutorial. For now, let's assume there is a method self.addGauges(keys), which automatically sets up the gauges depending on the given keys.

Subscribing to updates

The subscription API is a service that allows to subscribe to given keys in a datasource. On every data update, a callback will automatically be called until the subscription is terminated. We have to further configure some parameters:
1. type: The type of data. We use "LATEST" to subscribe to the most recent data.
2. datasources: Defines which datasources and keys to subscribe to
3. limit: How many latest values shall be returned.
4. callbacks: An object that defines the methods to call on certain events
// Create the subscription options. The latest data of all datasources
// is reported to the self.onDataUpdated method
const subscriptionOptions = {
    type: "LATEST",
    datasources: [keysProcessed],
    limit: 1,
    callbacks: {
        onDataUpdated: (values) => self.onDataUpdated(values)
    }
};
// Subscribe. Now, the onDataUpdated callback will be called.
const sub = self.ctx.subscriptionApi.createSubscription(subscriptionOptions, true);
Finally, we create the subscription, starting the data production.

Ending a subscription

The call to createSubscription returns a subscription. The subscription can be cancelled by calling following method:
self.ctx.subscriptionApi.removeSubscription(sub.id);
This is not used in this tutorial, it is however worth mentioning.

Putting all together

self.subscribeKeys = () => {
    self.ctx.datasources.forEach(datasource => {
        self.ctx.telemetryService.getTelemetryKeys([datasource.entity.id])
            .subscribe((keys) => {
                keys = keys.filter(k => k.startsWith(self.ctx.settings.keyBegin));
                self.addGauges(keys);
                const subscriptionOptions = {
                    type: "LATEST",
                    useDashboardTimewindow: true,
                    limit: 1,
                    datasources: [{
                        type:       datasource.type,
                        entityType: datasource.entity.id.entityType,
                        entityId:   datasource.entity.id.id,
                        dataKeys:   [].concat(keys.map(s => ({ "name": s , "type": "timeseries" })))
                    }],
                    callbacks: {
                        onDataUpdated: (values) => self.onDataUpdated(values)
                    }
                };
                self.ctx.subscriptionApi.createSubscription(subscriptionOptions, true);
        });
    });
};
Now all required keys are subscribed and updates are reported to the self.onDataUpdated method. To activate it, we add it to the onInit method. Since we instantiate the gauges here, they need to be loaded already, meaning we need to make this call as the callback after the library has been loaded.
self.onInit = () => {
    self.loadLibrary(self.subscribeKeys);
};

Setting up the gauges

 

We now have the keys subscribed and the data of them are being published to self.onDataUpdated. Before we can do this however, we need to create our view. Since this is basic Javascript, we are not going to investigate this code deeply. First, add a container with a unique ID to the HTML section of the widget configurator.
<div id="gauges-container"></div>
Additionally, we need some basic CSS:
#gauges-container           { display: flex; }
#gauges-container > div     { margin: 1rem; }
#gauges-container > div > p { text-align: center; }

 

Afterwards, we add the Javascript. The below script creates a subcontainer for each gauge, adds it and additionally provides a title for each gauge. The objects created are saved into self.ctx.gauges, an object which contains a gauge for each key.
self.addGauges = (keys) => {
    const container = document.getElementById("gauges-container");
    keys.forEach(k => {
        // Create a subcontainer that contains the gauges and the respective titles
        const subcontainer = document.createElement("div");
        container.appendChild(subcontainer);
        // Create gauge
        self.ctx.gauges[k] = new self.ctx.Gauge(subcontainer);
        // Create title
        const gaugeTitle = document.createElement("p");
        subcontainer.appendChild(gaugeTitle);
        gaugeTitle.textContent = k;
    });
}
Gauges can now be accessed with self.ctx.gauges[KEY].

Reacting to updates

As we already defined, updates are sent to the self.onDataUpdated method. The new data is conveniently passed to the method, so we now only need to read the data and update our gauges.
self.onDataUpdated = (updates) => { 
    updates.data.forEach(d => {
        const key       = d.dataKey.name; 
        const timestamp = d.data[0][0];
        const value     = d.data[0][1];
        self.ctx.gauges[key].updateValue(value);
    });
}
The updates object contains another object data. Inside of it, we can access all relevant information we need to update our widget.  
  • dataKey contains information about the data's key, name, device etc. Name returns the key
  • data contains the actual data. If we have limit > 1, the first dimension of the array returns the respective point in data.
Although we do not use timestamp, it is still saved in a variable for this tutorial.

Conclusion

This tutorial has explained how you can create dynamic, advanced widgets using optiCloud. This is only a simple advanced application. Since any library can be loaded, the possibilities are endless.  device_widget

Keep in mind that it is generally a good idea to not do too much inside the optiCloud Javascript widget editor, as it has no file capabilities and can get difficult to maintain quickly.