Introduction
Communicating with sensors and other devices is a core feature of macchina.io EDGE. macchina.io EDGE comes with a set of pre-defined service interfaces for various sensor and device types. This means that sensors of a certain type, like temperature sensors or accelerometers, always have the same programming interface, no matter how they are actually implemented.
The pre-defined sensor and device interfaces in macchina.io EDGE are:
- Accelerometer (IoT::Devices::Accelerometer)
- BarcodeReader (IoT::Devices::BarcodeReader)
- BooleanSensor (IoT::Devices::BooleanSensor)
- Camera (IoT::Devices::Camera)
- Counter (IoT::Devices::Counter)
- GNSSSensor (IoT::Devices::GNSSSensor)
- Gyroscope (IoT::Devices::Gyroscope)
- IO/GPIO (IoT::Devices::IO)
- LED (IoT::Devices::LED)
- Magnetometer (IoT::Devices::Magnetometer)
- RotaryEncoder (IoT::Devices::RotaryEncoder)
- Sensor (IoT::Devices::Sensor)
- SerialDevice (IoT::Devices::SerialDevice)
- Switch (IoT::Devices::Switch)
- Trigger (IoT::Devices::Trigger)
Every sensor or device in macchina.io EDGE is an OSP service implementing one of these C++ interfaces.
Simple sensors, like temperature or humidity sensors that only provide a single value, are based on the IoT::Devices::Sensor interface. There are also more specific interfaces for sensors like accelerometers, GPIOs or GPS/GNSS receivers.
Prerequisites
This tutorial assumes the you've already obtained the macchina.io EDGE sources from its Git repository and built macchina.io EDGE for your host system, according to the instructions in the Getting Started document. You should also have worked through the macchina.io EDGE C++ Programming Guide and be familiar with the basics of C++ programming with the macchina.io EDGE framework. You should also have a good understanding of OSP bundles and services.
Writing Your First Sensor Implementation in C++
As a first example, we will write a Sensor service that provides an interface to the Linux thermal subsystem. Modern CPUs and SoCs have on-chip temperature sensors, and the Linux thermal subsystem provides a standard interface for reading them. For example, on a Raspberry Pi, the SoC temperature can be obtained by reading the file /sys/class/thermal/thermal_zone0/temp:
pi@raspberrypi:~$ cat /sys/class/thermal/thermal_zone0/temp 42932
The obtained value (42932) needs to be divided by 1000 to obtain the temperature in degrees Celsius (42.932 °C).
To make this value available in macchina.io EDGE through a Sensor service, the following steps must be taken:
- A new project for a sensor bundle must be set up.
- A subclass of the IoT::Devices::Sensor class must be created.
- A BundleActivator class must be implemented, which must create an instance (or multiple instances) of the sensor class and register it with the OSP service registry.
Setting Up a Device or Sensor Bundle C++ Project
In the first step, we will create a sensor with only the most basic features. The project is called LinuxThermalSimple.
The basic directory structure for the sensor device project looks as follows:
LinuxThermalSimple/ Makefile LinuxThermalSimple.bndlspec src/ LinuxThermalSensor.h LinuxThermalSensor.cpp BundleActivator.cpp
The project directory contains a Makefile and a bundle specification file, as well as the header and implementation file for the LinuxThermalSensor class and the implementation file for the BundleActivator.
The Sensor Class
Let's have a look a the LinuxThermalSensor class first.
Every sensor or device implementation must implement the basic IoT::Devices::Device interface. This interface defines a number of methods that every device or sensor must provide. Specifically, these are methods for inquiring and changing device properties and features. Properties and features are used as a generic extension mechanism, allowing each device to expose properties or features not available in the generic interface.
Properties can be used to configure devices, or to obtain additional data a sensor provides in addition to the actual value. Features are used to enable or disable device-specific features.
As a look at the IoT::Devices::Device interface reveals, there is quite a number of methods that must be implemented to satisfy the interface. The main reason for this is that properties can have different types, and there are setter and getter methods for each property type. The Remoting framework, which is used to implement the JavaScript bridging unfortunately does not support any-style types like Poco::Any or Poco::DynamicAny, so separate methods have to be used for the supported property types std::string, int, double and bool.
The DeviceImpl Class Template
Since implementing all these methods for every device class would be very tedious, help in the form of the IoT::Devices::DeviceImpl class template is available. This template implements all required methods in a generic way and allows derived classes to register specific getter and setter member functions for the properties and features they support.
The IoT::Devices::DeviceImpl takes two template parameters. The first one is the interface class of the actual device or sensor type to be implemented (e.g., IoT::Devices::Sensor, or IoT::Devices::Accelerometer). The second parameter is the class you are implementing, which is derived from IoT::Devices::DeviceImpl. This "famous" C++ idiom is called the Recurring Template Pattern https://en.wikipedia.org/wiki/Curiously_recurring_template_pattern (or CRTP for short).
So let's have a look at the LinuxThermalSensor class definition:
class LinuxThermalSensor: public IoT::Devices::DeviceImpl<IoT::Devices::Sensor, LinuxThermalSensor> { public: LinuxThermalSensor(const std::string& devicePath); ~LinuxThermalSensor() = default; // Sensor double value() const; bool ready() const; static const std::string NAME; static const std::string TYPE; static const std::string SYMBOLIC_NAME; static const std::string PHYSICAL_QUANTITY; protected: Poco::Any getDisplayValue(const std::string&) const; Poco::Any getName(const std::string&) const; Poco::Any getType(const std::string&) const; Poco::Any getSymbolicName(const std::string&) const; Poco::Any getPhysicalQuantity(const std::string&) const; Poco::Any getPhysicalUnit(const std::string&) const; private: std::string _path; };
Note: the full source code can be found in the macchina.io EDGE samples folder.
First thing we notice is the mentioned CRTP idiom when using the IoT::Devices::DeviceImpl class template as base class for our LinuxThermalSensor. Therefore, the first template argument is IoT::Devices::Sensor, which is the actual interface the LinuxThermalSensor class implements. The second template argument is the LinuxThermalSensor class itself.
The rest of the LinuxThermalSensor is not too complicated. The IoT::Devices::Sensor interface defines two methods that must be implemented - value() which returns the current sensor value, and ready() which returns true if the sensor device is ready to report a value, or otherwise false.
Device Properties
The IoT::Devices::Sensor interface also requires a few read-only properties providing meta-data about the sensor to be implemented, as well as the "displayValue" property which is used to display the sensor value in the macchina.io EDGE web user interface and other places.
The following read-only properties (all of type std::string) must be implemented:
- symbolicName: A unique name in reverse DNS notation (like a bundle symbolic name) which identifies the device class. For this example, the symbolic name is "io.macchina.linux-thermal-simple".
- name: A user-readable name for the device ("Linux Thermal Sensor").
- type: The symbolic name of the device interface used. For IoT::Devices::Sensor it is "io.macchina.sensor". See the IoT::Devices::Device class for a list of types.
- physicalQuantity: The physical quantity that is being measured by the sensor, e.g. "temperature".
- physicalUnit: The physical unit the measured value is being represented in (e.g. "Cel" for degree Celsius). This should use the "c/s" symbols from the Unified Code for Units of Measure (http://unitsofmeasure.org/ucum.html). See the PHYSICAL_UNIT_* string constants in the The IoT::Devices::Sensor for predefined values.
- displayValue: The current value of the sensor, formatted as string for display purposes (e.g. in the web user interface). This property is optional, but it's good practice to implement it for every sensor.
Implementing the Sensor Class
The Constructor
In the implementation of the LinuxThermalSensor, there are two methods of interest. The first one is the constructor:
LinuxThermalSensor::LinuxThermalSensor(const std::string& path): _path(path + "/temp") { addProperty("displayValue", &LinuxThermalSensor::getDisplayValue); addProperty("symbolicName", &LinuxThermalSensor::getSymbolicName); addProperty("name", &LinuxThermalSensor::getName); addProperty("type", &LinuxThermalSensor::getType); addProperty("physicalQuantity", &LinuxThermalSensor::getPhysicalQuantity); addProperty("physicalUnit", &LinuxThermalSensor::getPhysicalUnit); }
The constructor takes the path to the Linux thermal device as argument (e.g. "/sys/class/thermal/thermal_zone0"), adds the "/temp" part for the file to read the actual value from, and stores it in the _path member variable.
Then it registers the getter methods for the supported properties, by calling the addProperty() method inherited from the DeviceImpl base class.
The value() Method
The implementation of the value() method is quite trivial in this case. It simply reads the contents of the "temp" file from the Linux thermal subsystem and divides the value by 1000 to get the correctly scaled value.
double LinuxThermalSensor::value() const { Poco::FileInputStream istr(_path); int value; istr >> value; return value/1000.0; }
The ready() Method
For implementing this sample class, we assume that the sensor is always ready. Therefore, the ready() class simply returns true.
The getDisplayValue() Method
This method simply formats the double value as a string, using the Poco::NumberFormatter::format() method.
Meta-Information Property Getters
The property getters for meta-information (symbolicName, name, type, etc.) simply return a string constant with the appropriate value.
Implementing the BundleActivator
The BundleActivator implementation creates and registers an instance of the LinuxThermalSensor service. To make the sensor also usable from JavaScript code, the Remoting framework is used. The LinuxThermalSensor instance is not directly registered as an OSP service class. This would not be possible anyway, because LinuxThermalSensor is not implementing the Poco::OSP::Service interface. Instead, the Remoting framework is used to create a IoT::Devices::SensorRemoteObject wrapping the LinuxThermalSensor object. The IoT::Devices::SensorRemoteObject is then registered with the service registry.
In order to make the LinuxThermalSensor discoverable through the service registry, three service properties are provided for registration. All sensor and device implementations should provide these service properties when registering the service.
For example, by registering the "io.macchina.physicalQuantity" property, code looking for a temperature sensor can look for temperature sensors the following way (in JavaScript):
var temperatureRefs = serviceRegistry.find('io.macchina.physicalQuantity == "temperature"');
All this is implemented in the createSensor() method in our BundleActivator class:
Poco::OSP::ServiceRef::Ptr createSensor(const std::string& id, const std::string& path) { // Define an alias to the ServerHelper class for convenience. using ServerHelper = typedef Poco::RemotingNG::ServerHelper<IoT::Devices::Sensor>; // Create the LinuxThermalSensor instance... Poco::SharedPtr<LinuxThermalSensor> pSensor = new LinuxThermalSensor(path); // and wrap it in a RemoteObject. ServerHelper::RemoteObjectPtr pSensorRemoteObject = ServerHelper::createRemoteObject(pSensor, id); // Set the service properties. Properties props; props.set("io.macchina.device", LinuxThermalSensor::SYMBOLIC_NAME); props.set("io.macchina.deviceType", LinuxThermalSensor::TYPE); props.set("io.macchina.physicalQuantity", LinuxThermalSensor::PHYSICAL_QUANTITY); // Register the RemoteObject as a service and return its ServiceRef::Ptr. return _pContext->registry().registerService(id, pSensorRemoteObject, props); }
The start() method of the BundleActivator reads the path of the device directory in /sys/class from the configuration (using the Poco::OSP::PreferencesService), then checks whether the device directory exists. If so, it calls createSensor() to create and register the service for the LinuxThermalSensor.
void start(BundleContext::Ptr pContext) { _pContext = pContext; Poco::OSP::PreferencesService::Ptr pPrefs = ServiceFinder::find<PreferencesService>(pContext); std::string path = pPrefs->configuration()->getString("linux-thermal-simple.path", "/sys/class/thermal/thermal_zone0"); Poco::File file(path); if (file.exists()) { _pServiceRef = createSensor(LinuxThermalSensor::SYMBOLIC_NAME, path); } else { pContext->logger().warning("Thermal device file not found: %s", path); } }
The stop() method unregisters the service and resets all smart pointers.
void stop(BundleContext::Ptr pContext) { pContext->registry().unregisterService(_pServiceRef); _pServiceRef.reset(); _pContext.reset(); }