Building a realtime London tube arrival tracking app for Fitbit

In this tutorial, we will see how to use the Ably Realtime client library to build a realtime London Tube schedule clockface app for Fitbit. Ably enables realtime data sharing using Pub/Sub messaging architecture via a concept called channels.

For the purpose of this tutorial, we’ll not discuss storage of messages or other server-side mechanisms.

Getting started with Fitbit SDK is a guide to get started for developing apps and clockface apps for Fitbit.

Step 1 – Create your Ably app and API key

To follow this tutorial, you will need an Ably account. Sign up for a free account if you don’t already have one.

Access to the Ably global messaging platform requires an API key for authentication. API keys exist within the context of an Ably application and each application can have multiple API keys so that you can assign different capabilities and manage access to channels and queues.

You can either create a new application for this tutorial, or use an existing one.

To create a new application and generate an API key:

  1. Log in to your Ably account dashboard
  2. Click the “Create New App” button
  3. Give it a name and click “Create app”
  4. Copy your private API key and store it somewhere. You will need it for this tutorial.

To use an existing application and API key:

  1. Select an application from “Your apps” in the dashboard
  2. In the API keys tab, choose an API key to use for this tutorial. The default “Root” API key has full access to capabilities and channels.
  3. Copy the Root API key and store it somewhere. You will need it for this tutorial.

    Copy API Key screenshot

Step 2 – Create a clock face

Prerequisites:

  • Make sure Node and NPM are installed on your system.
    • You will not need to install the Fitbit SDK separately as we’ll be using NPX (that comes bundled with NPM) for doing this.
  • Fitbit user account. Sign up here.
  • A Fitbit OS device, or the Fitbit OS Simulator for Windows or macOS.
  • The latest Fitbit mobile application for Android, iOS or Windows Phone, paired with your Fitbit device.
  • A computer with access to Fitbit Studio.
  • A Wifi connection to provide the Fitbit device a connection to the internet.

We’ll be using the Fitbit command line interface for creating the clockface. You can find the guide here.

We’ll start by creating a new clockface. To do this, we’ll be using the create-fitbit-app command that allows you to generate the minimal scaffolding required for either an app or clockface.

We’ll call the app train-app. Let’s create the app from the terminal by running:

npx create-fitbit-app train-app

It will prompt you with a few questions. In the first question What type of application should be created? select the option clockface . For the remaining questions you can move ahead with default.
The new clockface app is ready, we need to open the Fitbit OS Simulator and then run it locally as follows:

cd train-app
npx fitbit
bi
exit

After running the clockface you should see a blank white screen on the simulator and the Companion Settings window is empty currently with the title App Settings, to verify it has run successfully.

Let’s install the package for ably, by running the following command from the terminal:

npm install --save ably

See this step in Github

Step 3 – Update the Settings

Using the Companion Settings we take input from the user in a Fibtit clockface/app.
So since we are making a clockface for the London Tube schedule, we ask the user for three inputs; the Line, Station and Towards.
Each input is an autocomplete, hence the user can just type and select the option they desire.

Each set of options is dependent on what the user selected in the previous input, using that the companion sets (which we will see the next few steps) the options in the settingsStorage which is then read by the Settings interface and displayed to the user.
Open the file index.jsx in the settings folder and empty it. Now add the following code:

function train(props) {
  const lines = [
    { name: "metropolitan", value: "metropolitan" },
    { name: "central", value: "central" },
    { name: "waterloo-city", value: "waterloo-city" },
    { name: "jubilee", value: "jubilee" },
    { name: "victoria", value: "victoria" },
    { name: "bakerloo", value: "bakerloo" },
    { name: "hammersmith-city", value: "hammersmith-city" },
    { name: "circle", value: "circle" },
    { name: "district", value: "district" },
    { name: "piccadilly", value: "piccadilly" },
    { name: "northern", value: "northern" }
  ]; // Possible Lines
  // Through the props we can access the settingsStorage and get value for any item which
  // was set in the companion with data
  return (
    <Page>
      <Section
        title={
          <Text bold align="center">
            Station and Line Settings
          </Text>
        }
      >
        <Select label={`Line`} settingsKey="line" options={lines} />
        {/* Getting the possible stations depending on the line selected by the user,
        through the settingsStorage set in the companion */}
        {props.settingsStorage.getItem("stationspossible") && (
          <TextInput
            title="Select Station Name"
            label="Station Name"
            placeholder="Search station"
            settingsKey="origin" // Used to read the selected value in companion
            action="Add Item"
            onAutocomplete={value => {
              const autoValues = JSON.parse(
                props.settingsStorage.getItem("stationspossible")
              ).values;
              return autoValues.filter(option =>
                option.name.toLowerCase().startsWith(value.toLowerCase())
              );
            }}
          />
        )}
        {/* Getting possible towards depending on the line & station selected by the user,
        through the settingsStorage set in the companion */}
        {props.settingsStorage.getItem("via") && (
          <TextInput
            title="Select Station Name"
            label="Towards"
            placeholder="Search station"
            settingsKey="towards" // Used to read the selected value in companion
            action="Add Item"
            onAutocomplete={value => {
              const autoValues = JSON.parse(
                props.settingsStorage.getItem("via")
              ).values;
              return autoValues.filter(option =>
                option.name.toLowerCase().startsWith(value.toLowerCase())
              );
            }}
          />
        )}
      </Section>
    </Page>
  );
}

registerSettingsPage(train);

You can refer to the Settings guide here.

See this step in Github

Step 4 – Adding static trains data

We’ll get started with a few import statements, add the following to your index.js file which is inside the companion folder:

import Ably from "ably/browser/static/ably-commonjs.js";
// Import the messaging module
import * as messaging from "messaging";
import { settingsStorage } from "settings";
import { geolocation } from "geolocation";

let navigator = {userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36"}

Then lets add the static data below it from the gist.
It contains two arrays, one is used for StationsIDMapping (line & station combination – stationid), the other is used for towardsMapping (line & origin combination to get towards options).

See this step in Github

Step 5 – Get location & find walking time

Now we will get the location and update the walking time to the user selected station.
So for that we will need to use the Google API to get the walking time.
Follow the steps here, to get the API key.
You can then subscribe to the TFL Tube Schedule via the Ably Hub

The locationSuccess gets triggered every time the location changes and then we get the new latitude & longitude.
We can then send that location as origin & the station as destination to the Google API. We’ll get the walking time as a response from the Google API, from the user’s current location to their selected station.
Add the following code in your index.js file which is inside the companion folder:

const ablyAPIKey = '<YOUR-API-KEY>';
const realtime = new Ably.Realtime(ablyAPIKey);
const apiKey = '<YOUR-GOOGLE-API-KEY>';
let watchID = geolocation.watchPosition(locationSuccess, locationError);
let latitude, longitude, timeToArrival, walkingTime;

function locationSuccess(position) {
  latitude = position.coords.latitude;
  longitude = position.coords.longitude;
  if (JSON.parse(settingsStorage.getItem("line")) === null) return;
  // Reading the origin station from Companion Settings
  const station = JSON.parse(settingsStorage.getItem("origin")).name;
  if (station === "") return;

  // Replace the space with plus so that it can be passed to Google API
  let dest = station.split(" ").join("+");
  let googleUrl = `https://maps.googleapis.com/maps/api/distancematrix/json?
  origins=${latitude},${longitude}&destinations=${dest}&mode=walking&key=${apiKey}`;
  fetch(googleUrl, {
    method: "GET"
  })
    .then(function(res) {
      return res.json();
    })
    .then(function(data) {
      let myData = data;
      // Convert the time to mins
      walkingTime = Math.floor(myData.rows[0].elements[0].duration.value / 60);
    })
    .catch(err => console.log("[FETCH]: " + err));
}

function locationError(error) {
  console.log("Error: " + error.code, "Message: " + error.message);
}

See this step in Github

Step 6 – Subscribe to Ably TFL Hub & send the message to app

Now using the pub/sub mechanism of Ably, we subscribe to the Ably TFL Hub with the selected line & stationId (station & towards) .
The new data to the hub is published automatically for the given input and we just subscribe to the channel to receive that.
We get the walkingTime using the Google API to which we send the current location & destination(station).
Then using the data that the Ably TFL Hub provides us we get the list of upcoming trains and calculate then the arrival time of the next train.
Now using the messaging module of Fitbit we send the data from companion(here) to the app .
The data consists of three times – walking time, time of next train & leave time(that the person uses to decide when to leave the current location to catch the train) .
Add the following code in your index.js file which is inside the companion folder:

function sendMessage() {
 // Get the line, origin station & towards from Companion settings through settingsStorage
   const line = JSON.parse(settingsStorage.getItem("line")).values[0].name;
   const station = JSON.parse(settingsStorage.getItem("origin")).name;
   const towards = JSON.parse(settingsStorage.getItem("towards")).name;

   if (station === "" || towards === "") return
     // Get stationId for the selected line and origin station,
     // using the StationsIDMapping static data
     const stationId = StationsIDMapping.filter(function(train) {
       return train.line === line && train.station === station;
     });

 // We use the Ably TFL Hub, to receive update for the line & stationid (station & towards)
 let channelName = `[product:ably-tfl/tube]tube:${line}:${stationId[0].stationid}:arrivals`;
 let trainChannel = realtime.channels.get(channelName);
 // Replace the space with plus so that it can be passed to Google API
 let dest = station.split(" ").join("+");
 if (latitude !== undefined && longitude !== undefined) {
// Sending current location & station as destination to Google API to get walking time
   let googleUrl = `https://maps.googleapis.com/maps/api/distancematrix/json?
   origins=${latitude},${longitude}&destinations=${dest}&mode=walking&key=${apiKey}`;
   fetch(googleUrl, {
     method: "GET"
   })
     .then(function(res) {
       return res.json();
     })
     .then(function(data) {
       let myData = data;
        // Convert the time to mins
       walkingTime = Math.floor(
         myData.rows[0].elements[0].duration.value / 60
       );
     })
     .catch(err => console.log("[FETCH]: " + err));
 }

 // We subscribe to the messages coming on the Ably TFL Hub channel
 trainChannel.subscribe(msg => {
   /* station update in msg */
   // Get upcoming trains for our user input of line, station and towards
   const trains = msg.data.filter(train => {
     return (
       line === train.LineId &&
       station === train.StationName &&
       towards === train.Towards &&
       Math.floor((new Date(train.ExpectedArrival) - new Date())/1000/60) >= walkingTime
     );
   });
   // Get the first upcoming train time
   let trainTime = new Date(trains[0].ExpectedArrival);
   let currentTime = new Date();
   let diff = trainTime - currentTime;
   timeToArrival = Math.floor(diff / 1000 / 60);
   // Now using the messaging module we send the data to the app
   // Walk time, train time
   if (
     messaging.peerSocket.readyState === messaging.peerSocket.OPEN &&
     walkingTime !== undefined
   ) {
     messaging.peerSocket.send({
       tTA: timeToArrival,
       wT: walkingTime,
       lT: (timeToArrival - walkingTime) > 30? "FAR" : (timeToArrival - walkingTime)
     });
   }
 });
}

Channels are the medium through which messages are distributed; clients attach to channels to subscribe to messages, and every message published to that channel is broadcast by Ably to all its subscribers.

See this step in Github

Step 7 – Updating the settings

Now we need to update the settings options so that their respective dropdowns reflect the value selected in the setting above. We also need to call the sendMessage function on updating the settings or on starting up with all settings values selected already. Lastly we need to listen for errors to the messaging module since that helps in communication between the companion and app.
Add the following code in your index.js file which is inside the companion folder:

if (JSON.parse(settingsStorage.getItem("line")) != null) {
 sendMessage();
}

// OnChange of the settings
settingsStorage.onchange = function(evt) {
  // evt holds the setting which triggered the onchange
  if (evt.key == "line") {
    // We get the old and new value of the setting
    const lineOld =
      evt.oldValue == null ? "" : JSON.parse(evt.oldValue).values[0].name;
    const lineNew = JSON.parse(evt.newValue).values[0].name;
    // If same we just return
    if (lineOld == lineNew) return;
    // If not same we reset the next options
    settingsStorage.setItem("origin", '{"name":""}');
    settingsStorage.setItem("stationspossible", "");
    settingsStorage.setItem("towards", '{"name":""}');
    settingsStorage.setItem("via", "");
    // Get the Stations List for the new line
    const stationsList = StationsIDMapping.filter(function(train) {
      return train.line === lineNew;
    });
    // Get just the station name
    const stationsOptions = stationsList.map(train => {
      const { stationid, line, ...rest } = train;
      return { name: rest.station };
    });
    // Update the stationspossible with the new value
    settingsStorage.setItem(
      "stationspossible",
      `{"values":${JSON.stringify(stationsOptions)}} `
    );
  } else if (evt.key == "origin") {
    // We get the old and new value of the setting
    const originOld = JSON.parse(evt.oldValue).name;
    const originNew = JSON.parse(evt.newValue).name;
    const lineNew = JSON.parse(settingsStorage.getItem("line")).values[0].name;
    // If same we just return
    if (originNew == originOld) return;
    // If not same we reset the next options
    settingsStorage.setItem("towards", '{"name":""}');
    settingsStorage.setItem("via", "");
    // Get the viaOptions for the new origin
    const viaOptions = towardsMapping.filter(function(mapping) {
      return mapping.line === lineNew && mapping.origin === originNew;
    });
    // Get just the towards options
    const towardsOptions = viaOptions[0].towards.map(mapping => {
      return { name: mapping };
    });
    // Update the via with the new value
    settingsStorage.setItem(
      "via",
      `{"values":${JSON.stringify(towardsOptions)}} `
    );
  } else if (evt.key == "towards") {
    // We get the old and new value of the setting
    const towardsOld = JSON.parse(evt.oldValue).name;
    const towardsNew = JSON.parse(evt.newValue).name;
    // If same we just return
    if (towardsOld == towardsNew) return;
  }
  sendMessage();
};
// Listen for the onerror event
messaging.peerSocket.onerror = function(err) {
  // Handle any errors
  console.log("Connection error: " + err.code + " - " + err.message);
};

See this step in Github

So this completes the work of the companion, now we need to move to the app (which is what is displayed on the watch).

Step 8 – Update the gui files of app

Now we update gui files of the app to get the look of the clockface and see actual values on the watch.

Empty the file index.gui which is inside the resources folder and add the following code:

<svg viewport-fill="black">
<text id="myClock" x="50%" y="40%" font-size="32" font-family="System-Regular"
  text-anchor="middle" text-length="20" fill="fb-cyan">00:00:00</text>
  <text id="trainlabel" x="3%" y="10%" fill="fb-indigo" font-family="Seville-Bold-Italic"
  text-length="30"></text>
  <text id="teta" x="6%" y="20%" fill="fb-lavender" text-length="30"></text>
  <text id="walkinglabel" x="54%" y="89%" fill="fb-indigo" font-family="Seville-Bold-Italic"
  text-length="30"></text>
  <text id="weta" x="66%" y="98%" fill="fb-lavender" text-length="30"></text>
  <use id="spinner" href="#spinner" x="50%-25" y= "50%-25"
  width="50" height="50" fill="fb-lavender" />
  <arc id="cir" x="50" y="50" width="200" height="190"
  fill="fb-green" arc-width="6" start-angle="0" sweep-angle="360">
    <text id="leavelabel" x="33%" y="55%" fill="fb-indigo"
    font-family="Seville-Bold-Italic" text-length="30"></text>
    <text id="eta" x="38%" y="65%" fill="fb-green"
    font-family="Fabrikat-Bold" text-length="30"></text>
  </arc>
</svg>

Update the file widgets.gui which is inside the resources folder with the following code:

<svg>
  <defs>
    <link rel="import" href="/mnt/sysassets/widgets_common.gui" />
    <link rel="import" href="/mnt/sysassets/widgets/spinner_widget.gui" />
  </defs>
</svg>

Delete the file styles.css which is inside the resources folder. We have added the styles in the gui files and we are also updating them in the index.js of the app depending on the values.

See this step in Github

Step 9 – Updating the text on the watch with dynamic values

Now we will get the data that we need using the messaging module to the app from the companion. Using those values we decide the color of the text eg: Red when time is less. We also set the time to be displayed on the watch.
Empty the file index.js which is inside the app folder and add the following code:

import document from "document";
// Import the messaging module
import * as messaging from "messaging";
let spinner = document.getElementById("spinner");

// Start the spinner
spinner.state = "enabled";

import clock from "clock";

let myClock = document.getElementById("myClock");

clock.granularity = 'seconds'; // seconds, minutes, hours

// Updating the time on the clockface
clock.ontick = function(evt) {
  myClock.text = ("0" + evt.date.getHours()).slice(-2) + ":" +
                      ("0" + evt.date.getMinutes()).slice(-2) + ":" +
                      ("0" + evt.date.getSeconds()).slice(-2);
};

// Get the elements & set their values & colors
let eta = document.getElementById("eta");
let teta = document.getElementById("teta");
let weta = document.getElementById("weta");
let ll = document.getElementById("leavelabel");
let tl = document.getElementById("trainlabel");
let wl = document.getElementById("walkinglabel");
let cir = document.getElementById("cir");
messaging.peerSocket.onmessage = function(evt) {
  // Stop the spinner
  spinner.state = "disabled";
  // Adding the text & updating the color of text and arc
  eta.text = evt.data.lT + "min" ;
  ll.text = "Leave in";
  if (evt.data.lT <=3 ) {
    eta.style.fill = "fb-red"
    cir.style.fill = "fb-red"
  } else if (evt.data.lT >3 && evt.data.lT<=5 ) {
    eta.style.fill = "fb-yellow"
    cir.style.fill = "fb-yellow"
  }
  teta.text = evt.data.tTA + "min" ;
  tl.text = "Train in";
  weta.text = evt.data.wT + "min" ;
  wl.text = "Walk time";
}

See this step in Github

Step 9 – Add permissions in package.json

Now let’s add the needed permissions to package.json. Open the file package.json which is in the base folder and update the field requestedPermissions with the following:

[
  "access_internet",
  "access_location",
  "run_background"
]

See this step in Github

So this completes the work of the app and we have a working clockface!

Open the simulator and run the app! Inside the fitbit shell you can directly run bi which is short for build and install:

npx fitbit
bi

Download tutorial source code

The complete source code for each step of this tutorial is available on Github.

We recommend that you clone the repo locally:

git clone https://github.com/ably/tutorials.git

Each tutorial’s source code is hosted on a separate branch. Checkout the one for this tutorial:

git checkout fitbit-train-app

Install the NPM dependencies:

cd train-app
npm install

And then run the app locally by adding your Ably API key and Google Map API key to companion/index.js and run

npx fitbit
bi

to start the web server and open the browser.

Next steps

1. If you would like to find out more about how channels and how publishing & subscribing works, see the realtime channels & messages documentation
2. Learn more about Channel Occupancy Events
3. Learn more about Ably features by stepping through our other Ably tutorials
4. Learn more about Fitbit
5. Learn more about Ably’s history feature to understand how you can prevent your clients from losing messages.
6. Gain a good technical overview of how the Ably realtime platform works
7. Get in touch if you need help