Gateway plugins

From OpenMotics
Jump to: navigation, search

The plugin system on the OpenMotics Gateway allows users to run python code on the gateway. This code can interact with the OpenMotics Master through the webservice, expose new methods on the webservice, receive events for input and output changes and run background tasks.

Writing a plugin

The plugins are written in Python 2.7 and creating a new one is as easy as extending the OMPluginBase. Three class variables should be defined: name (String), version (String with format x.y.z with x,y,z are integers) and interfaces (a list of tuples (interface name, version)). You will find more information about the interface system below.

class MyPlugin(OMPluginBase):
 
    name = 'MyPlugin'
    version = '0.0.1'
    interfaces = [ ('webui', '1.0') ]
 
    def __init__(self, webinterface, logger):
        super(MyPlugin, self).__init__(webinterface, logger)

The constructor takes 2 arguments: webinterface and logger. The webinterface can be used for executing actions gateway api commands and is a reference to the WebInterface (see gateway/webinterface.py or Webservice API). The logger is a function that can be used to send a string to the plugin log. This log can be accessed using the WebInterface (see get_plugin_logs).


The @om_expose decorator

Decorator to expose a method of the plugin class through the webinterface. The url will be https://OpenMotics.local/plugins/{plugin-name}/{method}.

Normally an authentication token is required to access the method. The token will be checked and removed automatically when using the following construction:

@om_expose
def method_to_expose(self, ...):
...

It is possible to expose a method without authentication: no token will be required to access the method, this is done as follows:

@om_expose(auth=False)
def method_to_expose(self, ...):
...


The @input_status decorator

Decorator to indicate that the method should receive input status messages. The receiving method should accept one parameter, a tuple of (input, output). Each time an input is pressed, the method will be called.

Important ! This method should not block, as this will result in an unresponsive system. Please use a separate thread to perform complex actions on input status messages.

@input_status
def recv_input_status(self, status):
    (input, output) = status
    ....


The @output_status decorator

Decorator to indicate that the method should receive output status messages. The receiving method should accept one parameter, a list of tuples (output, dimmer value). Each time an output status is changed, the method will be called.

Important ! This method should not block, as this will result in an unresponsive system. Please use a separate thread to perform complex actions on output status messages.

@output_status
def recv_output_status(self, status):
    for s in status:
        (output, dimmer_level) = s
        ....


The @receive_events decorator

Decorator to indicate that the method should receive event messages. The receiving method should accept one parameter: the event code. Each time an event is triggered (by basic action 60), the method will be called.

Important ! This method should not block, as this will result in an unresponsive system. Please use a separate thread to perform complex actions on event messages.

@receive_events
def recv_events(self, code):
    print "Got an event with code %d" % code
    ....


The @background_task decorator

Decorator to indicate that the method is a background task. A thread running this background task will be started on startup.

@output_status
def run(self):
    ....


The @on_remove decorator

Decorator to indicate that the method should be called just before removing the plugin. This can be used to cleanup files written by the plugin. Note: the plugin package and plugin configuration data will be removed automatically and should not be touched by this method.

@on_remove
def uninstall(self):
    ....

Interfaces

The purpose of the interface system is that generic interface can be defined, those interfaces can be implemented by the plugins. For example an audio interface could define methods for setting the channel, the volume and so on. An concrete implementation of the audio interface can then be created for each type of audio device. This allows for a generic web page for controlling audio devices with different backends for different audio devices.

An interface defines a set of methods on the plugin that are exposed on the webinterface. At the moment only two interfaces are defined:

The webui interface

The webui interface allows the user to expose a html page on the gateway web interface. The gateway web interface shows an extra tab for each plugin that implements the webui interface, the tab contains an iframe with the content of the 'html_index' method.

The webui defines 1 method: 'html_index' that requires authentication and takes no arguments. For example:

@om_expose
def html_index(self):
    return "<html><body>Hello world</body></html>"


The config interface

The config interface provides a generic way to configure a plugin. It exposes a method that returns the configuration description (this contains the fields and types in the configuration), a method for setting and a method for getting the configuration. These methods should return json serialized objects.

The config interfaces contains:

  • get_config_description: returns a list of objects (each object defines one field in the configuration).
  • get_config: returns the configuration object
  • set_config(config): config is the configuration object, returns { 'success' : true }

See the #Plugin configuration below for more information about the configuration and configuration description objects.

Plugin configuration

The PluginConfigController enables the plugin creator to easily implement the 'config' plugin interface.

PluginConfigController

The PluginConfigController is able to verify if a configuration dict matches a description, when a configuration description is provided. The description is a list of dicts, each dict contains the 'name', 'type' and optionally 'description' keys.

These are the basic types: 'str', 'int', 'bool', 'password', these types don't have additional keys. For the 'enum' type the user specifies the possible values in a list of strings in the 'choices' key.

The complex types 'section' and 'nested_enum' allow the creation of lists and conditional elements.

A 'nested_enum' allows the user to create a subsection of which the content depends on the choosen enum value. The 'choices' key should contain a list of dicts with two keys: 'value', the value of the enum and 'content', a configuration description like specified here.

A 'section' allows the user to create a subsection or a list of subsections (when the 'repeat' key is present and true, a minimum number of subsections ('min' key) can be provided when 'repeat' is true. The 'content' key should provide a configuration description like specified above.

An example of a description:

description =  [
        { 'name' : 'hostname', 'type' : 'str',      'description': 'The hostname of the server.' },
        { 'name' : 'port',     'type' : 'int',      'description': 'Port on the server.' },
        { 'name' : 'use_auth', 'type' : 'bool',     'description': 'Use authentication while connecting.' },
        { 'name' : 'password', 'type' : 'password', 'description': 'Your secret password.' },
        { 'name' : 'enumtest', 'type' : 'enum',     'description': 'Test for enum', 'choices': [ 'First', 'Second' ] },
 
        { 'name' : 'outputs', 'type' : 'section', 'repeat' : true, 'min' : 1, 'content' : [
            { 'name' : 'output', 'type' : 'int' }
        ] },
 
        { 'name' : 'network',  'type' : 'nested_enum', 'choices' : [
            { 'value': 'Facebook',  'content' : [ { 'name' : 'likes', 'type' : 'int' } ] },
            { 'value': 'Twitter',  'content' : [ { 'name' : 'followers', 'type' : 'int' } ] }
        ] }
    ]

The following methods are defined on the PluginConfigChecker:

pcc = PluginConfigChecker(description)

Creates a PluginConfigChecker using a description. If the description is not valid, a PluginException will be thrown.

pcc.check_config(config)

Check if a config is valid for the given description. Raises a PluginException if the config is not valid

Provided methods on OMPluginBase

The OMPluginBase class provides a methods for reading and a method for writing the plugins configuration. The config file contains a json searialized dict and is stored in /opt/openmotics/etc/pi_<plugin_name>.conf.

self.read_config(default_config=None)

Read the configuration file for the plugin: the configuration file contains json string that will be converted to a python dict, if an error occurs, the default config is returned. The PluginConfigChecker can be used to check if the configuration is valid, this has to be done explicitly in the Plugin class.

self.write_config(config)

Write the plugin configuration to the configuration file: the config is a python dict that will be serialized to a json string.

Putting it all together

Below is the code for a logging plugin that keeps track of the last 100 events (here an event is an input or output change). Create a file main.py containing the following content:

from plugins.base import om_expose, input_status, output_status, OMPluginBase, PluginConfigChecker
from datetime import datetime
import simplejson as json
 
class Logger(OMPluginBase):
    """ This example plugin shows in memory logging of the last input and output status events.
    The plugin can be configured to disable or enable the logging of input and output events.
    """
 
    name = 'Logger'
    version = '1.0.0'
    interfaces = [ ('webui', '1.0'), ('config', '1.0') ]
 
    config_descr = [
        { 'name' : 'log_inputs',  'type' : 'bool', 'description': 'Log the input data.'  },
        { 'name' : 'log_outputs', 'type' : 'bool', 'description': 'Log the output data.' }
    ]
 
    default_config = { 'log_inputs' : True, 'log_outputs' : True }
 
    def __init__(self, webinterface, logger):
        super(Logger, self).__init__(webinterface, logger)
 
        self.__config = self.read_config(Logger.default_config)
        self.__config_checker = PluginConfigChecker(Logger.config_descr)
 
        self.__logs = []
 
        self.logger("Started Logger plugin")
 
    def __check_length(self):
        if len(self.__logs) >= 100:
            self.logger("Chopping the logs")
            self.__logs = self.__logs[-100:]
 
    @input_status
    def recv_input_status(self, status):
        if self.__config['log_inputs']:
            self.__check_length()
            self.__logs.append("%s -- Input pressed: %s -- Output changed: %s" % (datetime.now(), status[0], status[1]))
 
    @output_status
    def recv_output_status(self, status):
        if self.__config['log_outputs']:
            self.__check_length()
            self.__logs.append("%s -- Outputs on: %s" % (datetime.now(), status))
 
    @om_expose
    def html_index(self):
        return "<br/>".join(self.__logs)
 
    @om_expose
    def get_config_description(self):
        return json.dumps(Logger.config_descr)
 
    @om_expose
    def get_config(self):
        return json.dumps(self.__config)
 
    @om_expose
    def set_config(self, config):
        config = json.loads(config)
        self.__config_checker.check_config(config)
        self.write_config(config)
        self.__config = config
        return json.dumps({ 'success' : True })

After packaging and installing, the exposed html_index method can be accessed by surfing to eg. https://OpenMotics.local/plugins/Logger/html_index?token={token}. A token can be obtained by surfing to https://OpenMotics.local/login?username={user}&password={pass}. For more information on creating an account see Webservice API.

The plugin will also show up in the tabs on the gateway web interface on https://OpenMotics.local/.


Packaging a plugin

A plugin is packaged as a tgz file containing the content of the plugin package. The plugin class should be defined in main.py and an __init__.py file should be provided. Extra python files can be included in the package. The following commands can be used to create a package and to calculate the md5 sum (required for the plugin installation):

tar czf plugin.tgz main.py __init__.py
md5sum plugin.tgz


Installing a plugin

The easies way to install a plugin is surfing to https://OpenMotics.local/, logging in (or the first time: creating an account using the button on the gateway), going to the plugin tab and uploading the tgz file created in the step above and filling in the md5 sum for the package.

Installing the plugin can be automated using install_plugin on the gateway api. See the Webservice API for more information.