René van Mil

Live Heart Rate With iOS and Meteor

When I go out running I always have my iPhone with me, together with a Mio Heart Rate Watch and the Runmeter App. Having built a few apps with Meteor after publishing my previous post, I thought it would be fun to try building something which shows a live heart rate chart on a website, while I’m outside running. Turns out, this is was a lot easier than I thought it would be :)

Summary

  • The Meteor app shows a heart rate chart in the browser, which is continuously updated when new heart rate values are added to the database.
  • The Meteor app provides an addheartrate webservice which can be used to POST a heart rate value to, which is saved into the database.
  • The iOS app connects to the heart rate monitor and sends all received HR updates to the addheartrate webservice.

Both the iOS and Meteor app created for this blog post can be found over here.

The Meteor app

Step 1 - Create the Meteor app

Make sure you have Meteor installed and run the following commands from the folder in which you want to create the demo app.

Create Meteor App
1
2
3
4
5
6
7
meteor create HRlive
cd HRlive
meteor remove autopublish
meteor remove insecure
meteor add twbs:bootstrap
meteor add peernohell:c3
meteor add iron:router

This will create a new Meteor app, remove the autopublish and insecure packages for security, and add the following packages:

  • twbs:bootstrap: Bootstrap to add some style to our app
  • peernohell:c3: C3.js used to create the chart
  • iron:router: Iron.Router a router for Meteor

Step 2 - Define a heart rate collection

We need a collection to store the received heart rate values and let the client subscribe to its updates.

collections.js
1
HeartRate = new Mongo.Collection('heartrate');

Step 3 - Server scripts

The application.js file contains the Meteor startup method, which allows us to clear the database when the app is restarted.

application.js
1
2
3
Meteor.startup(function() {
    HeartRate.remove({}); // Delete everything on startup
});

In the methods.js file we define an addHeartRate method, which will insert the provided heart rate value into the heart rate collection.

methods.js
1
2
3
4
5
6
7
8
9
10
Meteor.methods({

    addHeartRate: function(heartRate) {
        HeartRate.insert({
            date: new Date().getTime(),
            hr: heartRate
        });
    }

});

The heart rate collection must be published to the clients, so they are able to subscribe to its updates.

publications.js
1
2
3
Meteor.publish('heartrate', function() {
    return HeartRate.find();
});

In the server router.js file we specify the addheartrate route as a server route. A server route means it is possible to access the Node.js request and response objects, which allows us to implement a web service in the Meteor app.

The implementation executes the following steps:

  1. Check if the request was sent as a POST request, otherwise return 405.
  2. Read the body of the request (and to be safe, abort the connection if too much data was sent).
  3. Use wrapAsync to wrap the callback for the request end event, because the callback needs to execute a Meteor method, which will not work if the callback is not wrapped.
  4. The request body was read and is added to the collection with the addHeartRate Meteor method.
  5. All done, return OK.
router.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Router.route('/addheartrate', function() {
    var request = this.request;
    var response = this.response;
    if (request.method === 'POST') {
        var body = '';
        request.on('data', function(data) {
            body += data;
            if (body.length > 1e6) request.connection.destroy();
        });

        var requestEnd = function(body, callback) {
            request.on('end', function() {
                callback();
            });
        }
        var requestEndSync = Meteor.wrapAsync(requestEnd, this);
        requestEndSync(body, function() {
            Meteor.call('addHeartRate', body);
            response.end('OK');
        });

    } else {
        response.statusCode = 405;
        response.end();
    }
}, {where: 'server'});

Step 4 - Client scripts

In the application.js file the client subscribed to the heart rate collection.

application.js
1
HeartRateSub = Meteor.subscribe('heartrate');

The index.js contains all the logic used to render and update the heart rate chart.

  1. A session variable chartRendered is used to track if the chart template has been rendered by the DOM, because we cannot generate the chart before the DOM has completely rendered the template.
  2. As soon as the chart template was rendered by the DOM and the heart rate collection subscription is ready to use, we fetch the last 30 records from the heart rate collection and use those to render the chart. This is done using a Tracker autorun function, which means the function is executed every time the used collection changes. In this case this means we re-render the chart every time the collection changes.
  3. The chart is rendered using the C3.js library. We use the load method for updates after the chart has been created, which means the C3.js library will show a nice animation when the data for the chart changes.
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Init session
Session.set('chartRendered', false);

// Auto-update the chart depending on the HeartRate collection
Tracker.autorun(function() {
    if (Session.get('chartRendered')) {
        if (HeartRateSub.ready()) {
            // Limit to the last N records
            var heartrates = HeartRate.find({}, {
                sort: { date : -1 },
                limit: 30
            }).fetch();
            renderChart(heartrates);
        }
    }
});

// Auto chart-update should only run when the chart template has finished rendering
Template.heartrate.rendered = function() {
    Session.set('chartRendered', true);
};
Template.heartrate.destroyed = function() {
    Session.set('chartRendered', false);
};

// Charts
function renderChart(heartrates) {
    var dataHR = ['HR'];
    var dataDates = ['x'];
    heartrates.forEach(function(heartrate) {
        dataHR.push(heartrate.hr);
        dataDates.push(heartrate.date);
    }, this);
    // Render chart
    if (_.isUndefined(window.chart)) {
        // Create chart
        window.chart = c3.generate({
            bindto: '#heartratechart',
            data: {
                type: 'spline',
                x: 'x',
                columns: [
                    dataHR,
                    dataDates
                ]
            },
            axis: {
                x: {
                    type: 'timeseries',
                    tick: {
                        format: '%H:%M:%S'
                    }
                }
            }
        });
    } else {
        // Update chart
        window.chart.load({
            columns: [
                dataHR,
                dataDates
            ]
        });
    }
}

We define a default route which renders the hrlive template.

router.js
1
2
3
Router.route('/', function () {
    this.render('hrlive');
});

The index.html file contains the templates for the chart. It displays the chart inside a resizable panel.

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<head>
    <title>HRlive</title>
    <style type="text/css">
    body {
        margin: 10px;
    }
    </style>
</head>

<body></body>

<template name="hrlive">
    <div class="container">
        <div class="row">
            <div class="col-md-12">
                <div class="panel panel-info">
                    <div class="panel-heading">
                        <h3 class="panel-title">Heart Rate</h3>
                    </div>
                    <div class="panel-body">
                        {{> heartrate}}
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<template name="heartrate">
    <div id="heartratechart"></div>
</template>

The iOS app

Step 1 - Create the iOS app

Start Xcode and create a new Single View Application.

Step 2 - Create the storyboard

The storyboard for this app is very simple. A single view controller with one label centered in the view. The label will be used to display the last received value for the heart rate.

Step 3 - Define some bluetooth constants

Bluetooth uses a lot of predefined uuid’s for communication. You can find them all over here, and here.

AppConstants.swift
1
2
3
4
5
6
7
8
import Foundation

struct AppConstants {

    static let uuidHeartRate: String = "180D"
    static let uuidHeartRateMeasurement: String = "2A37"

}

Step 4 - Implement the view controller

I think the implementation is pretty straight forward:

  1. Setup the Bluetooth manager and create an operation queue for the HTTP requests which send heart rate values to the Meteor app.
  2. Start scanning for the heart rate monitor device as soon as the Bluetooth manager is ready (centralManagerDidUpdateState callback).
  3. Stop scanning and connect to the heart rate monitor device once it is found (didDiscoverPeripheral callback).
  4. Start scanning for services once the device is connected (didConnectPeripheral callback).
  5. Start scanning for characteristics once the services have been discovered (didDiscoverServices callback).
  6. Once the heart rate measurement characteristic is found (didDiscoverCharacteristicsForService callback), subscribe to notifications for this characteristic.
  7. Every time a heart rate measurement characteristic notification is received (didUpdateValueForCharacteristic callback), process the received data.
  8. The processHRCharacteristic function reads the array of values received from the heart rate monitor device, and grabs the heart rate value from it, which is then sent to the Meteor app
  9. The sendHR function creates a POST request, sending the value of the heart rate to the Meteor app in plain text format. Make sure to change the URL to your own server which will be running the Meteor app.
ViewController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import UIKit
import CoreBluetooth

class ViewController: UIViewController, CBCentralManagerDelegate, CBPeripheralDelegate {

    @IBOutlet var hrLabel: UILabel!

    var centralManager: CBCentralManager!
    var mio: CBPeripheral!
    var queue: NSOperationQueue!

    override func viewDidLoad() {
        super.viewDidLoad()
        centralManager = CBCentralManager(delegate: self, queue: nil)
        queue = NSOperationQueue()
        queue.maxConcurrentOperationCount = 1 // Make sure requests are handled in the same order
    }

    func scanForServices() {
        let services: [CBUUID] = [CBUUID(string: AppConstants.uuidHeartRate)]
        centralManager.scanForPeripheralsWithServices(services, options: nil)
    }

    func processHRCharacteristic(characteristic: CBCharacteristic) {
        let data = characteristic.value
        var values = [UInt8](count:data.length, repeatedValue:0)
        data.getBytes(&values, length:data.length)
        let hr = "\(values[1])"
        hrLabel.text = hr
        sendHR(hr)
    }

    func sendHR(hr: String) {
        if let url = NSURL(string: "http://hrlive.vanmil.org/addheartrate") {
            println("Sending HR \(hr)")
            let request = NSMutableURLRequest(URL: url, cachePolicy: NSURLRequestCachePolicy.UseProtocolCachePolicy, timeoutInterval: 10.0)
            request.HTTPMethod = "POST"
            request.HTTPBody = hr.dataUsingEncoding(NSUTF8StringEncoding)
            request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
            NSURLConnection.sendAsynchronousRequest(request, queue: queue, completionHandler: { (response, data, error) -> Void in
                if error != nil {
                    println("error=\(error)")
                    return
                }
                let responseString = NSString(data: data, encoding: NSUTF8StringEncoding)
                println(responseString as String!)
            })
        }
    }

    // MARK: CBCentralManagerDelegate

    func centralManager(central: CBCentralManager!, didConnectPeripheral peripheral: CBPeripheral!) {
        println("Connected")
        mio.discoverServices(nil)
    }

    func centralManager(central: CBCentralManager!, didDiscoverPeripheral peripheral: CBPeripheral!, advertisementData: [NSObject : AnyObject]!, RSSI: NSNumber!) {
        if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
            println("Found device \(localName)")
            centralManager.stopScan()
            mio = peripheral
            mio.delegate = self
            centralManager.connectPeripheral(mio, options: nil)
        }
    }

    func centralManagerDidUpdateState(central: CBCentralManager!) {
        if central.state == CBCentralManagerState.PoweredOff {
            println("Powered Off")
        } else if central.state == CBCentralManagerState.PoweredOn {
            println("Powered On")
            scanForServices()
        } else if central.state == CBCentralManagerState.Resetting {
            println("Resetting")
        } else if central.state == CBCentralManagerState.Unauthorized {
            println("Unauthorized")
        } else if central.state == CBCentralManagerState.Unknown {
            println("Unknown")
        } else if central.state == CBCentralManagerState.Unsupported {
            println("Unsupported")
        }
    }


    // MARK: CBPeripheralDelegate

    func peripheral(peripheral: CBPeripheral!, didDiscoverServices error: NSError!) {
        println("Discovered services:")
        for service in mio.services as! [CBService] {
            println("    \(service.UUID)")
            mio.discoverCharacteristics(nil, forService: service)
        }
    }

    func peripheral(peripheral: CBPeripheral!, didDiscoverCharacteristicsForService service: CBService!, error: NSError!) {
        println("Discovered characteristics for service \(service.UUID):")
        for characteristic in service.characteristics as! [CBCharacteristic] {
            println("    \(characteristic.UUID)")

            // Request heart rate notifications
            if service.UUID == CBUUID(string: AppConstants.uuidHeartRate) {
                if characteristic.UUID == CBUUID(string: AppConstants.uuidHeartRateMeasurement) {
                    mio?.setNotifyValue(true, forCharacteristic: characteristic)
                    println("Enabled notifications for characteristic \(characteristic.UUID):")
                }
            }

        }
    }

    func peripheral(peripheral: CBPeripheral!, didUpdateValueForCharacteristic characteristic: CBCharacteristic!, error: NSError!) {
        if characteristic.UUID == CBUUID(string: AppConstants.uuidHeartRateMeasurement) {
            self.processHRCharacteristic(characteristic)
        }
    }

}

Run the apps!

First start the Meteor app and open it in the browser. You should see an empty heart rate chart.

Test the Meteor app by manually sending a POST request with a heart rate value to it. If you’re on a Mac you should try Paw, it’s a really nice HTTP client.

POST request sending a heart rate value of 90
1
2
3
4
5
6
7
POST /addheartrate HTTP/1.1
Host: hrlive.vanmil.org
Connection: close
User-Agent: Paw/2.2.2 (Macintosh; OS X/10.10.3) GCDHTTPRequest
Content-Length: 2

90

If the Meteor app is working, you should see the chart update. Try sending some more requests with different values and watch what happens to the chart.

Next, put on your heart rate monitor and start the monitoring. Then run the iPhone app and see if the heart rate appears on the screen of your iPhone. If you run the app from Xcode you can check the console for logging messages to see what’s happening.

If everything is working correctly, you should now be able to see your own live heart rate in the browser :)

Comments

Real Time Web Analytics