How to Design a Complete IoT Solution with Node.js

This article describes an internet-of-things (IoT) experiment using the new Raspberry Pi 4B.

It starts with a sensor connected to the Raspberry Pi that measures the temperature. A service securely communicates the measurement using MQTT to an MQTT broker. Finally, another service retrieves the measurement and stores it in a database

The complete solution is available on GitHub and runs inside Docker containers. You can start it using Docker Compose.

Architecture

Production-ready IoT solutions must be capable of handling more than 100,000 samples per second. So the design of my solution should be able to handle these kinds of numbers.

For the solution to be able to handle this amount of transactions per second, it should be scalable. Therefore, I separated the solution into four different services.

This is image title

Each service runs inside a Docker container and is responsible for a specific function. During the experiment, I ran all Docker containers on the Raspberry Pi.

I choose containers as this makes it easier to scale and run the complete solution on a single node during development or multiple nodes during production.

The bottleneck of this solution is the database that stores all the incoming measurements. Therefore, I used a specialized database, a time-series database. Time-series databases are specially designed for storing time-stamped data. By design, these databases are capable of writing and reading large amounts of data.

I divided the solution into the four following services:

clim-measure

The clim-measure service handles retrieving the measured temperate from the sensor. It publishes the temperature using the MQTT protocol.

clim-broker

The clim-broker service is the MQTT broker of the solution. It’s responsible for receiving and routing all MQTT messages.

clim-storage

The clim-storage service is responsible for receiving temperature events via MQTT and sending them to the database.

clim-db

The clim-db service is responsible for persisting the temperature sensor data in the database.

Choosing the Right Sensor (clim-measure)

I selected the Dallas DS18B20 digital sensor to measure the temperature for its ease of use. It uses a single wire to communicate temperature and doesn’t need a gross net conversion. It directly returns the measured temperature as a double.

I connected the sensor to one of the general-purpose input/output (GPIO) ports of the Raspberry Pi 4B. The Raspberry Pi is running Raspbian Lite.

Before you can use the DS18B20, you have to enable the one-wire protocol on the Pi. Raspberry PI Tutorials created an excellent tutorial that explains how to enable it.

After the one-wire protocol is enabled, you can execute the following script to read the temperature.

cat /sys/bus/w1/devices/28-00000ab45db8/w1_slave | sed -n 's/^.*\(t=[^ ]*\).*/\1/p' | sed 's/t=//' | awk '{x=$1}END{print(x/1000)}'

On my Raspberry Pi, the script outputs 13.812, which was the temperature in the room at that moment.

Automating temperature reading

As the sensor is enabled, we can create the clim-measure service. It’s a Node.js application that reads the sensor and publishes it using MQTT.

I used ds18b20-raspi, which is an npm package created by Dave Johnson that helps with reading the sensor data. I wrapped this in a module called sensor.js.

/*
 * Sensor responsible for reading the temperature sensor
 */
const tempSensor = require('ds18b20-raspi');

const log = require('./log');
const config = require('./config');
const constants = require('./constants');

const sensor = {};

sensor.read = function read(cb) {
  if (config.envName === constants.ENVIRONMENTS.PRODUCTION) {
    // Read the temperature from the sensor
    tempSensor.readSimpleC((err, temp) => {
      if (!err) {
        cb(null, temp);
      } else {
        log.error(`An error occurred while trying to read the temperature sensor. ${err}`);
        cb(err);
      }
    });
  } else {
    // Generate a fake temperature for testing
    const temperature = Math.floor(Math.random() * 20);
    cb(null, temperature);
  }
};

module.exports = sensor;

The if statement on line 13 checks if the program is running in production mode. If it’s running in production mode, it reads the temperature from the sensor. Otherwise, it generates a random number. This allowed me to develop and test the solution on my laptop.

Publishing the temperature reading using MQTT

After the sensor reads the temperature, the application publishes the temperature using the MQTT protocol. MQTT is a lightweight publish-subscribe protocol used with IoT solutions.

MQTT divides clients into publishers and subscribers. A central MQTT broker is responsible for routing messages from the publishers to the subscribers.

Messages are divided into topics. If you want to receive messages on a certain topic, you indicate to the MQTT broker you want to subscribe to this topic.

On the other hand, if you want to publish a message for a specific topic, you directly send it to the MQTT broker.

clim-measure, the service reading the temperature, is an MQTT client. I used the npm module MQTT.js to implement the MQTT client. MQTT.js is an MQTT client library for Node.js.

The first thing the service does after startup is connect to the MQTT broker.

transmitter.connect = function connect(cb) {
  const connectOptions = {
    port: config.mqtt.port,
    host: config.mqtt.broker,
    rejectUnauthorized: false,
    protocol: 'mqtts',
    username: config.mqtt.username,
    password: config.mqtt.password,
  };

  log.info(`Trying to connect to the MQTT broker at ${config.mqtt.broker} on port ${config.mqtt.port}`);

  transmitter.client = mqtt.connect(connectOptions);

  transmitter.client.on('connect', () => {
    log.info(`Connected successfully to the MQTT broker at ${config.mqtt.broker} on port ${config.mqtt.port}`);
    cb();
  });

  transmitter.client.on('error', (err) => {
    log.error(`An error occurred. ${err}`);
  });
};

The MQTT broker uses a username and password to authenticate MQTT clients.

The temperature is sent using the publish method of the mqtt client library. I construct an object that contains the temperature and a timestamp. Just before sending, I convert the object to a JSON string using JSON.stringify.

transmitter.send = function send(temperature, cb) {
  const message = {
    temperature,
    timeStamp: moment().unix(),
  };

  transmitter.client.publish(config.mqtt.topic, JSON.stringify(message), (err) => {
    if (err) {
      log.error(`An error occurred while trying to publish a message. Err: ${err}`);
    } else {
      log.debug('Successfully published message');
    }
    cb(err);
  });
};

This will send this message to the MQTT broker. It uses the topichouse/bedroom/temperature.

MQTT topics

MQTT uses topics as a form of addressing to filter messages for connected clients. The topic itself is a case sensitive UTF-8 string that consists of one or more characters.

You can define a hierarchy in topics using the forward-slash (/) as a delimiter. The following are all valid MQTT topics.

  • house/bedroom/temperature
  • house/bathroom/relativehumidity
  • house/bathroom/temperature
  • house/garage/temperature

Multilevel wildcards

When subscribing, it’s possible to use wildcards to subscribe to multiple topics. Multilevel wildcards are defined using the hash symbol. The hash symbol must be the last character of the topic.

For example, when an MQTT client subscribes to the topic house/bathroom/#, it’ll receive all topics that start with house/bathroom.

So looking at the previous topic list, this will be house/bathroom/relativehumidity, and house/bathroom/temperature.

Single-level wildcards

Single-level wildcards replace a single topic level by using the plus symbol.

For example, when an MQTT client subscribes to the topic house/+/temperature, it’ll receive all topics that start with house and end with temperature. So looking at the previous topic list, this will be house/bathroom/temperature, house/bedroom/temperature, and house/garage/temperature.

The MQTT Broker (clim-broker)

clim-broker is the service responsible for receiving and routing the MQTT messages to MQTT clients. During startup, the service opens a socket and listens for incoming MQTT messages.

I wanted to have secure communication between the MQTT clients and the MQTT broker. This is possible in MQTT using TLS. To use TLS, you have to use SSL certificates.

I created a self-signed certificate and a private key to use it in the MQTT broker. If you need some help with creating self-signed certificates, see my previous article.

Aedes, a bare-bones MQTT broker

For implementing the MQTT broker, I used the npm package Aedes. Aedes is an MQTT broker with basic functionality that can be extended using extensions.

During startup of the clim-broker service, it creates an instance of aedes, starts the server, and starts listening for MQTT messages on the specified port.

broker.listen = function listen(cb) {
  broker.aedes = aedes();

  const options = {
    key: fs.readFileSync('./certificates/broker-private.pem'),
    cert: fs.readFileSync('./certificates/broker-public.pem'),
  };

  broker.server = tls.createServer(options, broker.aedes.handle);

  log.info(`Starting MQTT broker on port:${config.mqtt.port}`);

  broker.server.listen(config.mqtt.port);

  cb();
};

Both the private and public key are added to the options when creating the server.

Authenticating clients

Besides using TLS, MQTT clients must also be authenticated. This is done using a username and password. You have to implement an authenticate function and assign this to Aedes.

The setupAuthentication method in broker.js sets up the function to execute when an MQTT client connects to the broker.

broker.setupAuthentication = function setupAuthentication() {
  broker.aedes.authenticate = (client, username, password, cb) => {
    if (username && typeof username === 'string' && username === config.mqtt.username) {
      if (password && typeof password === 'object' && password.toString() === config.mqtt.password) {
        cb(null, true);
        log.info(`Client: ${client} authenticated successfully`);
      }
    } else {
      cb(false, false);
    }
  };
};

Receiving and Storing Temperature Samples (clim-storage)

The clim-storage service is responsible for receiving temperature events and storing them in the database.

The clim-storage service is just like clim-measure — an MQTT client. But instead of publishing, it subscribes to the house/bedroom/temperature topic to receive the temperature messages.

During startup, the clim-storage service connects to the MQTT broker.

Receiving temperature message

To receive messages, the clim-storage service first subscribes to the house/bedroom/temperature topic on line 4. Then, it uses the client.on('message', ...) to receive the message.

  receiver.client.on('connect', () => {
    log.info(`Connected successfully to the MQTT broker at ${config.mqtt.broker} on port ${config.mqtt.port}`);

    receiver.client.subscribe(config.mqtt.topic);

    receiver.client.on('message', (topic, message) => {
      if (topic === config.mqtt.topic) {
        const parsedMessage = helper.parseJsonToObject(message.toString());
        messageCallback(parsedMessage);
      }
    });

    connectCallback();
  });

The incoming message is an instance of the Node.js Buffer class.

I had some trouble with converting the incoming message. After I found out the incoming message was actually a Buffer, I could convert it to a string.

So I first converted it to a string and then used the parseJsonToObject helper function to convert the JSON string back into an object.

Storing the temperature sample

After we have receive the temperature sample, we have to store it. All the samples are persisted using an InfluxDB database.

Connecting to the database For interacting with InfluxDB, I use the npm package node-influx. To connect to the database, you have to create an instance of the InfluxDB class while providing the host, port, and schema.

storage.connect = function connect(cb) {
  storage.influx = new Influx.InfluxDB({
    host: config.database.host,
    database: config.database.name,
    username: config.database.user,
    password: config.database.password,
    schema: [
      {
        measurement: 'temperature',
        fields: {
          temperature: Influx.FieldType.FLOAT,
        },
        tags: ['host'],
      },
    ],
  });

  storage.influx.getDatabaseNames().then((names) => {
    if (!names.includes(config.database.name)) {
      return storage.influx.createDatabase(config.database.name);
    }
    return null;
  }).then(cb);
};

The connect function also creates the database if it doesn’t exist.

Saving the sample The message that’s received contains the temperature and the time the measurement was taken. The function save saves a record in the database.

storage.save = function save(message, cb) {
  log.info(`Storing message: ${message.temperature} ${message.timeStamp}`);
  storage.influx.writePoints([
    {
      measurement: 'temperature',
      fields: {
        temperature: message.temperature,
      },
      timestamp: message.timeStamp,
    },
  ]).then(cb);
};

Storing the Measurement (clima-db)

InfluxDB is an open-source time-series database (TSDB). I use the official InfluxDB Docker image to run the database.

TSDBs are specially designed for storing large numbers of time-stamped data. Most IoT solution providers use them. In our case, we use them to store the temperature of the room at a specific time.

Describing TSDBs in more detail will be the subject of a future article.

If you can’t wait, watch the excellent presentation by Ted Dunning. Ted Dunning is chief application architect at MapR. MapR is a data platform that also provides a time-series database.

Docker Specifics

Each service within clima-link runs inside a Docker container. You can start the complete solution using Docker Compose. I based all the Docker images on Arm32 base images because I wanted to be able to run all the services on a Raspberry Pi.

The source code of each service is stored inside a folder of the GitHub repository. Each folder contains the Dockerfile for that specific service. I used my checklist from the article below to create Dockerfiles that are production-ready.

I automated the creation of Docker images using bash scripts. This saves me time during development. The script below creates and uploads the Docker image of the clim-broker service.

#!/bin/bash
VERSION="0.5.7"
ARCH="arm32v7"
APP="clima-broker"
docker buildx build -f ./Dockerfile-$APP-$ARCH -t $APP:$VERSION . --load
docker tag $APP:$VERSION pkalkman/$APP:$VERSION
docker push pkalkman/$APP:$VERSION

Running the complete solution

If you own a Raspberry Pi and want to run the solution, you have to install Docker and Docker Compose on the Pi.

After installation, copy the contents below into a file calleddocker-compose.yml. With docker-compose up, the Docker images will be downloaded and started.

version: '2'
services:
  clima-db:
    image: 'arm32v7/influxdb:1.7.10'
    container_name: 'clima-db'
    environment:
      - INFLUXDB_DB=climalink
      - INFLUXDB_ADMIN_USER=admin
      - INFLUXDB_ADMIN_PASSWORD=secretadmin
      - INFLUXDB_USER=climalink
      - INFLUXDB_USER_PASSWORD=secretuser
    volumes:
      - ./data:/var/lib/influx.db
    ports:
      - '8086:8086'
    networks: 
      - clim-network
  clima-storage:
    image: 'pkalkman/clima-storage:0.5.6'
    container_name: 'clima-storage'
    environment:
      - MQTT_BROKER_HOST=clima-broker
      - MQTT_BROKER_PORT=8883
      - MQTT_USERNAME=brokerusername
      - MQTT_PASSWORD=brokerpassword
      - DATABASE_HOST=clima-db
      - DATABASE_NAME=climalink
      - DATABASE_USER=climalink
      - DATABASE_PASSWORD=secretuser
    networks: 
      - clim-network
  clima-broker:
    image: 'pkalkman/clima-broker:0.5.6'
    container_name: 'clima-broker'
    environment:
      - MQTT_BROKER_PORT=8883
      - MQTT_USERNAME=brokerusername
      - MQTT_PASSWORD=brokerpassword
    networks:
      - clim-network
  clima-measure: 
    image: 'pkalkman/clima-measure:0.5.6'
    container_name: 'clima-measure'
    environment:
      - MQTT_BROKER_HOST=clima-broker
      - MQTT_BROKER_PORT=8883
      - MQTT_USERNAME=brokerusername
      - MQTT_PASSWORD=brokerpassword
      - NODE_ENV=production
    networks: 
      - clim-network
networks:
    clim-network:

This is image title

Conclusion

This article showed it’s possible to create a scalable IoT solution with open-source components. By splitting the solution into services and running them in Docker containers, it can be used for development and scale in production.

Thank you for reading.

Originally published on medium.com

#IoT #node-js #javascript #docker #programming

How to Design a Complete IoT Solution with Node.js
1 Likes22.00 GEEK