Skip to content

pineviewlabs/iot-edge-rshiny-dashboard-doc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 

Repository files navigation

IoT Edge: R Shiny dashboard (draft)

Summary

In this article we will build an Azure IoT Edge solution with an R Shiny dashboard and a Node.js application as a data generator.

Glossary

Microsoft Azure is a cloud computing service. Other examples of such services are Amazon Web Services and Google Cloud Platform. This article will focus on Azure.

IoT stands for the Internet of Things — the network of physical objects that are embedded with sensors, software, and other technologies for the purpose of connecting and exchanging data with other devices and systems over the internet. An example of an IoT solution would be the network consisting of:

  • sensors gathering the temperature data in various parts of a complex factory;
  • central server which collects the data from the sensors, analyzes it and makes some short-term predictions;
  • automated coolant system which is connected to the same network and operated by the signals from the central server, sent according to short-term predictions of temperature fluctuations;

IoT Edge takes the part of a data processing burden from the central server(s) to the IoT devices themselves (the edge). This allows to:

  • lower the amount of traffic between IoT devices and the server(s);
  • make IoT devices resilient to internet connectivity issues;
  • significantly shorten the feedback loop on the IoT system with feedback (the sensor → actuator chain);
  • reduce the processing load on the server(s);

In this article we will be working with Azure IoT Edge implementation.

R is a programming language for statistical computing and graphics. Shiny is an R package that allows to build interactive fullstack web apps using R.

Node.js is a JavaScript language runtime.

Application idea

Let's imagine we have a fleet of ferries, which are constantly tracking their GPS position during the trip. We want to be able to see the ferry's trajectory on the map while being on a ferry (even without internet access). We also want to report the ferry's speed to the central server (for example, to gather statistics and make some ML models).

For the purpose of this article we will be generating the ferry's coordinates using the Node.js application (modelling GPS and velocity sensors) called generator and visualize ferry's trajectory in real time on the map using the R Shiny dashboard called visualizer. We will also report the ferry's current position to another module called analyzer, which will have quite a simple job: to calculate the total distance traveled by the ferry and to report this total distance to a central hub.

First, let's implement both generator and visualizer as a separate applications; later we will organize these applications under the single IoT Edge solution and will also add an analyzer module.

Generator (Node.js)

First, we need to install Node.js using one of two options:

I also suggest to install Visual Studio Code: it is useful both to work with a standalone generator app and also to work with IoT Edge solution later.

Create a new folder named generator, open the terminal, change directory to a newly created folder and type npm init -y to initialize a Node.js application. In fact, this command will create a package.json file, which is used by the Node.js ecosystem.

Open generator folder with Visual Studio Code (you can just type code . in the terminal). We will be using TypeScript for our Node.js modules, so there are a couple of extra steps to get everything running. First, create a tsconfig.json file (its needed to provide TypeScript tools with proper configuration options) with the following contents:

{
  "ts-node": {
    "files": true,
    "transpileOnly": true
  },
  "exclude": ["public"],
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "rootDir": "./src",
    "baseUrl": "./",
    "outDir": "./build",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true
  }
}

I won't get into the details on each option' semantics here; if you're interested — all the information is here.

Next, we need to install two npm modules (@types/node needed for TypeScript to recognize standard Node.js internals/libraries; ts-node is a TypeScript execution engine for Node.js):

npm i -D @types/node
npm i ts-node

Create an src directory under the project's root, and put the app.ts file there with the following contents:

console.log("Hi there!");

Save the file and modify the "scripts" section in a package.json file so it looks like this:

"scripts": {
  "start": "ts-node src/app.ts"
},

This will tell Node Package Manager (npm) how to start the application. Next, type npm start in the console (while inside the generator folder). You should see the string "Hi there!" in the terminal.

Now we need to implement our ferry "simulator" engine. It will consist of several TypeScript modules, which will be listed below with exhaustive comments.

The src/helpers.ts module contains several helper functions:

// Generate a pseudo-random number between min and max values
export const random = (min: number, max: number) =>
  Math.random() * (max - min) + min;

// Pause an execution for ms milliseconds
export const pause = (ms: number) => new Promise((res) => setTimeout(res, ms));

// Exit process with optional error reporting
export function exit(code: number, error?: unknown): never {
  if (error) console.error(error);
  process.exit(code);
}

The src/point.ts module contains the Point class, which will be used to describe ferry coordinates:

// Helper class describing map coordinates pair
export class Point {
  // Create a point with latitude and longitude
  constructor(public lat: number, public lng: number) {}

  // Update a point with the coordinates from another point
  update(point: Point) {
    this.lat = point.lat;
    this.lng = point.lng;
  }

  // Calculate the distance between two points
  static distance(point1: Point, point2: Point) {
    return Math.sqrt(
      Math.pow(point2.lat - point1.lat, 2) +
        Math.pow(point2.lng - point1.lng, 2)
    );
  }

  // Create a new point's instance from the existing point
  static from(point: Point) {
    return new Point(point.lat, point.lng);
  }
}

The src/ports.ts module describes ports a ferry will be travelling between; each port entry also contains a list of coordinates — to help the ferry to sail the narrow parts of the path from/to the port; we will call these points "breadcrumbs" below:

import { Point } from "./point";

// Port data format
export interface Port {
  // Port's name (just for illustrative purposes)
  name: string;
  // Breadcrumb points — describes how to sail the narrow parts of the path from the port
  breadcrumbs: Point[];
}

// List of ports to work with
export const ports: Port[] = [
  {
    name: "Hirtshals, Denmark",
    breadcrumbs: [
      new Point(57.592841, 9.966922),
      new Point(57.595534, 9.963483),
      new Point(57.597369, 9.958817),
      new Point(57.599751, 9.958033),
      new Point(57.601585, 9.956994),
    ],
  },
  {
    name: "Kristiansand, Norway",
    breadcrumbs: [
      new Point(58.144044, 7.985321),
      new Point(58.14229, 7.98688),
      new Point(58.140142, 7.98738),
      new Point(58.133968, 7.988785),
      new Point(58.128519, 8.001452),
      new Point(58.125508, 8.008597),
      new Point(58.117212, 8.017392),
      new Point(58.09778, 8.037732),
      new Point(58.076788, 8.060878),
      new Point(58.041643, 8.108352),
    ],
  },
  {
    name: "Larvik, Norway",
    breadcrumbs: [
      new Point(59.041698, 10.045262),
      new Point(59.040981, 10.042309),
      new Point(59.038086, 10.03398),
      new Point(59.035695, 10.033933),
      new Point(59.025676, 10.041152),
      new Point(59.01797, 10.04746),
      new Point(59.001897, 10.066968),
      new Point(58.993137, 10.091842),
      new Point(58.972646, 10.095421),
    ],
  },
];

The src/track.ts module contains the Track class, which is responsible for keeping track of "breadcrumbs" and to advance the ferry's position between the "breadcrumbs" and in open waters:

import { random } from "./helpers";
import { Point } from "./point";

// Helper class designed to:
// - keep track [:)] of "breadcrumbs" to help ferry navigate in narrow areas
// - advance ferry's position between the "breadcrumbs"
export class Track {
  // List of track's breadcrumb points
  private points: Point[];
  // Current target (point's index)
  private index: number;
  // Current ferry's position
  private position: Point;

  constructor(originBreadcrumbs: Point[], destinationBreadcrumbs: Point[]) {
    // Combine origin and destination ports' breadcrumbs into the single array
    // (note reversing the destination port's points: they are in "departure" order)
    this.points = originBreadcrumbs.concat(
      [...destinationBreadcrumbs].reverse()
    );
    // Current target point is the first ("zeroth" actually) breadcrumb
    this.index = 0;
    // Set the position based on the first breadcrumb
    this.position = Point.from(this.target);
  }

  // Get the current breadcrumb point
  private get target() {
    return this.points[this.index];
  }

  // Get the last breadcrumb point
  private last() {
    return this.points[this.points.length - 1];
  }

  // True if ferry arrived at last breadcrumb point
  public done() {
    return this.index >= this.points.length;
  }

  // Advance the ferry on track and return a ferry's new position
  public next(averageSpeed: number, jitter: number): Point {
    // If we're arrived at the last breadcrumb — just return that last point
    if (this.done()) return Point.from(this.last());
    // Calculate the distance between the target breadcrumb and ferry's current position
    const distance = Point.distance(this.target, this.position);
    // If distance is less than or equal to average speed
    if (distance <= averageSpeed) {
      // Jump to the target breadcrumb
      this.position.update(this.target);
      // Set the next breadcrumb as a target
      this.index += 1;
      // Otherwise — advance to next breadcrumb using some primitive math
    } else {
      // Calculate the "ideal" step count between current position and the target
      const stepCount = distance / averageSpeed;
      // Increment the current position's latitude with average distance plus some randomness
      this.position.lat +=
        (this.target.lat - this.position.lat) / stepCount +
        random(-jitter, jitter);
      // Increment the current position's longitude with average distance plus some randomness
      this.position.lng +=
        (this.target.lng - this.position.lng) / stepCount +
        random(-jitter, jitter);
    }
    return this.position;
  }
}

The src/ferry.ts module contains the Ferry class, which helps the ferry to navigate between ports and using the Track instance to generate the ferry coordinates:

import { random } from "./helpers";
import { Port } from "./ports";
import { Track } from "./track";

interface FerryOptions {
  ports: Port[];
  averageSpeed?: number;
  jitter?: number;
}

// Helper class to generate ferry's route points
export class Ferry {
  // List of ports with breadcrumbs
  private ports: Port[];
  // Ferry's average speed
  private averageSpeed: number;
  // Ferry's default average speed
  static readonly defaultAverageSpeed = 0.02;
  // Trajectory calculation' randomness factor
  private jitter: number;
  // Default trajectory calculation' randomness factor
  static readonly defaultJitter = 0.005;
  // Current origin port index
  private originIndex: number;
  // Current destination port index
  private destinationIndex: number;
  // "Breadcrumbs" to help ferry navigate in narrow areas
  private track: Track;

  constructor({ ports, averageSpeed, jitter }: FerryOptions) {
    // Initialize the configuration options
    this.ports = ports;
    this.averageSpeed = averageSpeed || Ferry.defaultAverageSpeed;
    this.jitter = jitter || Ferry.defaultJitter;
    // Start in a random port
    this.originIndex = Math.floor(random(0, this.ports.length));
    // Pick a destination port
    this.destinationIndex = this.pickDestination();
    // Make a joined track from both ports' breadcrumbs
    this.track = this.makeTrack();
  }

  // Update the ferry's average speed
  setAverageSpeed(averageSpeed: number) {
    this.averageSpeed = averageSpeed;
  }

  // Update the trajectory calculation' randomness factor
  setJitter(jitter: number) {
    this.jitter = jitter;
  }

  // Pick a destination port
  private pickDestination() {
    // Simply select a next port in the list
    const index = this.originIndex + 1;
    // Mind the overflow
    return index >= this.ports.length ? 0 : index;
  }

  // Create a track from origin and destination ports' breadcrumbs
  private makeTrack() {
    return new Track(this.origin.breadcrumbs, this.destination.breadcrumbs);
  }

  // Get the current origin port
  private get origin() {
    return this.ports[this.originIndex];
  }

  // Get the current destination port
  private get destination() {
    return this.ports[this.destinationIndex];
  }

  // Advance the ferry and return the ferry's position
  // Pick the next port if arrived to a destination
  public next() {
    // Generate next position based on a track
    const position = this.track.next(this.averageSpeed, this.jitter);
    // If a ferry reached final track's position (destination)
    if (this.track.done()) {
      // Set the current port as an origin
      this.originIndex = this.destinationIndex;
      // Pick the next destination port
      this.destinationIndex = this.pickDestination();
      // Reinitialize the breadcrumbs track
      this.track = this.makeTrack();
    }
    return position;
  }
}

Finally, the src/app.ts module, which orchestrates everything together:

import { promises as fs } from "fs";
import { pause } from "./helpers";

import { ports } from "./ports";
import { Ferry } from "./ferry";
import { Point } from "./point";

// Path to a file with generated coordinates
const dataFilePath = "route.csv";

// Entrypoint
async function main() {
  console.info("Data generator: started");

  // Initialize a ferry object
  const ferry = new Ferry({ ports });

  // Write a CSV header
  await fs.writeFile(dataFilePath, "lng,lat\n");

  // Infinite loop to generate infinite amount of points
  // Well, at least until the disk is full :)
  while (true) {
    // Generate a ferry's next position
    const position = ferry.next();

    // Generate and write a CSV row with ferry's current position
    const line = `${position.lng},${position.lat}`;
    await fs.appendFile(dataFilePath, `${line}\n`);
    console.info(`New line: ${line}`);

    // Wait for one second before generating the next position
    await pause(1000);
  }
}

main();

To put it briefly, the logic of the application is: ferry is moving between three pre-defined ports. Each second new ferry coordinates are being generated based on "breadcrumbs", previous coordinates, speed and randomness factor (jitter). The coordinates are being put into the CSV file. Read the comments to better understand what is going on. Note the dataFilePath constant: we will be adjusting its value later. For now, start the Node.js application using npm start. You should see some logging in the terminal, for example:

Data generator: started
New line: 7.985321,58.144044
New line: 7.98688,58.14229
New line: 7.98738,58.140142
New line: 7.988785,58.133968
New line: 8.001452,58.128519
New line: 8.008597,58.125508

And also route.csv file should appear in the application's folder (it should contain the CSV header and the same lines as the terminal output).

You can stop the script execution for now using Ctrl+C on your keyboard. Our data emulator is ready for now (later we will adjust its code to provide some IoT messaging capabilities).

Visualizer (R Shiny)

First, you need to install R language and RStudio. I suggest to use this official instruction to install both.

Select FileNew Project… from the menu, select New DirectoryShiny Application, select a convenient path for a new project, type visualizer as a directory name and click a Create Project button. RStudio will present you with a pre-populated app.R source file. You can click Run App button in the top right corner of a text editor to start the example R Shiny application and play with it. If you're interested in learning R Shiny, there is an excellent official tutorial.

For now, let's replace the code in app.R source file with the following (another disclaimer: not for production use):

# R Shiny library (powering the interactive full-stack web application)
library(shiny)
# Leaflet library used to visualize the map
library(leaflet)

# Shiny UI (in this case — just the full-screen leaflet map)
ui <- fluidPage(tags$style(type = "text/css", "#map {height: 100vh !important;}"),leafletOutput("map"))

# Shiny server code (logic)
server <- function(input, output, session) {
  # Path/name of a CSV file with ferry positions
  points_file_name = "route.csv"

  # Dynamic polling for a CSV file contents
  # Positions are stored as a data.frame
  points <- reactivePoll(
    # Poll each second (1000 ms) for a changes
    1000,
    session,
    # Function to check whether the file modification time has changed
    # To avoid reload the file if it has not been changed
    checkFunc = function() {
      if (file.exists(points_file_name))
        file.info(points_file_name)$mtime[1]
      else
        ""
    },
    # Function to read the file contents
    valueFunc = function() {
      if (file.exists(points_file_name))
        read.csv(points_file_name)
      else
        data.frame(lat = c(),
                   lng = c(),)
    }
  )

  # Render the map into the Shiny UI element
  output$map <- renderLeaflet({
    # The map itself
    leaflet() %>%
      # The map tiles (see https://rstudio.github.io/leaflet/basemaps.html)
      addProviderTiles(providers$Stamen.TonerLite,
                       options = providerTileOptions(noWrap = TRUE)) %>%
      # Starting map lat/lng bounds (hard-coded, don't do that in production code)
      fitBounds(7.9, 57.5, 10.1, 59.1)
  })

  # Observer which will be triggered when any reactive dependency changes
  # In this particular case the only dependency is the points data frame
  observe({
    pts <- points()
    # This is the way to tell leaflet to apply changes to an already created map
    leafletProxy("map", data = data.matrix(pts)) %>%
      # Remove all the points / lines
      clearShapes() %>%
      # Add the points (which are actually small circles)
      addCircles(color = "green", weight = 2) %>%
      # Add the trajectory lines
      addPolylines(weight = 0.9)
  })
}

# Start the R Shiny application
shinyApp(ui, server)

To put it briefly, the logic of the application is the following: draw a map, zoom it on a provided starting bounds, and start polling the CSV file with ferry positions each second. Draw aqcuired points on the map as small circles, connect the circles with lines (to show the trajectory).

But right now there is no route.csv file inside the visualizer app, so Shiny application has nothing to draw on the map. Let's fix this.

Generator & Visualizer: back-to-back

Let's adjust the Node.js generator application a bit: we need to put the proper path to an R Shiny application into this variable:

// Path to a file with generated coordinates
const dataFilePath = "route.csv";

If you're using MacOS or Linux, this path might look something like this (note: this is just an example, you need to adjust the value according to the real path to a visualizer application in your system):

const dataFilePath = "/home/me/src/RShiny/visualizer/route.csv";

If you're using Windows, it will probably look something like this (also see the note above):

const dataFilePath = "c:\\Users\\me\\src\\RShiny\\visualizer\\route.csv";

After changing this path in generator application, (re)start it with npm start. After string the visualizer application you should see the dynamically updating trajectory on the map.

Map

Now, let's see how can we make these applications to run on IoT Edge device.

Azure IoT Edge solution

To create an Azure IoT Edge solution, I suggest to install the Azure IoT Tools extension pack for Visual Studio Code (both extension pack and IDE itself are free to use). After the extension pack has been installed, open a new Visual Studio Code window, press Ctrl+Shift+P to display the command palette, and start typing "Azure IoT Edge". You should see the item Azure IoT Edge: New IoT Edge Solution. Click on it, select a path where you want your solution to be stored; then enter the name of the solution (for example, iot-ferry); then select Empty Solution.

You can see that several files and a directory named modules were created. The deployment.template.json file describes the IoT Edge device template; the deployment.debug.template.json is the debug version of deployment.template.json. The modules directory is empty for now; we will be adding our two applications as an IoT Edge modules there shortly. As the official documentation says, the modules are the smallest unit of computation managed by IoT Edge.

Let's start converting our applications to IoT Edge modules by copying generator and visualizer folders into the modules directory of the iot-ferry solution. The structure should look like this:

Modules structure

Azure IoT Edge works with containerized modules, so we need to provide instructions on how to build containers for each module. Let's add a Dockerfile for both generator and visualizer applications. An annotated example of a Dockerfile for Node.js application (generator):

# Base image for this container
FROM node:16-alpine3.16

# Create a temporary directory for a CSV document
RUN mkdir /tmp/shiny-data
# Set a "free-for-all" permissions on this directory
RUN chmod 777 /tmp/shiny-data
# Create an application directory and set proper permissions
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
# Change current directory to an application folder
WORKDIR /home/node/app
# Switch user to node
USER node
# Copy package.json and package-lock.json into the container
COPY package.json ./
# Install dependencies
RUN npm install
# Copy project files into the container
COPY --chown=node:node . .
# Start the Node.js application
CMD [ "npm", "start" ]

An annotated example of a Dockerfile for R Shiny application (visualizer):

# Base image for this container
FROM rocker/shiny:4.2.1

# Install dependencies
RUN R -e 'install.packages(c(\
  "shinyjs", \
  "leaflet" \
  ) \
  )'

# Remove all the Rocker example project files
RUN rm -rf /srv/shiny-server/**
# Copy application files into the container
COPY ./app.R /srv/shiny-server/

In order for Azure IoT Tools to understand that the applications inside the modules directory are indeed the IoT Edge modules, we need to add a module.json file to both generator and visualizer sub-folders. Example of such file for the generator:

{
  "$schema-version": "0.0.1",
  "description": "Ferry coordinates generator",
  "image": {
    "repository": "${CONTAINER_REGISTRY_SERVER}/generator",
    "tag": {
      "version": "0.0.1",
      "platforms": {
        "amd64": "./Dockerfile"
      }
    },
    "buildOptions": []
  },
  "language": "javascript"
}

And here is an example of a module.json file for the visualizer:

{
  "$schema-version": "0.0.1",
  "description": "Ferry trajectory visualizer",
  "image": {
    "repository": "${CONTAINER_REGISTRY_SERVER}/visualizer",
    "tag": {
      "version": "0.0.1",
      "platforms": {
        "amd64": "./Dockerfile"
      }
    },
    "buildOptions": []
  },
  "language": "R"
}

As you can see, the structure of these module.json files is quite trivial.

Next, we need to adjust deployment.template.json so it will contain information about our modules. Open this file, locate the empty section:

"modules": {}

and replace it with the following:

"modules": {
  "generator": {
    "version": "0.0.1",
    "type": "docker",
    "status": "running",
    "restartPolicy": "always",
    "settings": {
      "image": "${MODULES.generator}",
      "createOptions": {
        "HostConfig": {
          "Binds": ["shiny-data:/tmp/shiny-data"]
        }
      }
    }
  },
  "visualizer": {
    "version": "0.0.1",
    "type": "docker",
    "status": "running",
    "restartPolicy": "always",
    "settings": {
      "image": "${MODULES.visualizer}",
      "createOptions": {
        "HostConfig": {
          "Binds": ["shiny-data:/tmp/shiny-data"],
          "PortBindings": {
            "3838/tcp": [
              {
                "HostPort": "8765"
              }
            ]
          }
        }
      }
    }
  }
}

Important parts are:

  • "Binds" field, which describes mapping of a Docker volume to a filesystem path (needed so modules would share a filesystem volume to have access to the same routes.csv file)
  • "PortBindings" field, which describes ports mapping for R Shiny app: port 3838 from inside the container will be mapped to port 8765 accesible from outside (so you will be able to access R Shiny app in browser)

You can get more information on deployment template syntax here.

Also, we need to let IoT Edge tools know where to put docker images (these will be built a bit later). At the moment we will use local container registry. To do so we need to set up several environment variables by creating .env file in solution's root directory (iot-ferry) and pasting these variables there:

CONTAINER_REGISTRY_SERVER="localhost:5000"
CONTAINER_REGISTRY_USERNAME=""
CONTAINER_REGISTRY_PASSWORD=""

After that right-click on the deployment.template.json and select Build and run IoT Edge solution in Simulator. After some time and quite a lot of terminal output, you should see the logs from visualizer and generator. Open http://localhost:8765/ in the browser and you should see R Shiny application, working as an IoT Edge module in the simulator. To stop the simulator, press Ctrl+C in the terminal.

Modules: messaging and dynamic properties

Azure IoT Edge provides module←→module and module←→cloud communication capabilities. Let's utilize these capabilities to:

  • allow to modify ferry's average speed and jitter dynamically from the cloud
  • report ferry's current position to the separate analyzer module
  • calculate total distance in the analyzer module and report it periodically to the cloud

First, let's install a couple of npm dependencies into the generator module (these will allow to use Azure IoT device API):

npm i azure-iot-device azure-iot-device-mqtt

Next, let's add a new TypeScript file src/iot.ts to the generator module. This file contain a helper class to incapsulate the interaction with IoT Hub:

import { Mqtt as Transport } from "azure-iot-device-mqtt";
import { ModuleClient as Client, Twin } from "azure-iot-device";
import { Message } from "azure-iot-device";
import { exit } from "./helpers";

// Properties which could be controlled from IoT Hub
interface TwinDesiredProperties {
  AverageSpeed?: number;
  Jitter?: number;
}

// Type describing the callback for a properties change
type TwinDesiredPropertiesCallback = (props: TwinDesiredProperties) => void;
// Type describing the callback for a new message
type MessageCallback = (input: string, data: string) => void;

// Options used when creating IoTClient
interface IoTClientOptions {
  // Callback for a new message
  onMessage: MessageCallback;
  // Callback for a properties change
  onTwinDesiredProperties: TwinDesiredPropertiesCallback;
}

// Helper class to work with IoT Client
export class IoTClient {
  constructor(private client: Client) {}

  // Create a new IoTClient instance
  static async create({
    onMessage,
    onTwinDesiredProperties,
  }: IoTClientOptions) {
    try {
      // Create and initialize IoT Client
      const client = await Client.fromEnvironment(Transport);
      client.on("error", exit.bind(-1));
      await client.open();
      console.info("IoT Hub module client initialized");

      // React to an incoming message
      client.on("inputMessage", (input: string, message: Message) => {
        // Transform the message data to a string
        const data = message.getBytes().toString("utf-8");
        console.info(
          `Message received: input=${input}; data=${data}`
        );
        // Execute the passed callback
        onMessage(input, data);
      });

      // Initialize modules twin
      const twin = await client.getTwin();
      // Execute the passed callback with the immediately received properties
      onTwinDesiredProperties(twin.properties.desired);
      // React to properties change
      twin.on("properties.desired", (delta) => {
        // Execute the passed callback with the received properties delta
        onTwinDesiredProperties(delta);
      });

      return new IoTClient(client);
    } catch (error) {
      exit(-1, error);
    }
  }

  // Send the data to the named output as an IoT message
  async send(output: string, data: string) {
    const message = new Message(data);
    await this.client.sendOutputEvent(output, message);
    console.info(
      `Message sent: output=${output}; data=${data}`
    );
  }
}

Next, we need to use IoTClient class in src/app.ts. First, import it:

import { IoTClient } from "./iot";

After that, initialize it and provide message and properties change handlers (this code need to be put right after initializing the ferry object):

  // Create an IoT Client
  const iotClient = await IoTClient.create({
    // When new message arrives,
    onMessage: (input, data) => {
      // Just log it to the console
      console.info(input, data);
    },
    // When module twin properties changes,
    onTwinDesiredProperties: ({ AverageSpeed, Jitter }) => {
      // Set new average speed if provided
      if (AverageSpeed) ferry.setAverageSpeed(AverageSpeed);
      // Set new jitter if provided
      if (Jitter) ferry.setJitter(Jitter);
    },
  });

And finally, we need to send ferry's position to the analyzer module each time new position is being generated (this code need to be put right after generating a ferry's next position):

    // Send a ferry's position to an analyzer
    iotClient.send("position", `${position.lat}:${position.lng}`);

Final src/app.ts code (generator module) should look like this:

import { promises as fs } from "fs";
import { pause } from "./helpers";
import { IoTClient } from "./iot";

import { ports } from "./ports";
import { Ferry } from "./ferry";

// File to save the route coordinates to
const dataFilePath = "/tmp/shiny-data/route.csv";

// Entrypoint
async function main() {
  console.info("Data generator: started!");

  // Initialize a ferry object
  const ferry = new Ferry({ ports });

  // Create an IoT Client
  const iotClient = await IoTClient.create({
    // When new message arrives,
    onMessage: (input, data) => {
      // Just log it to the console
      console.info(input, data);
    },
    // When module twin properties changes,
    onTwinDesiredProperties: ({ AverageSpeed, Jitter }) => {
      // Set new average speed if provided
      if (AverageSpeed) ferry.setAverageSpeed(AverageSpeed);
      // Set new jitter if provided
      if (Jitter) ferry.setJitter(Jitter);
    },
  });

  // Write a CSV header
  await fs.writeFile(dataFilePath, "lng,lat\n");

  // Infinite loop to generate infinite amount of points
  // Well, at least until the disk is full :)
  while (true) {
    // Generate a ferry's next position
    const position = ferry.next();
    // Send a ferry's position to an analyzer
    iotClient.send("position", `${position.lat}:${position.lng}`);

    // Generate and write a CSV row with ferry's current position
    const line = `${position.lng},${position.lat}`;
    await fs.appendFile(dataFilePath, `${line}\n`);
    console.info(`New line: ${line}`);

    // Wait for one second before generating the next position
    await pause(1000);
  }
}

main();

Now, let's implement a third module: analyzer. It will be responsible for calculating the total distance travelled by the ferry and periodically sending this total distance to the cloud.

Create a new analyzer directory in the modules folder of IoT solution. Copy package.json, tsconfig.json and module.json files from the modules/generator directory to the modules/analyzer. Change generator → analyzer in package.json and module.json files. Add a modules/analyzer/Dockerfile with following contents:

# Base image for this container
FROM node:16-alpine3.16

# Create an application directory and set proper permissions
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
# Change current directory to an application folder
WORKDIR /home/node/app
# Switch user to node
USER node
# Copy package.json and package-lock.json into the container
COPY package.json ./
# Install dependencies
RUN npm install
# Copy project files into the container
COPY --chown=node:node . .
# Start the Node.js application
CMD [ "npm", "start" ]

Now, for our new analyzer module we will need some helper functions and IoT Client class, which will be very similar to those used in generator module. Usually it is a good idea to move such common code into the separate library and reuse it as an npm dependency in both modules. But to simplify this guide, we will just copy-paste some code with slight modifications. It is OK for our example, but please don't do that in production code.

First, let's create src/helpers.ts (for analyzer module):

// Exit process with optional error reporting
export function exit(code: number, error?: unknown): never {
  if (error) console.error(error);
  process.exit(code);
}

// Interface to describe a coordinate point
export interface Point {
  lat: number;
  lng: number;
}

// Calculate the distance between two points
export const distance = (point1: Point, point2: Point) =>
  Math.sqrt(
    Math.pow(point2.lat - point1.lat, 2) + Math.pow(point2.lng - point1.lng, 2)
  );

Next, we will need our IoT Client in src/iot.ts (note that it is stripped of some functionality used in generator module, as it is not required here):

import { Mqtt as Transport } from "azure-iot-device-mqtt";
import { ModuleClient as Client } from "azure-iot-device";
import { Message } from "azure-iot-device";
import { exit } from "./helpers";

// Type describing the callback for a new message
type MessageCallback = (input: string, data: string) => void;

// Options used when creating IoTClient
interface IoTClientOptions {
  // Callback for a new message
  onMessage: MessageCallback;
}

// Helper class to work with IoT Client
export class IoTClient {
  constructor(private client: Client) {}

  // Create a new IoTClient instance
  static async create({ onMessage }: IoTClientOptions) {
    try {
      // Create and initialize IoT Client
      const client = await Client.fromEnvironment(Transport);
      client.on("error", exit.bind(-1));
      await client.open();
      console.info("IoT Hub module client initialized");

      // React to an incoming message
      client.on("inputMessage", (input: string, message: Message) => {
        // Transform the message data to a string
        const data = message.getBytes().toString("utf-8");
        console.info(
          `Message received: input=${input}; data=${data}`
        );
        // Execute the passed callback
        onMessage(input, data);
      });

      return new IoTClient(client);
    } catch (error) {
      exit(-1, error);
    }
  }

  // Send the data to the named output as an IoT message
  async send(output: string, data: string) {
    const message = new Message(data);
    await this.client.sendOutputEvent(output, message);
    console.info(
      `Message sent: output=${output}; data=${data}`
    );
  }
}

And finally, the src/app.ts:

import { distance, Point } from "./helpers";
import { IoTClient } from "./iot";

// How often to report total distance
const reportingIntervalMs = 10 * 1000;

// Entry point
async function main() {
  // Total distance travelled by the ferry
  let totalDistance = 0;
  // Ferry's latest position
  let position: Point | null = null;

  // Create an IoT Client
  const iotClient = await IoTClient.create({
    // When the new message arrive
    onMessage: (input, data) => {
      // If message arrived to the "position" input
      if (input === "position") {
        // fetch latitude and longitude from the message's data
        const [lat, lng] = data.split(":").map(Number);

        // Increment the total distance
        if (position) totalDistance += distance({ lat, lng }, position);
        // Update the latest position
        position = { lat, lng };
      }
    },
  });

  setInterval(() => {
    // Report the total distance to IoT Hub each reportingIntervalMs ms
    iotClient.send("totalDistance", `${totalDistance}`);
  }, reportingIntervalMs);
}

main();

It is also important to add the analyzer module settings to the deployment.template.json, under the modulesContent/$edgeAgent/properties.desired/modules key (it is convenient to paste these right above the "generator" settings):

"analyzer": {
  "version": "0.0.2",
  "type": "docker",
  "status": "running",
  "restartPolicy": "always",
  "settings": {
    "image": "${MODULES.analyzer}"
  }
},

Also, to make sure that IoT Edge runtime will route our messages properly, we need to add the following routes under the modulesContent/$edgeHub/properties.desired/routes key:

"routes": {
  "generatorToAnalyzer": "FROM /messages/modules/generator/outputs/position INTO BrokeredEndpoint(\"/modules/analyzer/inputs/position\")",
  "analyzerToCloud": "FROM /messages/modules/analyzer/outputs/* INTO $upstream"
},

First route will allow generator to send messages to analyzer. Second route will allow analyzer to send messages to the cloud.

Also, we need to add a generator module properties (with default values) under the modulesContent/generator key:

"generator": {
  "properties.desired": {
    "AverageSpeed": 0.02,
    "Jitter": 0.005
  }
}

Final version of deployment.template.json should look like this:

{
  "$schema-template": "4.0.0",
  "modulesContent": {
    "$edgeAgent": {
      "properties.desired": {
        "schemaVersion": "1.1",
        "runtime": {
          "type": "docker",
          "settings": {
            "minDockerVersion": "v1.25",
            "loggingOptions": "",
            "registryCredentials": {
              "iotferry": {
                "address": "${CONTAINER_REGISTRY_SERVER}",
                "password": "${CONTAINER_REGISTRY_PASSWORD}",
                "username": "${CONTAINER_REGISTRY_USERNAME}"
              }
            }
          }
        },
        "systemModules": {
          "edgeAgent": {
            "type": "docker",
            "settings": {
              "image": "mcr.microsoft.com/azureiotedge-agent:1.2",
              "createOptions": {}
            }
          },
          "edgeHub": {
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "mcr.microsoft.com/azureiotedge-hub:1.2",
              "createOptions": {
                "HostConfig": {
                  "PortBindings": {
                    "5671/tcp": [
                      {
                        "HostPort": "5671"
                      }
                    ],
                    "8883/tcp": [
                      {
                        "HostPort": "8883"
                      }
                    ],
                    "443/tcp": [
                      {
                        "HostPort": "443"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "modules": {
          "analyzer": {
            "version": "0.0.2",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.analyzer}"
            }
          },
          "generator": {
            "version": "0.0.2",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.generator}",
              "createOptions": {
                "HostConfig": {
                  "Binds": ["shiny-data:/tmp/shiny-data"]
                }
              }
            }
          },
          "visualizer": {
            "version": "0.0.2",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.visualizer}",
              "createOptions": {
                "HostConfig": {
                  "Binds": ["shiny-data:/tmp/shiny-data"],
                  "PortBindings": {
                    "3838/tcp": [
                      {
                        "HostPort": "8765"
                      }
                    ]
                  }
                }
              }
            }
          }
        }
      }
    },
    "$edgeHub": {
      "properties.desired": {
        "schemaVersion": "1.2",
        "routes": {
          "generatorToAnalyzer": "FROM /messages/modules/generator/outputs/position INTO BrokeredEndpoint(\"/modules/analyzer/inputs/position\")",
          "analyzerToCloud": "FROM /messages/modules/analyzer/outputs/* INTO $upstream"
        },
        "storeAndForwardConfiguration": {
          "timeToLiveSecs": 7200
        }
      }
    },
    "generator": {
      "properties.desired": {
        "AverageSpeed": 0.02,
        "Jitter": 0.005
      }
    }
  }
}

Now, let's try to run our IoT solution in simulator again. Right-click on the deployment.template.json and select Build and run IoT Edge solution in Simulator. After some time and quite a lot of terminal output, you should see logs which will look like this:

generator     | New line: 7.98688,58.14229
generator     | Message sent: output=position; data=58.14229:7.98688
analyzer      | Message received: input=position; data=58.14229:7.98688
generator     | New line: 7.98738,58.140142
generator     | Message sent: output=position; data=58.140142:7.98738
analyzer      | Message received: input=position; data=58.140142:7.98738
generator     | New line: 7.988785,58.133968
generator     | Message sent: output=position; data=58.133968:7.988785
analyzer      | Message received: input=position; data=58.133968:7.988785

Once in a while you will also see the message:

analyzer      | Message sent: output=totalDistance; data=0.1049695643811563

This shows that the messaging is indeed working (at least between modules). Now let's check how all this works on a separate device with proper IoT Edge runtime.

Azure Infrastructure

Let's setup an infrastructure required for our project. Login into the account (or create a new one) in Microsoft Azure. In Visual Studio Code, open Command Palette with Ctrl+Shift+P and type-and-select Azure: Sign In. You will be redirected to the browser to confirm Azure login.

Let's create IoT Hub:

  • go back to VSCode, open Command Palette, type Azure IoT Hub: Create IoT Hub
  • select default subscription
  • create resource group rg-iot-ferry (rg prefix is for "resource group") in a suitable location
  • select the same location for IoT Hub
  • select F1: Free tier
  • enter hub-iot-ferry as an IoT Hub name
  • pay attention to the OUTPUT tab of the integrated terminal: there should be Creating IoT Hub: hub-iot-ferry log message
  • after some time (usually up to a minute), IoT Hub 'hub-iot-ferry' is created message should appear — this means that IoT Hub is ready to use

Next, let's create an IoT Edge device:

  • open Command Palette, type Azure IoT Edge: Create IoT Edge Device
  • type ed-iot-ferry as a device name (ed prefix is for "edge device")

Now, you should be able to see newly created IoT Hub and Iot Edge Device in VSCode explorer:

VSCode IoT Hub

We will use Azure Container Registry to store container images for iot-ferry modules. To set it up:

  • open Command Palette, type Azure Container Registry: Create Registry…
  • select Connect Registry… → Azure
  • type iotferry as a registry name (this name should be globally unique, so you might need to add a unique postfix)
  • select Basic SKU
  • select rg-iot-ferry resource group
  • select the same location as for previously created resources
  • wait for registry to create

Now, we need to let our IoT solution know where to store container images. First, we need to enable accessing container registry via login/password. To do so:

  • open https://portal.azure.com/ (login if needed)
  • go to Container registriesiotferry<postfix> → (Settings) Access Keys
  • enable Admin user and copy values from Login server, Username, password fields into corresponding fields in .env file:
    CONTAINER_REGISTRY_SERVER="iotferry.azurecr.io"
    CONTAINER_REGISTRY_USERNAME="iotferry"
    CONTAINER_REGISTRY_PASSWORD="…"
    

Next, let's push container images for IoT solution to Azure Container Registry. To do so:

  • execute az acr login --name iotferry in the command line
  • right-click on the deployment.template.json and select Build and push IoT Edge solution

You can check that container images were successfully uploaded by going to Azure Portal → Container registriesiotferry → (Services) Repositories.

To create a deployment for our IoT device, right-click on the ed-iot-ferry device in the AZURE IOT HUB tab, pick Create Deployment for Single Device menu point and select the iot-ferry/config/deployment.amd64.json file in the file dialog.

IoT Edge runtime

Now, to setup IoT Edge runtime you'll need to use a virtual machine. You can run one in the cloud, but for the purpose of this guide we will use a local one.

Download and install the latest version of VirtualBox from the official site. Download the installation image for Ubuntu 20.04 (it is important to use exactly 20.04). Create a new virtual machine using VirtualBox, supplying Ubuntu image as an Optical Drive storage. I suggest to leave most options by default, but these few:

  • raise Base Memory up to 4096 MB
  • raise processors count to 4
  • raise Video Memory to 64 Mb and enable 3D acceleration

After starting this new VM with Ubuntu iso mounted as an optical drive, Ubuntu installation should start. No noticable options to change during the installation. After the installation has finished, open the terminal and execute sudo apt-get update && sudo apt-get install build-essential gcc make perl dkms. Then, in VirtualBox top menu select DevicesInsert Guest Additions CD image…. Go to the root CD directory in the terminal (cd /media/<username>/VBox_GAs_<version>) and execute ./autorun.sh. After installing Guest Additions, reboot the Ubuntu system and then you should be able to activate shared clipboard by executing from the VirtualBox top menu: DevicesShared ClipboardBidirectional. This will help with copy-pasting some settings later.

Next, open the terminal in Ubuntu VM and follow the instructions from this guide for Ubuntu 20.04. Notably:

  • add Microsoft packages by executing in the terminal:
    wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
    sudo dpkg -i packages-microsoft-prod.deb
    rm packages-microsoft-prod.deb
    
  • install container engine by executing sudo apt-get update && sudo apt-get install moby-engine
  • create the /etc/docker/daemon.json file (for example, by executing sudo nano /etc/docker/daemon.json) with the following contents:
    {
      "log-driver": "local"
    }
    
  • restart the container engine by executing sudo systemctl restart docker
  • install IoT Edge runtime by executing sudo apt-get update && sudo apt-get install aziot-edge defender-iot-micro-agent-edge
  • go back to VSCode on the host machine, right-click on the ed-iot-ferry device in the AZURE IOT HUB tab and pick Copy Device Connection String menu point
  • go back to Ubuntu VM terminal, execute sudo iotedge config mp --connection-string 'PASTE_DEVICE_CONNECTION_STRING_HERE' to let IoT Edge runtime know how to connect
  • apply IoT Edge runtime settings by executing sudo iotedge config apply
  • check that everything is up and running by executing sudo iotedge system status
  • check different modules logs by executing sudo iotedge logs <module-name>, for example:
    • sudo iotedge logs edgeAgent
    • sudo iotedge logs generator
    • sudo iotedge logs analyzer
    • sudo iotedge logs visualizer
  • open Firefox browser in Ubuntu VM, open http://localhost:8765/ to check that generator and visualizer are working properly
  • go back to VSCode on the host machine, right-click on the ed-iot-ferry device in the AZURE IOT HUB tab and pick Start Monitoring Built-in Event Endpoint menu point: you should see the logs from analyzer module (total distance, which is sent to the cloud)

Great, we have our (virtual) IoT Edge device running using real IoT Edge runtime! Now, let's try to tinker with generator module's properties. Open Azure Portal, go to IoT Hub → hub-iot-ferry → (Device management) IoT Edgeed-iot-ferry. On the bottom of the screen you can see the list of modules. Click on the generator module, go to Module Identity Twin, and adjust values under properties/desired keys: for example, double the values for AverageSpeed and Jitter. You should see that ferry's speed (and randomness factor) has increased (check Firefox in Ubuntu VM). From the IoT Hub → hub-iot-ferry → (Device management) IoT Edgeed-iot-ferry screen you can also click on the module's Runtime Status value to see the module's logs.

Don't forget to shut down the Ubuntu VM after this. You might also want to remove all the ferry-related resources from Azure.

About

An article on RShiny dashboard in IoT Edge

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published