The IoL City would like to use their metropolitan free WiFi to better understand how the citizens move about the city. With this information, they can build smarter public areas, provide analytics for local services and trigger other forms of IoT automation.

How does Cisco Meraki CMX work?

All WiFi clients discover a wireless network (SSID) by sending a “probe request“. This is effectively a broadcast message emitted by the device (i.e. phone or laptop), which basically says “Hello! I want to join a network, my name is AA:BB:CC:DD:EE:FF”. In reality, the wireless access points in the area hear a broadcast message with a MAC address. At that point, the Meraki APs will log the time of day, MAC, signal strength and deduce some other bits of info like the manufacturer and operating system. The Meraki cloud will then combine this data with what other APs heard, to help triangulate the client with the help of GPS or X/Y coordinates on a map.

The Cisco Meraki Dashboard will retain this information in an anonymized way to generate helpful trend analysis for marketing research or event space optimization. Alternatively, Meraki can export the raw data feed to your own web service that will use that data however it’s needed.

For a complete explanation on this technology, check out this article.

 

Security & Privacy

As with all things IoT, security and privacy should be an important part of your final solution. If you are collecting this information in an unambiguous way, there should be disclaimers placed where the clients can be informed. This might be on a captive portal, or posted near the access points. Apple and likely Google will anonymize their MAC addresses during the probe request to prevent tracking of unaware clients. Only after the client has associated to a wireless SSID, will their MAC address remain consistent for accurate tracking. More information on security and privacy best practices can be found in a previous IoL article.

Network Diagram

Meraki CMX Diagram

Meraki CMX Diagram

JSON Data

{
  "apMac": <string>,
  "apTags": [<string, ...],
  "apFloors": [<string>, ...],
  "observations": [
    {
      "clientMac": <string>,
      "ipv4": <string>,
      "ipv6": <string>,
      "seenTime": <string>,
      "seenEpoch": <integer>,
      "ssid": <string>,
      "rssi": <integer>,
      "manufacturer": <string>,
      "os": <string>,
      "location": {
        "lat": <decimal>,
        "lng": <decimal>,
        "unc": <decimal>,
        "x": [<decimal>, ...],
        "y": [<decimal>, ...]
      },
    },...
  ]
}

The Meraki Dashboard must be configured to point to your Node-RED service.

CMX Meraki Dashboard API config

In addition, the Meraki APs should be placed on the map within the Meraki Dashboard.

Hardware

Cisco Meraki MR access points

A minimum of three access points are required for triangulation, but one will at least give you some proximity.

Motion Sensor Towermr42-mantle

Cisco Meraki MX security appliance

This device acts as the Internet firewall. Although it’s not a mandatory component, it makes it easy to provide secure access to the Raspberry Pi with a simple NAT rule and by using the built-in Dynamic DNS feature.

mx65-mantle (1)

Raspberry Pi

The CMX web services will be hosted on this small computer, running Node-RED.

Pi2ModB1GB_-comp

Node-RED

To make this information easy to work with, I personally built my own Node-RED “node” to accept the JSON feed provided directly from a Meraki wireless network. I’ll go into the details of building that in a future post.

CMX node

Installation

(Note: I’m running this flow on a Raspberry Pi, so NodeJS and Node-RED are installed by default)

  • Install NodeJS
    • https://nodejs.org/en/download/
  • Install Node-RED
    • Run the following command in your computer terminal
 sudo npm install -g --unsafe-perm node-red
  • Install the Meraki CMX node
    • Run the following command in your Node-RED user directory – typically ~/.node-red
npm i node-red-contrib-meraki-cmx
  • Restart Node-RED

 

Flow

I’ve created a few example flows to demonstrate the power of this new IoT data stream.

In this example, I have attached a “switch” node, which I named “Search Clients. This will look for either a MAC address or machine name to determine the next flow path. If an “Apple” device was found, I simply send a message “Apple device found!” to my debug screen. If my personal phone’s MAC address was found, it will trigger a message “Welcome Back”. If the device has not been seen for 5 minutes, it then sends a message “We Miss You!”. Although I am just sending these messages to the debug console, I have also played with sending myself a Tweet, logging the data and triggering lights in my LEGO city to start flashing. In theory I could create a digital media board to customize a welcome message, or combine it with my hotspot captive portal flow to build up profile information.

CMX sample flow


 

CMX Workflow

Download Flow:

In this example, I’ve pulled in the Meraki CMX node into my flow area. I then double clicked on it to bring up the local configuration.

CMX node

The URL is going to be the end point where Meraki will send the JSON stream of location data. This will look like http://yourserver:1880/cmx2

CMX config node

The Credentials section will hold the configurations specific to your Meraki network. It will consist of a validator and a secret. The validator is used by Meraki to ensure they are delivering content to the correct system. They do this by first sending a [GET] request to your path. The CMX node will automatically respond with this validator key. If successful, the JSON stream will be sent as a [POST] message to the CMX node. The secret will then be used by the CMX node to verify the data is coming from the correct Meraki network.

CMX config credentials

The CMX node is then connected to a switch node. which has two filters created, each representing a different output path.

CMX search function

The first criteria looks to see if the msg.payload contains the word “Apple”, if so the message function node will be triggered to rewrite the msg.payload object with the string “Apple device found!”.

CMX apple device found

The second criteria looks for a specific MAC address, then tells a trigger node to send a string “Welcome Back!”. After waiting for 5 minutes without seeing a new message, the trigger node will then send a string “We miss you :(”

CMX welcome message

That’s it! I can now easily consume WiFi location information to trigger a workflow.

CMX Map

Download Flow:

Since the JSON data contains GPS coordinates, among other things, I thought it would be fun to place this information on a map. To be fair, this front-end was largely ported from a CMX API demo project written with Ruby provided by Meraki. My primary task was re-writing the Ruby code with Node-RED. This involved storing the data into a MongoDB in the structure expected by the front-end code. The front-end will then present a Google Map that allows the ability to track all clients or a specific device. I then enhanced the data provided in the client info window and also allowed for a JSON export from the web page. I thought of many other ideas along the way… but I’m going to keep things simple for now.

Receive CMX Data

This flow section will accept the JSON data either from the CMX node or by injecting sample data.

 

CMX map - receive flow

The “Format Client” function node iterates through the supplied JSON and maps it to a client object to be stored in the database.

// This function extracts the raw CMX data to create a consistent DB entry

map = msg.payload;
client = {}; //reset payload object for clarity

if (map['version'] != '2.0'){
 msg.log = "got post with unexpected version: #{map['version']}";
 return msg;
}else{
 msg.log = "working with correct version";
}
if (map['type'] != 'DevicesSeen'){
msg.log = "got post for event that we're not interested in: #{map['type']}";
return msg;
}

var o = map['data']['observations'];
console.log('map.data.apMac = '+map.data['apMac']);
 for (var c in o){
 if (o.hasOwnProperty(c)) {
 //console.log("Key is " + c + ", value is " + o[c].location.lat);
 if (!o[c]['location']){continue}
 client.name = o[c]['clientMac'];
 client.mac = o[c]['clientMac'];
 client.lat = o[c]['location']['lat'];
 client.lng = o[c]['location']['lng'];
 client.unc = o[c]['location']['unc'];
 client.seenString = o[c]['seenTime'];
 client.seenEpoch = o[c]['seenEpoch'];
 client.floors = map['data']['apFloors'] === null ? "" : map['data']['apFloors'].join;
 client.manufacturer = o[c]['manufacturer'];
 client.os = o[c]['os'];
 client.ssid = o[c]['ssid'];
 client.ap = map['data']['apMac'];
 msg.log = "AP #{map['data']['apMac']} on #{map['data']['apFloors']}: #{c}";
 if (client.seenEpoch===null || client.seenEpoch === 0){continue}// # This probe is useless, so ignore it
 
 }
 msg.payload = client;
 node.send(msg);
 }

 
return msg;

I then use a function node, “build operation parameters: filter, update”, to prepare the MongoDB insert operation.

// This function updates/creates the client in the database
var filter = msg.payload;
if ("string" == typeof filter) {
  filter = JSON.parse(filter);
}

msg.payload = [
    {'name':msg.payload.name},
    msg.payload,
    {upsert:true}
];

return msg;

The MongoDB2 node was something I had to install additionally into Node-RED. It provides tons of options for working with a Mongo database.

You can install it just like the CMX node, by going to the ~/.node-red directory and typing the following.

 

npm install node-red-contrib-mongodb2

 A MongoDB database should be already installed and running for this flow to work. Installation instructions can be found here.

Client Front-end API

This flow section handles the requests made by the front-end page.

The [get] /clients route will perform a lookup in the database with a simple search query, msg.payload = {}, which is equivalent to find({}) in MongoDB. This will return all available documents within the database, which will be sent back to the client.

The [get] /clients/:mac route will accept a MAC address appended to the /clients/ path, which is then parsed out of the params and then formatted in the following two function objects. Then a search for the specific client is performed on the database and returned to the client.

 

CMX map API

Client Front-end Site

This flow section will deliver the webpage that a user will see in their browser. Creating this specific flow was a process in itself, starting with the basic chaining of “mustache” nodes, which I describe in detail here.


CMX map siteThe core set of code is within that JavaScript mustache node. This is what I kindly borrowed from a clever Merakian. It basically utilizes the Google Maps API to place the supplied coordinates on the map and add information from the JSON data.

(function ($) {
  var map,                                      // This is the Google map
    clientMarker,                               // The current marker when we are following a single client
    clientUncertaintyCircle,                    // The circle describing that client's location uncertainty
    lastEvent,                                  // The last scheduled polling task
    lastInfoWindowMac,                          // The last Mac displayed in a marker tooltip
    allMarkers = [],                            // The markers when we are in "View All" mode
    lastMac = "",                               // The last requested MAC to follow
    infoWindow = new google.maps.InfoWindow();  // The marker tooltip
    /*
    ,
    markerImage = new google.maps.MarkerImage('blue_circle.png',
      new google.maps.Size(15, 15),
      new google.maps.Point(0, 0),
      new google.maps.Point(4.5, 4.5)
    );
    */
    
    var latlngbounds = new google.maps.LatLngBounds();

  // Removes all markers
  function clearAll() {
    clientMarker.setMap(null);
    clientUncertaintyCircle.setMap(null);
    lastInfoWindowMac = "";
    var m;
    while (allMarkers.length !== 0) {
      m = allMarkers.pop();
      if (infoWindow.anchor === m) {
        lastInfoWindowMac = m.mac;
      }
      m.setMap(null);
    }
  }

  // Plots the location and uncertainty for a single MAC address
  function track(client) {
    clearAll();
    if (client !== undefined && client.lat !== undefined && !(typeof client.lat === 'undefined')) {
      var pos = new google.maps.LatLng(client.lat, client.lng);
      console.log('track client pos '+pos);
      if (client.manufacturer !== undefined) {
        mfrStr = client.manufacturer + " ";
      } else {
        mfrStr = "";
      }
      if (client.os !== undefined) {
        osStr = " running " + client.os;
      } else {
        osStr = "";
      }
      if (client.ssid !== undefined) {
        ssidStr = " with SSID '" + client.ssid + "'";
      } else {
        ssidStr = "";
      }
      if (client.floors !== undefined && client.floors !== "") {
        floorStr = " at '" + client.floors + "'"
      } else {
        floorStr = "";
      }
      $('#last-mac').text(mfrStr + "'" + lastMac + "'" + osStr + ssidStr +
        " last seen on " + client.seenString + floorStr +
        " with uncertainty " + client.unc.toFixed(1) + " meters (reloading every 20 seconds)");
      map.setCenter(pos);
      clientMarker.setMap(map);
      clientMarker.setPosition(pos);
      clientUncertaintyCircle = new google.maps.Circle({
        map: map,
        center: pos,
        radius: client.unc,
        fillColor: 'RoyalBlue',
        fillOpacity: 0.25,
        strokeColor: 'RoyalBlue',
        strokeWeight: 1
      });
    } else {
      $('#last-mac').text("Client '" + lastMac + "' could not be found");
    }
  }

  // Looks up a single MAC address
  function lookup(mac) {
    $.getJSON('/clients/' + mac, function (response) {
      track(response);
    });
  }

  // Adds a marker for a single client within the "view all" perspective
  function addMarker(client) {
    var pos = new google.maps.LatLng(client.lat, client.lng);
    console.log('addMarker pos '+pos);
    var m = new google.maps.Marker({
      position: pos,
      map: map,
      mac: client.mac,
      //icon: markerImage
    });
    
    if(client.lat){
        latlngbounds.extend(pos);
        map.fitBounds(latlngbounds);
    }
    google.maps.event.addListener(m, 'click', function () {
        //build info
        var htmlString = '<h2>Client:  '+client.name +'</h2>';
        
        for (var key in client) {
            if (client.hasOwnProperty(key)) {
                if(client[key] !== undefined){
                    if(key == '_id' || key == 'name'){continue}
                    htmlString += '<p>'+key+' : '+client[key]+'</p>';
                }
            }
        }
        
        infoWindow.setContent("<div>" + htmlString + "</div>" + "(<a class='client-filter' href='#' data-mac='" +
        client.mac + "'>Follow this client)</a>");
        //
      //infoWindow.setContent("<div>" + client.mac + "</div> (<a class='client-filter' href='#' data-mac='" +
        //client.mac + "'>Follow this client)</a>");
        
      infoWindow.open(map, m);
    });
    if (client.mac === lastInfoWindowMac) {
      infoWindow.open(map, m);
    }
    allMarkers.push(m);
  }

  // Displays markers for all clients
  function trackAll(clients) {
    clearAll();
    if (clients.length === 0) {
      $('#last-mac').text("Found no clients (if you just started the web server, you may need to wait a few minutes to receive pushes from Meraki)");
    } else { $('#last-mac').text("Found " + clients.length + " clients (reloading every 20 seconds)"); }
    clientUncertaintyCircle.setMap(null);
    clients.forEach(addMarker);
  }

  // Looks up all MAC addresses
  function lookupAll() {
    $('#last-mac').text("Looking up all clients...");
    $.getJSON('/clients/', function (response) {
      trackAll(response);
    });
  }

  // Begins a task timer to reload a single MAC every 20 seconds
  function startLookup() {
    lastMac = $('#mac-field').val().trim();
    if (lastEvent !== null) { window.clearInterval(lastEvent); }
    lookup(lastMac);
    lastEvent = window.setInterval(lookup, 20000, lastMac);
  }

  // Begins a task timer to reload all MACs every 20 seconds
  function startLookupAll() {
    if (lastEvent !== null) { window.clearInterval(lastEvent); }
    lastEvent = window.setInterval(lookupAll, 20000);
    lookupAll();
  }

  // This is called after the DOM is loaded, so we can safely bind all the
  // listeners here.
  function initialize() {
    var center = new google.maps.LatLng(37.7705, -122.3870);
    var mapOptions = {
      zoom: 20,
      center: center
    };
    map = new google.maps.Map(document.getElementById('map-canvas'), mapOptions);
    clientMarker = new google.maps.Marker({
      position: center,
      map: null,
      //icon: markerImage
    });
    clientUncertaintyCircle = new google.maps.Circle({
      position: center,
      map: null
    });

    $('#track').click(startLookup).bind("enterKey", startLookup);

    $('#all').click(startLookupAll);

    $(document).on("click", ".client-filter", function (e) {
      e.preventDefault();
      var mac = $(this).data('mac');
      $('#mac-field').val(mac);
      startLookup();
    });

    startLookupAll();
  }

  // Call the initialize function when the window loads
  $(window).load(initialize);
}(jQuery));

The CSS mustache node does the styling. (I’ve left that code out to stay on topic)

The HTML mustache node will then import the JavaScript and CSS information since they were set as properties of the msg.payload object.

<html>
  <head>
    <title>CMX push API demo app with Node-RED</title>
    <meta name="viewport" content="initial-scale=1.0, user-scalable=no">
    <meta charset="utf-8">
    <script>TypekitConfig={kitId:"hum1oye",scriptTimeout:1.5e3},function(){var a=document.getElementsByTagName("html")[0];a.className+=" wf-loading";var b=setTimeout(function(){a.className=a.className.replace(/(\s|^)wf-loading(\s|$)/g,""),a.className+=" wf-inactive"},TypekitConfig.scriptTimeout),c=document.createElement("script");c.src="//use.typekit.com/"+TypekitConfig.kitId+".js",c.type="text/javascript",c.async="true",c.onload=c.onreadystatechange=function(){var a=this.readyState;if(!a||a=="complete"||a=="loaded"){clearTimeout(b);try{Typekit.load(TypekitConfig)}catch(c){}}};var d=document.getElementsByTagName("script")[0];d.parentNode.insertBefore(c,d)}()</script>
    <script src="https://maps.googleapis.com/maps/api/js?v=3.exp&sensor=false"></script>
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
    <script>{{{payload.script}}}</script>
    <style>{{{payload.style}}}</style>
  </head>
  <body>
    <div id="masthead">
      <div id="masthead-content">
        <img src="https://meraki.cisco.com/img/cisco-meraki.png"/>
      </div>
    </div>
    <div id="content">
      <h1>CMX API Demo with Node-RED</h1>
      <div id="mac-address">
        <input id="mac-field" type="text" placeholder="Enter MAC address" />&nbsp;
        <button id="track">Follow</button>&nbsp;
        <button id="all">View All</button>
        <button><a href=/clients target="_blank" style="text-decoration:none; color: inherit">View All - JSON</a></button>
      </div>
      <div id="last-mac"></div>
      <div class="small"><span class="bold">Clients in the wrong place?</span> Make sure your APs are placed properly in Dashboard.</div>
      <div id="map-wrapper">
        <div id="map-canvas"></div>
      </div>
    </div>
  </body>
</html>

Note:

I had to create a Google API key to append to the import script for this to not generate errors. The real HTML line will look something like this

src="https://maps.googleapis.com/maps/api/js?v=3.exp&sensor=false&key=XXXXXXXXXXXfLzjGaepofBse9sHFF-S-mtqVjzLA

 

The Website

In the browser, navigate to the path for your new website to see the results.

http://localhost:1880/cmxapimap
CMX map website

CMX map client details

 

Client JSON

By using Postman or simply clicking on the JSON button, all of the data within the MongoDB can be exported.

CMX map Postman

Conclusion

When thinking about IoT, sensors are one of the most important parts. By leveraging the Cisco Meraki CMX API, a huge amount of sensor data can be obtained that will enable location based workflows and analysis with ease.