Creating a realtime attendance app with React Native and BLE
To follow this tutorial you will need an IoT device with a network and BLE module, a mobile device to test the app, React Native set up on your machine, and a Pusher account.
In this tutorial, we’ll be using React Native to create the attendance app on Android, Bleno for implementing Bluetooth Low Energy, and Pusher for notifying connected devices on realtime.
Prerequisites
You’ll need the following in order to follow along with this tutorial:
- IoT Device - you’ll need an IoT device which has a network and BLE (Bluetooth Low Energy) module. The device should be connected to the internet in order for Pusher to work. In this tutorial, I’ll be using a Raspberry Pi 3 because of its Node.js support.
- Mobile Device - you’ll be testing the app on a real device because of the need for bluetooth. But if your machine has bluetooth capabilities and your emulator has access to it, then you can use it as well.
- React Native development environment - as this is a React Native tutorial, I’ll be assuming that you have already setup your machine for React Native development. If not, you can follow the installation instructions on the official docs. Be sure to select the Building Projects with Native Code tab because the Quickstart one uses Expo. It is great for local development but if you need certain device features such as Bluetooth, you need to start with the native method so you can immediately run the app on a device.
- A Pusher app - sign up for a free account if you don’t already have one. Then create a new Pusher app after that. You can do so from the Pusher dashboard.
What You’ll Create
The app will have two components: client and server. The client is the Android app that will be created using React Native, and the server is the BLE server which is responsible for processing requests from the Android app.
The Android app is going to allow the user to scan for nearby peripherals. Once the scan is complete, it will list out the peripherals that it found:
The user can then connect to the peripheral. Once connected, the peripheral will send the list of attendees (if any) via Pusher:
The user can then press on the enter button. This will open a prompt which will ask for the user’s full name:
Once the user has entered their name, it will be sent to the peripheral, and the peripheral will send out the name of the user to all users subscribed to a specific channel via Pusher.
You can find the full source code of this app on its Github repo.
Creating the App
Now you’re ready to create the app. You’re going to code the server component first and then the client app.
Server Component
Start by installing the dependencies of Bleno:
sudo apt-get install bluetooth bluez libbluetooth-dev libudev-dev
If your IoT device is not using a linux-based operating system, you can check the official documentation for instructions on how to install the dependencies.
Once you’ve installed the dependencies for Bleno, you can now create a new project:
npm init
Next, install the dependencies of the server:
npm install bleno pusher dateformat dotenv --save
Create an attendance.js
file and import the packages you’ve just installed:
var bleno = require('bleno'); // for implementing BLE peripheral
var Pusher = require('pusher'); // for pushing real-time updates to clients
var dateFormat = require('dateformat'); // for formatting dates
require('dotenv').config(); // for loading .env file
Initialize Pusher:
var pusher = new Pusher({
appId: process.env.APP_ID,
key: process.env.APP_KEY,
secret: process.env.APP_SECRET,
cluster: process.env.APP_CLUSTER,
encrypted: true
});
Note that for this to work you have to create a .env
inside the same folder where you have the attendance.js
file and then add your pusher config:
APP_ID="YOUR PUSHER APP ID"
APP_KEY="YOUR PUSHER APP KEY"
APP_SECRET="YOUR PUSHER APP SECRET"
APP_CLUSTER="YOUR PUSHER APP CLUSTER"
Add some default attendees to ensure that Pusher is properly sending the data when someone connects to the peripheral:
var time_format = 'h:MM TT';
var attendees = [
{
id: 1,
full_name: 'milfa',
time_entered: dateFormat(new Date(1505901033110), time_format)
},
{
id: 2,
full_name: 'red',
time_entered: dateFormat(new Date(1505901733110), time_format)
},
{
id: 3,
full_name: 'silver',
time_entered: dateFormat(new Date(1505908733110), time_format)
}
];
Next, listen for when the state of the BLE peripheral changes. It has to be poweredOn
before it can start advertising its services:
const BASE_UUID = '-5659-402b-aeb3-d2f7dcd1b999';
const PERIPHERAL_ID = '0000';
const PRIMARY_SERVICE_ID = '0100';
var primary_service_uuid = PERIPHERAL_ID + PRIMARY_SERVICE_ID + BASE_UUID;
var ps_characteristic_uuid = PERIPHERAL_ID + '0300' + BASE_UUID;
var settings = {
service_id: primary_service_uuid,
characteristic_id: ps_characteristic_uuid
};
bleno.on('stateChange', function(state){
if(state === 'poweredOn'){
bleno.startAdvertising('AttendanceApp', [settings.service_id]);
}else{
bleno.stopAdvertising();
}
});
For those of you who don’t have experience in constructing UUIDs, the quickest way to do it is by using a tool like the Online UUID Generator. That will generate a new UUID every time you reload the page. That is where I got the value for the BASE_UUID
. The last 24 characters (excluding the hyphens) serves as the BASE_UUID
, and the first 8 characters is the combination of the peripheral ID and the service or characteristic ID. Notice that I’ve used 0100
for the service ID, and 0300
for the characteristic ID to make them unique.
For more information regarding how to generate UUIDs, and BLE advertising in general, I recommend you to read A BLE Advertising Primer.
Once the service advertisement is started, create a new service that will respond to write requests from the client app:
bleno.on('advertisingStart', function(error){
if(error){
console.log('something went wrong while trying to start advertisement of services');
}else{
console.log('started..');
bleno.setServices([
new bleno.PrimaryService({ // create a service
uuid : settings.service_id,
characteristics : [
new bleno.Characteristic({ // add a characteristic to the service
value : null,
uuid : settings.characteristic_id,
properties : ['write'],
onWriteRequest : function(data, offset, withoutResponse, callback){
// next: add code for processing write request
}
})
]
})
]);
}
});
Once a write request is received, you first need to convert the data to a string. The data isn’t being sent in one go from the client. This is due to the limitation that only 20 bytes of data can be written to a peripheral at any given time. This means that if the data that you’re sending is more than 20 bytes, the client converts it into a byte array and sends the individual chunks to the peripheral one by one until it has sent the whole data. Bleno abstracts this part away, so you don’t have to deal with each individual chunk.
var attendee = JSON.parse(data.toString());
attendee.time_entered = dateFormat(new Date(), time_format);
attendees.push(attendee);
console.log(attendees);
pusher.trigger('attendance-channel', 'attendance-event', attendee); // send the new attendee's data to all clients
callback(this.RESULT_SUCCESS); // tell the client that the request has succeeded
Once the peripheral accepts a new connection from a client, send the details of all the attendees. This way, the new person knows the people who are currently in the room:
bleno.on('accept', function(clientAddress){
console.log('client address: ', clientAddress);
var data = {
is_attendees: true,
attendees: attendees
};
pusher.trigger('attendance-channel', 'attendance-event', data);
});
Client Component
Now you’re ready to create the Android app. First, bootstrap a new React Native project:
react-native init BLEPusherAttendance
Next, install the dependencies:
npm install --save react-native-ble-manager random-id convert-string bytes-counter react-native-spinkit@latest react-native-prompt pusher-js
Once everything is installed, you need to link the resources to the app:
react-native link
This step is needed for the BLE Manager and Spinkit packages to work correctly.
The app requires some permissions in order for it to work. Add the following on the app/src/main/AndroidManifest.xml
file, right after the default permissions (INTERNET
and SYSTEM_ALERT_WINDOW
):
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
For a detailed information on what each permission does, check out the Android documentation.
Next, include the built-in React Native components that you will be using:
import React, { Component } from 'react';
import {
AppRegistry,
Platform,
PermissionsAndroid, // for checking if certain android permissions are enabled
StyleSheet,
Text,
View,
NativeEventEmitter, // for emitting events for the BLE manager
NativeModules, // for getting an instance of the BLE manager module
Button,
ToastAndroid, // for showing notification if there's a new attendee
FlatList, // for creating lists
Alert
} from 'react-native';
Also import the packages you’ve installed earlier:
import BleManager from 'react-native-ble-manager'; // for talking to BLE peripherals
const BleManagerModule = NativeModules.BleManager;
const bleManagerEmitter = new NativeEventEmitter(BleManagerModule); // create an event emitter for the BLE Manager module
import { stringToBytes } from 'convert-string'; // for converting string to byte array
import RandomId from 'random-id'; // for generating random user ID
import bytesCounter from 'bytes-counter'; // for getting the number of bytes in a string
import Pusher from 'pusher-js/react-native'; // for using Pusher inside React Native
import Spinner from 'react-native-spinkit'; // for showing a spinner when loading something
import Prompt from 'react-native-prompt'; // for showing an input prompt
// next: create main component
On the main component’s constructor, initialize the state values that you’ll be using throughout the app:
export default class pusherBLEAttendance extends Component {
constructor() {
super();
this.state = {
is_scanning: false, // whether the app is currently scanning for peripherals or not
peripherals: null, // the peripherals detected
connected_peripheral: null, // the currently connected peripheral
user_id: null, // the ID of the current user
attendees: null, // the attendees currently synced with the app
promptVisible: false, // whether the prompt for the user's name is visible or not
has_attended: false // whether the current user has already attended
}
this.peripherals = []; // temporary storage for the detected peripherals
this.startScan = this.startScan.bind(this); // function for scanning for peripherals
this.openBox = this.openBox.bind(this); // function for opening the prompt box
}
// next: add code componentWillMount()
}
Before the component is mounted, check if bluetooth is enabled and alert the user that they need to enable bluetooth on their device if not. After that, you can initialize the BLE module. Note that it’s not required to initialize the module once you’re sure that bluetooth is enabled. This is because bluetooth is only used once the user scans for peripherals.
componentWillMount() {
BleManager.enableBluetooth()
.then(() => {
console.log('Bluetooth is already enabled');
})
.catch((error) => {
Alert.alert('You need to enable bluetooth to use this app.');
});
// initialize the BLE module
BleManager.start({showAlert: false})
.then(() => {
console.log('Module initialized');
});
// next: add code for checking coarse location
}
For Android devices using API version 23 and above (Android 6.0 and above), you need to check whether the COARSE_LOCATION
permission is enabled, and alert the user if it’s not. COARSE_LOCATION
is used to access the user’s approximate location. This is required by the BLE Manager package.
if(Platform.OS === 'android' && Platform.Version >= 23){
PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION).then((result) => {
if(!result){
PermissionsAndroid.requestPermission(PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION).then((result) => {
if(!result){
Alert.alert('You need to give access to coarse location to use this app.');
}
});
}
});
}
Once the component is mounted, you can now listen for when a peripheral is discovered. Note that this event only gets triggered while the peripheral scan is ongoing. Later on, you’ll see how the scan can be started. Also note that the callback function can be triggered multiple times for the same peripheral. That’s why you have to check if it’s already stored in the array before pushing.
componentDidMount() {
bleManagerEmitter.addListener('BleManagerDiscoverPeripheral', (peripheral) => {
var peripherals = this.peripherals; // get the peripherals
// check if the peripheral already exists
var el = peripherals.filter((el) => {
return el.id === peripheral.id;
});
if(!el.length){
peripherals.push({
id: peripheral.id, // mac address of the peripheral
name: peripheral.name // descriptive name given to the peripheral
});
this.peripherals = peripherals; // update the array of peripherals
}
});
// next: add code for listening for when the peripheral scan has stopped
}
Next, listen for when the scan has stopped. This is where you update the state with the peripherals that were found:
bleManagerEmitter.addListener(
'BleManagerStopScan',
() => {
console.log('scan stopped');
if(this.peripherals.length == 0){
Alert.alert('Nothing found', "Sorry, no peripherals were found");
}
this.setState({
is_scanning: false,
peripherals: this.peripherals
});
}
);
// next: add code for binding to Pusher events
Next, initialize the Pusher client and subscribe to the same channel that you used in the server component earlier. Once the attendance-event
is triggered, it can either be the server has sent the array of attendees (sent to the one who just joined) or a single attendee (sent to all the other people in the room).
var pusher = new Pusher('YOUR PUSHER APP KEY', {
cluster: 'YOUR PUSHER APP CLUSTER',
encrypted: true
});
var channel = pusher.subscribe('attendance-channel');
channel.bind('attendance-event', (data) => {
if(data.is_attendees){
this.setState({
attendees: data.attendees
});
}else{
ToastAndroid.show(`${data.full_name} just entered the room!`, ToastAndroid.LONG);
this.setState({
attendees: [...this.state.attendees, data]
});
}
});
The startScan()
function is executed when the user presses on the Scan button. This uses the BLE manager’s scan
method. It accepts an array of the service UUIDs as the first argument. Here, we didn’t include it. Even though, more often than not, you already know which service UUIDs your app should connect to. There’s really no particular reason for this, aside from demonstrating that the app can actually detect other peripherals and not just the one you created earlier. The second argument is the number of seconds in which to scan for peripherals.
startScan() {
this.peripherals = [];
this.setState({
is_scanning: true
});
BleManager.scan([], 2)
.then(() => {
console.log('scan started');
});
}
Once the scanning has stopped, the user will have the option to connect to any of the peripherals that were detected. Each peripheral has a corresponding Connect button which the user can press. This will, in turn, execute the connect()
function which attempts to connect to the peripheral. Once connected, you have to retrieve the services from the peripheral so that the app becomes aware of the services that are available. Even though we already know the service UUID, you can’t really do anything to it unless the app is aware of it.
connect(peripheral_id) {
BleManager.connect(peripheral_id)
.then(() => {
this.setState({
connected_peripheral: peripheral_id
});
Alert.alert('Connected!', 'You are now connected to the peripheral.');
// retrieve the services advertised by this peripheral
BleManager.retrieveServices(peripheral_id)
.then((peripheralInfo) => {
console.log('Peripheral info:', peripheralInfo);
}
);
})
.catch((error) => {
Alert.alert("Err..", 'Something went wrong while trying to connect.');
});
}
Once the user has connected to the peripheral, the UI is updated to show an Attend button and a list of attendees. When the user presses on the Attend button, a prompt shows up and lets the user enter their full name. Once entered, the following function is executed:
attend(value) {
let user_id = RandomId(15);
this.setState({
user_id: user_id
});
let me = {
id: user_id,
full_name: value
};
let str = JSON.stringify(me); // convert the object to a string
let bytes = bytesCounter.count(str); // count the number of bytes
let data = stringToBytes(str); // convert the string to a byte array
// construct the UUIDs the same way it was constructed in the server component earlier
const BASE_UUID = '-5659-402b-aeb3-d2f7dcd1b999';
const PERIPHERAL_ID = '0000';
const PRIMARY_SERVICE_ID = '0100';
let primary_service_uuid = PERIPHERAL_ID + PRIMARY_SERVICE_ID + BASE_UUID; // the service UUID
let ps_characteristic_uuid = PERIPHERAL_ID + '0300' + BASE_UUID; // the characteristic ID to write on
// write the attendees info to the characteristic
BleManager.write(this.state.connected_peripheral, primary_service_uuid, ps_characteristic_uuid, data, bytes)
.then(() => {
this.setState({
has_attended: true
});
// disconnect to the peripheral
BleManager.disconnect(this.state.connected_peripheral)
.then(() => {
Alert.alert('Attended', 'You have successfully attended the event, please disable bluetooth.');
})
.catch((error) => {
Alert.alert('Error disconnecting', "You have successfully attended the event but there's a problem disconnecting to the peripheral, please disable bluetooth to force disconnection.");
});
})
.catch((error) => {
Alert.alert('Error attending', "Something went wrong while trying to attend. Please try again.");
});
}
From the code above, you can see that this creates an object containing the user’s details. The object is converted to a string and lastly to a byte array. This is done so that the data can be sent in chunks. As mentioned earlier in the server component, only 20 bytes of data can be written to a BLE peripheral at any given time. Byte arrays can be sent in chunks so it’s the perfect data type for dealing with this limit. We’re also getting the number of bytes so the write()
function knows how much data it needs to send.
Once a response is returned, immediately disconnect from the peripheral. This is because the peripheral can only cater to a limited number of devices at the same time.
The openBox()
function is responsible for setting the visibility of the prompt for entering the user’s full name:
openBox() {
this.setState({
promptVisible: true
});
}
Here’s the code for rendering each individual list item. This caters to both peripheral list and attendees list.
renderItem({item}) {
if(item.full_name){
return (
<View style={styles.list_item} key={item.id}>
<Text style={styles.list_item_text}>{item.full_name}</Text>
<Text style={styles.list_item_text}>{item.time_entered}</Text>
</View>
);
}
return (
<View style={styles.list_item} key={item.id}>
<Text style={styles.list_item_text}>{item.name}</Text>
<Button
title="Connect"
color="#1491ee"
style={styles.list_item_button}
onPress={this.connect.bind(this, item.id)} />
</View>
);
}
Here’s the render()
function. Note that it conditionally hides and shows different elements based on which step of the attendance process the user is currently at. For example, if the user has connected to a peripheral, the scan button and list of peripherals are no longer shown.
render() {
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.app_title}>
<Text style={styles.header_text}>BLE-Pusher Attendance</Text>
</View>
<View style={styles.header_button_container}>
{
!this.state.connected_peripheral &&
<Button
title="Scan"
color="#1491ee"
onPress={this.startScan} />
}
</View>
</View>
<View style={styles.body}>
<Spinner
size={50}
type={"WanderingCubes"}
color={"#6097FC"}
isVisible={this.state.is_scanning}
style={styles.spinner}
/>
{
!this.state.connected_peripheral &&
<FlatList
data={this.state.peripherals}
renderItem={this.renderItem.bind(this)}
/>
}
{
this.state.attendees &&
<View style={styles.attendees_container}>
<Prompt
title="Enter your full name"
placeholder="e.g. Son Goku"
visible={this.state.promptVisible}
onCancel={() => {
this.setState({
promptVisible: false
});
}
}
onSubmit={ (value) => {
this.setState({
promptVisible: false
});
this.attend.call(this, value);
}
}/>
{
!this.state.has_attended &&
<Button
title="Enter"
color="#1491ee"
onPress={this.openBox} />
}
<FlatList
data={this.state.attendees}
renderItem={this.renderItem.bind(this)}
/>
</View>
}
</View>
</View>
);
}
Add the styles:
const styles = StyleSheet.create({
container: {
flex: 1,
alignSelf: 'stretch',
backgroundColor: '#F5FCFF',
},
header: {
flex: 1,
backgroundColor: '#3B3738',
flexDirection: 'row'
},
app_title: {
flex: 7,
padding: 10
},
header_button_container: {
flex: 2,
justifyContent: 'center',
paddingRight: 5
},
header_text: {
fontSize: 20,
color: '#FFF',
fontWeight: 'bold'
},
body: {
flex: 19
},
list_item: {
paddingLeft: 10,
paddingRight: 10,
paddingTop: 15,
paddingBottom: 15,
marginBottom: 5,
borderBottomWidth: 1,
borderBottomColor: '#ccc',
flex: 1,
flexDirection: 'row'
},
list_item_text: {
flex: 8,
color: '#575757',
fontSize: 18
},
list_item_button: {
flex: 2
},
spinner: {
alignSelf: 'center',
marginTop: 30
},
attendees_container: {
flex: 1
}
});
Lastly, register the component as the main one so that it gets rendered:
AppRegistry.registerComponent('pusherBLEAttendance', () => pusherBLEAttendance);
Running the App
Login to your IoT device and start the server:
node attendance.js
After that, run the app on your computer:
react-native run-android
Don’t forget to update the dev settings in the app to connect to your computer’s internal IP address. You can trigger the app settings to open using the following command. Make sure your mobile device is connected to your computer before doing so.
adb shell input keyevent 82
Select dev settings and find the menu for the debug server. Enter your computer’s internal IP address, as well as the port in which the React Native server is running. Here’s an example:
192.168.254.104:8081
Once the app is running, try going through the whole attendance process and see if it works.
Conclusion
In this tutorial, you’ve learned how to use Pusher’s realtime capabilities with an IoT device to create an attendance app. There are many more possibilities for this technology. For example, you can create a game which allows users in the same room to compete with each other.
20 April 2018
by Wern Ancheta