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
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.
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.
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.
Raspberry Pi
The CMX web services will be hosted on this small computer, running Node-RED.
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.
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 Workflow
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.
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
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.
The CMX node is then connected to a switch node. which has two filters created, each representing a different output path.
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!”.
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 :(”
That’s it! I can now easily consume WiFi location information to trigger a workflow.
CMX Map
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.
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.
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.
The 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” />
- <button id=“track”>Follow</button>
- <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
Client JSON
By using Postman or simply clicking on the JSON button, all of the data within the MongoDB can be exported.
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.
the original post is from http://www.internetoflego.com/wifi-location-based-analytics-workflows-cisco-meraki-cmx/
Leave a Reply
You must be logged in to post a comment.