The SIGHTS software suite is designed to be very extensible when it comes to adding new sensors to your robot. We achieve this by using special Python classes called sensor wrappers to collect sensor data, and JavaScript classes called Graphs to display it. SIGHTS is shipped with many useful sensor wrappers for a lot of common sensors, but to use some less common sensors, you may need to write your own wrappers and graphs.
In this blog post, I’ll unpack one of the bundled SIGHTS sensor wrappers to show you how you can create your own. If you want to learn how to create a graph, check out the next post in this series.
What is a Sensor Wrapper?
As I briefly outlined above, a sensor wrapper is a Python class used to collect sensor data from a sensor. A sensor wrapper extends the SensorWrapper
class and can import any other library to do the heavy lifting – the idea is that you don’t need to write libraries specifically for SIGHTS.
Take a look at the SensorWrapper
class in sensor_wrapper.py
(view on GitHub).
__init__(self, config)
The sensor constructor. Here we set up the logger for the sensor and an instance attribute to track when the sensor was last polled.
When a sensor is created by the SensorStream
class, the sensor is provided with its definition in the configuration file. The SensorWrapper
class handles a few required config values such as the sensor name and whether the sensor is enabled, but you can implement more configuration settings in your __init__
function when you extend SensorWrapper
(which we will see when we create our own sensor wrapper below).
get_data(self)
and get_initial(self)
These functions get data from the sensor. Both are unimplemented by the SensorWrapper
class. get_initial()
is called once when the sensor is initialised. get_data()
is called every time the sensor is ready to be polled. Both are optional, but in most cases, you’ll want to implement at least get_data()
.
is_ready(self, now)
Given the current UNIX timestamp (now
), is_ready()
returns true if the sensor is ready to be polled using the period defined in the config.
Writing a Sensor Wrapper
Now we can write a sensor wrapper for a sensor that extends the SensorWrapper
class described above. We’ll go through the DiskUsageWrapper
(view on GitHub) since it works on all devices (no extra sensors required) and it implements all the functions in the SensorWrapper
class.
Create your new Python file in the sights/src/sensors
directory. You can name this whatever you want, but it’s probably a good idea to follow the existing convention with sensorname_wrapper.py
.
First we import the required modules and libraries. The only required line here is the one importing SensorWrapper
. We also import psutil
because we’ll be using that to check the disk usage.
1 2 | from sensor_wrapper import SensorWrapper import psutil |
If any of the modules you import aren’t part of the Python Standard Library, don’t forget to add them to requirements.txt
in sights/src
so that the required library will be installed by pip during the installation process.
Now create a class extending the SensorWrapper class. It’s a good idea to follow the existing naming convention, with the name of the sensor wrapper in PascalCase.
The type_
attribute defines the name of the sensor type in the config. This means when a “disk_usage” sensor appears in a config file, SIGHTS will know to create a new DiskUsageWrapper to handle it. Check out the init function in the SensorStream
class to see how this works (view on GitHub).
1 2 | class DiskUsageWrapper(SensorWrapper): type_ = 'disk_usage' |
Now we can implement the functions in SensorWrapper
. First up is the init function. Usually, all you will need to do is call the superclass constructor with SensorWrapper.__init__(self, config)
. In this case, however, we want to add an extra config option called “precision” which we’ll use to round our result to a certain number of decimal places before sending it to the interface. Sometimes you may want to initialise a I2C bus and sensor library here too (such as with the MLX90614Wrapper).
1 2 3 | def __init__(self, config): SensorWrapper.__init__(self, config) self.precision = int(config.get('precision', 2)) |
get_initial()
is an optional function that sends initialisation data to the interface. It’s a good idea to implement the “limit” message if your sensor can report some kind of hard limit (such as the total amount of system memory for a RAM sensor, or in this case (for our disk usage sensor), the size of the disk). The “limit” message is used by circle graphs to calculate the percentage of fill.
You can send other initialisation data here too if required by simply adding another entry to the dictionary. It will be ignored by graphs on the interface unless implemented on the interface as well (see part 2 of this series).
1 2 3 | def get_initial(self): disk_size = round((psutil.disk_usage('/').total >> 20) / 1024, self.precision) return {"limit": disk_size} |
Finally, we need to implement get_data()
. This is the function that will poll the sensor and return the current reading.
1 2 3 | def get_data(self): disk_usage = round((psutil.disk_usage('/').used >> 20) / 1024, self.precision) return disk_usage |
Some sensors collect multiple readings. You can return these as dictionaries like the SGP30 sensor does (view on GitHub).
1 2 3 4 5 6 7 | def get_data(self): data = self.sensor.read_measurements() msg = { "co2": data[0][0], "tvoc": data[0][1] } return msg |
Adding a Sensor to the Config Schema
The configuration of each sensor can be slightly different, so it’s helpful to users if you create a config schema that defines how your sensor wrapper can be configured.
Locate the sensors
definition in the config schema at sights/interface/js/sights.config.schema.js
. You will notice that a sensor can be “any of” a list of sensors. All you need to do is add your sensor with its configuration values to the list of options.
Below is an example for the disk usage sensor wrapper we created above.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | { "type": "object", "title": "Host Disk Usage Monitor", "options": { "collapsed": true }, "properties": { "enabled": { "type": "boolean", "title": "Enable", "description": "Whether the disk usage monitor is enabled", "format": "checkbox", "default": true }, "type": { "type": "string", "title": "Type", "enum": [ "disk_usage" ], "default": "disk_usage", "format": "radio" }, "name": { "type": "string", "title": "Name", "description": "The pretty name for the disk usage monitor.", "default": "New Sensor" }, "precision": { "type": "integer", "title": "Precision", "description": "The number of decimal places to round to.", "default": 2 }, "period": { "type": "number", "title": "Update Period", "description": "How often, in seconds, the disk usage monitor updates.", "default": 3 }, "display_on": { "type": "array", "title": "Display On", "description": "A list of graph UIDs to display this sensor's data on.", "items": { "type": "string", "title": "Graph UID" } } } } |
You may remember from earlier that some sensors such as the SGP30 sensor can return multiple values using a dictionary. So how do we let the user configure where to display each value from a multi-sensor individually?
Simply make display_on
an object. Each property is the name of a reading, which is an array of graph IDs. Here’s an example using the same sensor.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | "display_on": { "type": "object", "title": "Display On", "description": "The SGP30 sensor is a multi-sensor. Choose how each value is displayed individually.", "options": { "collapsed": false }, "properties": { "co2": { "type": "array", "title": "Carbon Dioxide", "description": "A list of graph UIDs to display this sensor's carbon dioxide data on.", "items": { "type": "string", "title": "Graph UID" } }, "tvoc": { "type": "array", "title": "Total Volatile Organic Compounds", "description": "A list of graph UIDs to display this sensor's total volatile organic compound data on.", "items": { "type": "string", "title": "Graph UID" } } } } |
Contributing your Own Sensor Wrapper to SIGHTS
If you’ve created a sensor wrapper for a sensor that’s not included with SIGHTS, in the spirit of open source collaboration, we’d love to include it in the official SIGHTS repository! The process is simple:
- Fork the repository at https://github.com/SFXRescue/sights
- Make your changes on your fork (add your new sensor wrapper and config schema changes!)
- Submit a pull request with your additions
It’s generally easier to focus on one feature per pull request, so if you have other unrelated changes you want to propose or you have multiple sensor wrappers to contribute, please consider making separate pull requests for each.
Further Reading
You can read the documentation about extending SIGHTS. Documentation is available offline when you install SIGHTS and is accessible through the interface. If you haven’t installed SIGHTS, you can still access this online at GitHub.
If there are major changes to the function of sensor wrappers and graphs, you may need to refer to the most up-to-date documentation at https://github.com/SFXRescue/sights/blob/master/docs/extending.md.
If the process for creating a sensor wrapper or graph changes significantly enough, we may post a new tutorial as well.