Elisa tutorials - Writing a weather plugin

In this tutorial we go over the steps needed to create a simple Elisa plugin to fetch and show weather reports.

We assume that you are familiar with Python and know a little about Twisted, the foundation framework used by Elisa.

Create your branch and setup the environment

The easiest way to get started hacking on Elisa is to create your own branch, work on it and then publish it so that users and developers can experiment with it. Elisa uses Launchpad and can be branched with Bazaar:

bzr branch http://bazaar.launchpad.net/~elisa-developers/elisa/0.5 weather_plugin

or alternatively, using the Launchpad name scheme:

bzr branch lp:~elisa-developers/elisa/0.5 weather_plugin

The above command(s) create a directory called weather_plugin/ containing a copy of the branch of Elisa called 0.5. The 0.5 branch is where all the cool things are happening, new developers are encouraged to base their work on it.

After branching you need to setup the environment so that you can run python code inside your newly created branch:

# go inside the branch
cd weather_plugin
export PYTHONPATH=$PWD/elisa-core:$PWD/elisa-plugins:$PYTHONPATH
python -c "import elisa; print elisa.__file__"

The output should point to a file in your newly created branch.

Create your plugin

Creating a plugin requires writing a small amount of boilerplate code. As no one likes to write boilerplate code over and over, there are skeleton plugins that can be used as a starting point for writing new plugins. Here we base our work on top of the basic skeleton plugin:

cp -R elisa-plugins/elisa/plugins/skeleton/basic elisa-plugins/elisa/plugins/weather

Now that we've copied the skeleton we need to tweak the setup.py file. Open elisa/plugins/weather/setup.py, you should see this:

from setuptools import setup
from elisa.core.utils.dist import find_packages, TrialTest, Clean

raise 'Modify the file and then remove this line'

packages, package_dir = find_packages()
cmdclass = dict(test=TrialTest, clean=Clean)

setup(name='elisa-plugin-basic',
    version='0.0.1',
    description='My supercool new plugin',
    license='GPL3',
    author='Put your name here',
    author_email='put your email address here',
    namespace_packages=['elisa', 'elisa.plugins'],
    packages=packages,
    package_dir=package_dir,
    cmdclass=cmdclass)

We modify it with some info about the plugin we're writing:

from setuptools import setup
from elisa.core.utils.dist import find_packages, TrialTest, Clean

packages, package_dir = find_packages()
cmdclass = dict(test=TrialTest, clean=Clean)

setup(name='elisa-plugin-weather',
    version='0.0.1',
    description='Weather plugin',
    license='GPL3',
    author='Elisa Developers',
    author_email='elisa@lists.fluendo.com',
    namespace_packages=['elisa', 'elisa.plugins'],
    packages=packages,
    package_dir=package_dir,
    cmdclass=cmdclass)

As you can see, we changed name, description, author and author_email. You should always modify at least those fields. Note that we also removed the raise statement that was there to check that we modified the file before trying to execute it.

Now that we have a good setup.py we need to run:

# generate the directory
python elisa-plugins/elisa/plugins/weather/setup.py egg_info -e elisa-plugins/

to generate the egg-info directory for our plugin. The egg-info directory needs to be generated at the beginning and then regenerated every time setup.py changes.

How to get weather reports (the resource provider)

To write a weather plugin we need to get the weather status from somewhere. In this plugin we are going to use METAR reports from weather.noaa.org. To parse the reports we use the pymetar library.

So let's create our first component, the METAR resource provider, that we put in elisa-plugins/elisa/plugins/weather/report_provider.py:

from elisa.core.components.resource_provider import ResourceProvider
from elisa.core.components.model import Model
from elisa.core.media_uri import MediaUri
from elisa.plugins.http_client.http_client import ElisaAdvancedHttpClient
from twisted.internet import defer
from twisted.web2 import responsecode
from twisted.web2.stream import BufferedStream
from pymetar import WeatherReport, ReportParser

class WeatherReportError(Exception):
    pass

class WeatherReportModel(Model):
    uri = None
    report_uri = None
    country = ''
    city = ''
    sky = ''
    temperature = ''
    image = ''

class WeatherReportProvider(ResourceProvider):
    default_config = {'base_url':
            'http://weather.noaa.gov/pub/data/observations/metar/decoded'}

    config_doc = {'base_url': 'The URL where to get METAR data from'}

    supported_uri = 'metar://.*'

    def initialize(self):
        self.server_uri = MediaUri(self.config['base_url'])
        self.client = ElisaAdvancedHttpClient(host=self.server_uri.host,
                                              port=self.server_uri.port or 80)
        return defer.succeed(self)

    def clean(self):
        close_defer = self.client.close()
        close_defer.addErrback(lambda x: None)
        return close_defer

    def get(self, uri, context_model=None):
        model = WeatherReportModel()
        model.uri = uri

        station_file = model.uri.host + '.TXT'
        model.report_uri = self.server_uri.join(station_file)
        dfr = self.client.request(model.report_uri)
        dfr.addCallback(self._request_callback, model)

        return (model, dfr)

    def _request_callback(self, response, model):
        if response.code != responsecode.OK:
            err = '%d error fetching report from %s' % (response.code,
                    model.report_uri)
            return defer.fail(WeatherReportError(err))

        dfr = BufferedStream(response.stream).readExactly()
        dfr.addCallback(self._report_read_callback, model)
        return dfr

    def _report_read_callback(self, data, model):
        station = model.uri.host
        report = WeatherReport(station)
        report.reporturl = str(model.report_uri)
        report.fullreport = data

        # parse the report
        parser = ReportParser()
        parser.ParseReport(report)

        # populate the model
        model.country = report.getStationCountry()
        model.city = report.getStationCity()
        model.sky = report.getSkyConditions()
        model.temperature = report.getTemperatureCelsius()
        model.humidity = report.getHumidity()
        model.image = report.getPixmap()

        return model

The first thing to notice here is the supported_uri variable, which denotes the family of URIs handled by the resource provider. The URI syntax that we use is metar://ICAO_STATION_CODE (see wikipedia for more info about ICAO codes and for the list of station ids). For example the URI to retrieve the weather status of Barcelona, Spain would be metar://LEBL, where LEBL is the ICAO station id of Barcelona.

As we want to just fetch data from weather.noaa.org, we only need to implement the get() method of the resource provider API. The method is quite simple. It uses the HTTP client API in Elisa to download weather reports, parse and put them into WeatherModel objects.

The weather widget

Now that we have our resource provider to retrieve weather models, the next step is to build a nice user interface to show them. Let's create a new file called elisa-plugins/elisa/plugins/weather/ui.py containing:

import pkg_resources
import pgm
from elisa.plugins.pigment.graph.image import Image
from elisa.plugins.pigment.widgets import Widget, Label, List

class WeatherWidget(Widget):
    def __init__(self):
        super(WeatherWidget, self).__init__()

        y_offset = 0.3
        x_margin = 0.05
        y_margin = 0.01

        self._city = Label()
        self.add(self._city)
        self._city.style.background_color = (0, 0, 0, 0)
        self._city._text.weight = pgm.TEXT_WEIGHT_BOLD
        self._city.width = 0.5
        self._city.height = 0.04
        self._city.x = x_margin
        self._city.y = y_offset
        y_offset += self._city.height + y_margin + 0.05
        self._city.visible = True

        self._sky = Label()
        self.add(self._sky)
        self._sky.style.background_color = (0, 0, 0, 0)
        self._sky.width = 0.5
        self._sky.height = 0.04
        self._sky.x = x_margin
        self._sky.y = y_offset
        y_offset += self._sky.height + y_margin
        self._sky.visible = True

        self._temperature = Label()
        self.add(self._temperature)
        self._temperature.style.background_color = (0, 0, 0, 0)
        self._temperature.width = 0.5
        self._temperature.height = 0.04
        self._temperature.x = x_margin
        self._temperature.y = y_offset
        y_offset += self._temperature.height + y_margin
        self._temperature.visible = True

        self._image = Image()
        self.add(self._image)
        self._image.bg_a = 0
        self._image.height = 0.7
        self._image.width = 0.4
        self._image.x = 0.5
        self._image.y = (1.0-self._image.height)/2.0
        self._image.visible = True
        self._image_file = None

    def set_city(self, value):
        self._city.text = value
    def get_city(self):
        return self._city.text
    city = property(get_city, set_city)

    def set_sky(self, value):
        self._sky.text = value
    def get_sky(self):
        return self._sky.text
    sky = property(get_sky, set_sky)

    def set_temperature(self, value):
        self._temperature.text = str(value) + " °C"
    def get_temperature(self):
        return self._temperature.text
    temperature = property(get_temperature, set_temperature)

    def set_image(self, value):
        if value:
            filename = pkg_resources.resource_filename('elisa.plugins.weather',
                    'data/%s.png' % value)
            self._image.set_from_file(filename)
        self._image_file = value
    def get_image(self):
        return self._image_file
    image = property(get_image, set_image)

This is a really simple pigment widget to show weather status. It has an icon and three labels to print city, sky condition and temperature.

Putting it all together (the controller)

At this point we have reports and a widget to show them nicely. The only thing that's left is the code that creates the interface and periodically updates it with up to date reports. Let's put such code in elisa-plugins/elisa/plugins/weather/controller.py:

from elisa.core.components.controller import Controller
from elisa.core.components.model import Model
from elisa.extern.log.log import getFailureMessage
from elisa.core import common
from elisa.core.media_uri import MediaUri
from elisa.plugins.pigment.pigment_controller import PigmentController
from elisa.plugins.weather.report_widget import WeatherWidget
from elisa.plugins.weather.report_provider import WeatherReportModel
from twisted.internet import defer, task

from pgm.timing.implicit import *

class WeatherController(PigmentController):
    default_config = {'interval': 60, 'station': 'LEBL'}
    config_doc = {'interval': 'update interval',
            'station': 'station id (defaults to Barcelona, Spain. Guess why.)'}

    def initialize(self):
        # create an empty model
        self.model = WeatherReportModel()
        # use a looping call to fill and update self.model at regular intervals
        self.looping_call = task.LoopingCall(self._get_report)

        return defer.succeed(self)

    def clean(self):
        if self.looping_call.running:
            self.looping_call.stop()

    def set_frontend(self, frontend):
        # build the UI
        widget = self.build_widget(self.model)
        widget.visible = True
        self.widget.add(widget)
        self.animated = AnimatedObject(widget)
        self.animated.setup_next_animations(repeat_count=INFINITE,
                                            repeat_behavior=REVERSE,
                                            duration=2000)
        self.animated.y -= 0.1

        # get the station id from the configuration file
        station = self.config['station']
        if station is not None:
            interval = int(self.config['interval'])
            self.debug('starting update at interval %s' % interval)
            self.looping_call.start(interval)

    def build_widget(self, model):
        widget = WeatherWidget()
        model.bind('city', widget, 'city')
        model.bind('sky', widget, 'sky')
        model.bind('temperature', widget, 'temperature')
        model.bind('image', widget, 'image')

        return widget

    def _get_report(self):
        # get a report via the ResourceManager
        resource_manager = common.application.resource_manager
        uri = MediaUri('metar://' + self.config['station'])
        model, dfr = resource_manager.get(uri)
        dfr.addCallback(self._get_report_callback)
        dfr.addErrback(self._get_report_errback)

        return dfr

    def _get_report_callback(self, model):
        # update self.model so that the changes will be reflected in the UI
        for attribute in ('city', 'sky', 'temperature', 'image'):
            setattr(self.model, attribute, str(getattr(model, attribute)))

        return model

    def _get_report_errback(self, failure):
        self.warning('error updating weather status: %s' %
                getFailureMessage(failure))

        # ignore so that the looping call won't stop
        return None

The controller object is pretty simple. In initialize() we create a LoopingCall to periodically fetch new reports. In set_frontend(), which is called when a controller object is loaded in Elisa, we create our widget and bind a weather model to it, so that when the model is updated the changes are automatically reflected in the widget.

It's time to see our code in action now. First we need to let Elisa know that we created a new controller. We do this by adding the controller_mappings keyword in elisa-plugins/elisa/plugins/weather/setup.py so that the setup() function looks like this:

setup(name='elisa-plugin-weather',
   version='0.1',
   description='Weather plugin',
   license='GPL3',
   author='Elisa Developers',
   author_email='elisa@lists.fluendo.com',
   keywords='',
   namespace_packages=['elisa', 'elisa.plugins'],
   packages=packages,
   package_dir=package_dir,
   package_data={'': ['*.png', '*.mo', '*.po']},
   controller_mappings=[('/elisa/weather',
           'elisa.plugins.weather.weather_controller:WeatherController')],
   cmdclass=cmdclass)

and then we regenerate the egg-info directory:

python elisa-plugins/elisa/plugins/weather/setup.py egg_info -e elisa-plugins/

At this point everything is ready. We create an ad hoc elisa_weather.conf file to test our plugin:

[general]
resource_providers = ['weather.report_provider:WeatherReportProvider']
frontends = ['weather_frontend']

[weather_frontend]
frontend = 'pigment.pigment_frontend:PigmentFrontend'
controller_path = '/elisa/weather'

[weather.weather_controller:WeatherController]
station = 'LEBL'

and start Elisa with:

python elisa-core/elisa.py elisa_weather.conf

Now you know how's the weather in Barcelona.