iniChapter 20: Communication and Data Exchange

One of the interesting and exciting features of a Pebble smartwatch is that it exchanges data with a phone. Through that exchange of data, many features can be implemented, including working with data from the Internet. In addition to Web pages and images, custom data exchanges can be made between apps on a phone and apps on a Pebble smartwatch.

This chapter will outline the concepts of phone-to-smartwatch communication and how that communication is implemented in C on a Pebble smartwatch. We will overview the model of the communication implemented by the Pebble SDK and we will review how to send and receive custom data objects through the Pebble SDK AppMessage system. We will include a look at communicating with large sets of data.

As with some previous chapters, this chapter is designed to introduce the concepts and programming foundation that is needed to programming Pebble smartwatch communication in C. There is a list of references to advanced features at the end of this chapter.

General Concepts

Before digging into communication implementation on Pebble smartwatches, we need to discuss some general concepts and ideas that influence the way communication is done.

By definition, communication happens between at least two parties: a sender and a receiver. (We actually could implement multipoint communication, between a sender and multiple receivers, but that's not what we are concerned about here.) Most of the time, the sender and the receiver are different entities. In our case, the two parties are a phone and a Pebble smartwatch.

Because they are two separate entities, communication between a phone and a Pebble smartwatch is asynchronous. We cannot make any assumptions about timing of the communication between the two. Data might arrive any time and might even be dropped accidentally, through interference or some error condition on either sender or receiver. A data receiver can never assume that data will be received on time, in order, or even at all.

We have seen other asynchronous processing. For example, user interaction is asynchronous; no assumptions can be made about when buttons will be pressed. We have handled this situation in user interfaces by using event-driven programming, where callbacks are registered as event handlers and called by the operating system when events happen. We can also use event-driven programming with communication; we will handle the asynchronicity of communication by registering callbacks with the operating system. These callbacks will be called when communication events occur: for example, data arrival or data being sent.

Data is usually sent and received as a series of data items in a stream. These data items, or packets, are necessary because sending and receving a long sequence of bytes can have very bad consequences. The long time devoted to the long data stream consumes the "attention" (i.e., CPU time and memory) of both the sender and the receiver. In addition, should errors occur in the data, it is easier to correct errors when the stream is sent in pieces: you correct and resend a small piece, not the whole data stream.

Because data is sent in smaller packets, callbacks will be called often as data packets arrive. It is important, then, to understand and record the state of communication. The communication state is a way to remember where we are in the process of sending and receiving data. It should be part of a program's design. This designation can change as various data are exchanged to indicate the progress of the exchange.

Communication is usually transactional: data is either sent or it is not. That sounds obvious, but it means that the acknowledgment of the receipt of data packets is an important action (an "ACK") and not acknowledging is also significant. The sender of data can set a timer; if the timer period expires without an ACK, it can result in a resend or in an error condition. Data communication between a phone and a Pebble will depend on these types of acknowledgements.

Finally, we should make a note about protocols. Protocols (from a programming perspective) are the rules and procedures used to exchange data between two parties. Protocols are easily demonstrated by watching two people who know each other approach in a hallway. There might be an exchange like this (let's assume their names are Katharine and Jon):

Katharine: "Hello Jon."
Jon: "Good morning, Katharine."
Katharine: "Are you going to be at the lunch meeting today?"
Jon: "No, I'm leaving for vacation at noon."
Katharine: "OK...enjoy!"
Jon: "See you next week."

There was a lot of data exchanged in that brief communication. But notice the protocol. There was an initial "handshake": someone started by greeting the other, and there was a response greeting. That set up the communication and implied an order of speakers. Notice that each speaker responded to what the other said (an acknowledgement) and added new information. In addition, there was a terminating exchange, after which both persons could walk away.

The above exchange is an example of how communication protocols work. There is often an initial sequence of data exchanged to establish sender/receiver relationships and to start data flowing. Then data is exchanged, often using acknowledgements to mark that data has arrived. Finally, the data exchange is terminated with a final sequence of data.

Protocols exist on many levels. It would be prudent to design a brief protocol when data is exchanged at the program level between a smartwatch app and a phone app. But there are also protocols that are implemented behind the scenes. These are designed to correctly carry the data exchange and to relay information about how that exchange is controlled. Errors and delays in exchanges are handled invisibly by these protocols. Fortunately, the Pebble communication system handles these lower level protocols for us.

PebbleKit

Communication with a phone depends on the right software being used on the phone. The software libraries and interfaces that make the phone side of communication possible are put together in a collection called "PebbleKit". There are versions of PebbleKit for Android and iOS phone operating systems as well as Javascript.

It is beyond the scope of this book to walk through how each PebbleKit version works. There is good documentation at the links above. In addition, the barcode app example we discussed in Chapter 18 and used in this chapter is also an example of PebbleKit for Android.

Sending and Receiving Data

Pebble apps send and receive data by using the Pebble AppMessage system. This way of sending and receiving data defines a packet structure (called a dictionary) and an event programming model as we discussed above. In this section, we will first discuss the basics of the system, examine the dictionary structure, and then look at how to receive and send data driven by events.

The AppMessage System

The AppMessage system is a communication system that enables an exchange of messages between a phone and a Pebble smartwarch. The system defines a data packet structure called a dictionary and a protocol for exchanging those dictionaries. The AppMessage system has mechanisms for serializing, sending and reconstructing packet structures.

To use the AppMessage system, a smartwatch app must go through the following steps:

  1. Signal intention to send or receive message by "opening" the AppMessage system.
  2. Register callback functions that will handle AppMessage system events.
  3. Wait for events, initiate sending, and handle the receipt or sending of dictionaries with callbacks.

This pattern should be familiar: it is the pattern used by the user interface event system and is typical of event-driven programing systems.

Opening the AppMessage system is done through the function app_message_open(). The function uses two parameters; its prototype is below:

AppMessageResult app_message_open(const uint32_t size_inbound, const uint32_t size_outbound);

The function takes two parameters, which indicate the sizes of the inbox and the outbox. The inbox is the buffer that will be used to store incoming dictionary messages. Likewise, the outbox is the buffer that will be used to store outgoing dictionary messages.

It is important to set these sizes correctly, since messages larger than its box will be rejected. There are several ways to compute the buffer size you might need:

  • You can use a minimum size set by the operating system. There are constants, called APP_MESSAGE_INBOX_SIZE_MINIMUM and APP_MESSAGE_OUTBOX_SIZE_MINIMUM, set by the Pebble SDK. Setting up a buffer using these constants will always work.
  • You can ask for the maximum size that the operating system can provide by using app_message_inbox_size_maximum() and app_message_outbox_size_maximum(). These functions will provide the largest message sizes.
  • You can use a convenience function to compute the size needed for an incoming or outgoing buffer. The dict_calc_buffer_size() function takes the number of key/value pairs and a list of sizes of value types and computes the size the buffer needs to be. The function dict_calc_buffer_size_from_tuplets() can also be used; see the next section for a description of tuplets.

Registering callbacks means telling the operating system which functions to call when one of several events occurs. There are four events that need callbacks:

  • An event is generated when a message is received. This event's callback is registered with the function app_message_register_inbox_received().
  • When a message is received but dropped, an event is generated. This could happen for a number of reasons; setting a inbox too small is an example. The callback for this event is registered with app_message_register_inbox_dropped().
  • Sending a message from the outbox generates an event. The callback for this event is registered with the function app_message_register_outbox_sent().
  • If sending a message from the outbox fails, an event is generated. Registering for this event is done with the function app_message_register_outbox_failed().

Only one callback per event may be registered; registering multiple callbacks replaces previous callbacks. Also, "registering" NULL as a callback will deregister the callback for a specific event. Calling the function app_message_deregister_callbacks() deregisters all callbacks.

Examples of registration and of how specific callback functions are defined will be given in the sections on sending a receiving below.

The actions of opening the AppMessage system and registering the callbacks is typically done in the initialization section of the user interface. No "closing" of the AppMessage systen is necessary.

Dictionaries

The AppMessage system starts with a definition of data packets called dictionaries. Dictionaries are a set of data, organized as key/value pairs, limited to small sizes. They are serializations of data: the organization of data into packets of bytes for both sending and receiving. The AppMessage system has mechanisms for serializing and reconstructing data structures.

Dictionaries begin with a dictionary iterator, a structure that holds dictionary information, and a buffer that the key/value pairs of a dictionary. Dictionaries are contructed like this:

  1. Declare a dictionary iterator that will be used to organize the dictionary and a buffer that will hold the serialized bytes of the dictionary.
  2. Start the construction process by calling dict_write_begin(), specifying the iterator and the buffer.
  3. Write data to the dictionary by calling "write" functions for each type of data and using the same dictionary iterator and buffer in each call.
  4. End the process by calling dict_write_end().

There are many "write" functions; each works with a specific C type, string, or integer array.

Consider an example. Let's say that a phone holds a list of barcodes and a smartwatch app needs to get the name of a barcode at a specific position in the list. We might build a dictionary that holds the request for the name list as follows:

#define COMMAND 0x0
#define NAMELIST_REQUEST 0x11

uint32_t buffer_size = dict_calc_buffer_size(1, sizeof(int));
uint8_t buffer[buffer_size];
DictionaryIterator iter;

dict_write_begin(&iter, buffer, sizeof(buffer));
dict_write_uint8(&iter, COMMAND, NAMELIST_REQUEST);
dict_write_end(&iter);

This is a simple dictionary, holding an integer. Note we need a pointer to an iterator in the dictionary write calls; we use "&" to generate the address of the iterator we declared. Also note that we used a convenience function to compute the size we needed for the buffer. The dict_calc_buffer_size() function takes the number of key/value pairs and a list of sizes of value types and computes the size the buffer needs to be.

As a convenience for constructing dictionaries, the Pebble SDK defines tuples and tuplets. A tuple is a key/value pair and a tuplet is a structure that focuses on the value of a tuple. We could have used tuples and tuplets to create the dictionary above using this code:

#define COMMAND 0x0
#define NAMELIST_REQUEST 0x11

uint32_t buffer_size = dict_calc_buffer_size(1, sizeof(int));
uint8_t buffer[buffer_size];
DictionaryIterator iter;

Tuplet request = TupletInteger(COMMAND, cmd);     

dict_write_begin(&iter, buffer, sizeof(buffer));
dict_write_tuplet(&iter, &request);
dict_write_end(&iter);

This might not seem like it's any more convenient, but the convenience will be demonstrated when we have a lot of data to put in a dictionary.

Sending Messages

Messages are sent using the outbox of an app. Remember that, in order for the outbox to be used, its size must have been set up by a call to app_message_open().

Three steps need to be followed to send a message to a phone:

  1. Inform the message system of the dictionary that will be used to send a message.
  2. Construct the message dictionary.
  3. Tell the system to send from the outbox.

The sequence is started by calling app_message_outbox_begin() to tell the system which dictionary to use. This function has the prototype below:

AppMessageResult app_message_outbox_begin(DictionaryIterator **iterator);

It returns an AppMessageResult result. This type is an enumeration of all the possible errors that could go wrong with sending a message. The non-error result you should look for is APP_MSG_OK.

To send a dictionary, the function app_message_outbox_send() needs to be called. Since the system is informed about which dictionary to use, sending a message is initiated with that dictionary. This call returns an AppMessageResult result. Since the process is asynchronous, a return value of APP_MSG_OK only signifies that the message sending process has begun correctly.

Let's consider another example. In the barcode display example, we can send a request to the phone about its barcode collection. There are many pieces of information we might ask the phone; all requests have a request key with the specific request code as the value. We built an example in the previous section that requested the list of barcode names.

We can build a function that makes generic requests of the phone this way:

static void send_request(uint8_t request, uint16_t data) {

    Tuplet value = TupletInteger(request, data);

    DictionaryIterator *iter;
    if (app_message_outbox_begin(&iter) == APP_MSG_OK) {
        dict_write_tuplet(iter, &value);
        dict_write_end(iter);
        app_message_outbox_send();
    }
}

This function builds a dictionary using tuplets and can be used for any request for information from the phone. For example, if we need a specific barcode image, we would call this way: send_request(SEND_BARCODE, value); where SEND_BARCODE has a specific code value that makes sense to both the phone and the smartwatch app and value is the list position of the barcode.

When sending a message, callbacks can be useful in a number of ways. A "sent" callback can report a successful operation to the user with a message or vibration. Uses for a "dropped" callback might be to report an error or to resend the dictionary. An interesting use for a "sent" callback is to implement a timer for a successful send operation. If we set the timer when we send the dictionary, then the "sent" callback can cancel the timer. If the "sent" callback is not called and the timer expires, we can use the timer callback to resend the dictionary.

Receiving Message

Messages are received via an app's inbox. This inbox's size must have been previously set up with a call to app_message_open(). If a message arrives that is larger than the inbox, it is dropped.

Receipt of messages is done entirely by callback. The receipt callback has to be registered for messages to be received. It is also advisable to register a callback for dropped messages. The receipt callback has the following prototype:

void received_callback(DictionaryIterator *iterator, void *context);

The iterator parameter is the pointer to the message represented as a dictionary. The context parameter is additional data set up by calling app_message_set_context() prior to receiving a message.

When a message arrives, it will be in the form of a dictionary, accessible by the iterator sent to the receipt callback. Reading a dictionary is done entirely by using tuplets. The way to parse a message is to get the first tuplet using dict_read_first(), extract the key and value, process the key and value, then repeat the sequence using dict_read_next(). When the return from either of these function is NULL, there are no tuplets left.

Another way to process a message is to specifically look for certain keys. If you know which key should be present, you can extract the tuplet using dict_find(). This function takes a dictionary iterator and a key, and will return the tuple where the tuple's key matches the search key.

Once we have a tuple, we can process the data structure. The struct representation of a tuple looks like this:

typedef struct __attribute__((__packed__)) {
    uint32_t key;
    TupleType type:8;
    uint16_t length;
    union {
        uint8_t data[0];
        char cstring[0];
        uint8_t uint8;
        uint16_t uint16;
        uint32_t uint32;
        int8_t int8;
        int16_t int16;
        int32_t int32;
    } value[];
} Tuple;

There are 4 fields in a Tuple struct: a 32-bit integer key, a designator of the type of the value, the length of the value, and the value itself, an array of bytes. The type of the value is one 4 possible types, designated by a TupleType enumerated type:

typedef enum {
    TUPLE_BYTE_ARRAY = 0,
    TUPLE_CSTRING = 1,
    TUPLE_UINT = 2,
    TUPLE_INT = 3,
} TupleType;

The value of a Tuple is extracted by working directly with the above struct. Note that the value is an array of 32-bit quantities.

Let's look at the barcode example. When a message arrives from the phone, the first thing that is checked is if it contains an error message. We do this as follows:

#define BARCODE_ERROR 0xFF

Tuple *tuple;
char *msg;

error = false;
tuple = dict_find(received, BARCODE_ERROR);
if (tuple != NULL) {
   msg = (char *)malloc(tuple->length+8);
   strcpy(msg, "ERROR: ");
   strcat(msg, tuple->value->cstring);
   state = STATE_ERROR;
}

We start by looking for a tuple with the key with the value 0xFF, defined in the code by the name BARCODE_ERROR. If a tuple with that key value exists, then we create a string and build an error message based on the string value in the tuple. We access all parts of the tuple through the pointer returned by the call to dict_find(). We access the message as a C string, terminated by a NULL character. (The app on the phone must make sure the string is sent terminated this way.)

Consider another example. Let's say that we have requested the name of a barcode from a specific position in the barcode list from the phone. The phone sends that name along with the format of a barcode (barcodes can have many different formats). This information comes as two different tuples in the same dictionary. We can process it like this:

#define NAME_BUFFER_SIZE 10
#define BARCODE_NAME 0x12
#define BARCODE_FORMAT 0x19

static char *nameBuffer[NAME_BUFFER_SIZE];
static char *formatBuffer[NAME_BUFFER_SIZE];

tuple = dict_find(received, BARCODE_NAME);
if (tuple != NULL) {
    nameBuffer[position] = (char *) malloc(tuple->length);
    strcpy(nameBuffer[position], tuple->value->cstring);
    tuple = dict_find(received, BARCODE_FORMAT);
    formatBuffer[position] = (char *) malloc(tuple->length);
    strcpy(formatBuffer[position], tuple->value->cstring);
} 

Here, we look for a tuple with key having the value depicted by BARCODE_NAME. We create space for two strings and extract those strings from the tuple. We do this by accessing the value field in the tuple struct directly.

Processing Several Different Key Values

It is common to have several different types of tuples arrive from a phone application. Since the tuples are grouped into the single DictionaryIterator object, we can process each one individually. We can either process them in sequence using dict_read_first() and dict_read_next() or we can look for specific keys.

Communicating with Complex Data

Using the dictionary model of messaging, we can send and receive data that is quite complex. In this section, we will overview how one might work with several types of complex data.

Sending Multiple Tuplets

In the previous section, we gave an example of how we might process multiple tuplets from same message or dictionary. We can also send messages that have multiple parts; we construct this type of dictionary using either multiple "dict_write" calls or by using an array of tuplets. We can do either one easily by repeating the method we use to add key/value pairs and postponing the dict_write_end() call until all pairs have been added.

Let's say we wanted to send multiple requests to the phone for barcode information. For this example, let's say we want the length of the barcode list and the name of the first barcode. We can do this without tuples in the code below:

#define REQUEST_BARCODE_LIST_LENGTH 0x11
#define SEND_BARCODE_NAME 0x13

uint32_t buffer_size = dict_calc_buffer_size(2, sizeof(int));
uint8_t buffer[buffer_size];
DictionaryIterator iter;

dict_write_begin(&iter, buffer, sizeof(buffer));
dict_write_uint8(&iter, REQUEST_BARCODE_LIST_LENGTH, 0);
dict_write_uint8(&iter, SEND_BARCODE_NAME, 0);
dict_write_end(&iter);

app_message_outbox_send();

This is a simple extension of our previous example, extending the size of the buffer and making more than one write call. This is just as simple in the use of tuples:

    Tuplet length_request = TupletInteger(REQUEST_BARCODE_LIST_LENGTH, 0);
    Tuplet name_request = TupletInteger(SEND_BARCODE_NAME, 0);

    DictionaryIterator *iter;
    if (app_message_outbox_begin(&iter) == APP_MSG_OK) {
        dict_write_tuplet(iter, &length_request);
        dict_write_tuplet(iter, &name_request);
        dict_write_end(iter);
        app_message_outbox_send();
    }

Techniques for Large Data Sets

When sending or receiving large amounts of data, there are techniques that we can use that exploit the AppMessage system to make the exchange efficient. Here are a few of those techniques.

  • Break the data up into sections. Often it is not possible (or efficient or error-tolerant) to send data in one big package. This means that the data set should be broken into pieces. Do the separation along logical lines: break images up by rows or divide sensor data by time.
  • Send as large of sections as possible. Use the functions app_message_inbox_size_maximum() and app_message_outbox_size_maximum() to determine the maximum size of the inbox and outbox. Use these sizes to scale your data sections. The size of these boxes can change from SDK to SDK, and using the functions will catch these changes and allow your code to adapt.
  • Manage and label each data package through the key of a data set. Use keys to tag each section of the data set and to keep them in order. The key is simply an integer and incrementing that integer for each section sent can keep the data stream organized. Make sure you use a special key value that signals the stream is completely sent.
  • Make the outbox sent callback send the next data set. Sometimes, sending needs to be a quick and efficient as possible. Using the callback for data sent from the outbox to send the next section of data is the most efficient method. See this Pebble guide for an example.

Advanced Features

This chapter is designed to overview the AppMessage system and to give enough information to get you started with communication. There are a number of more advanced topics that are covered in the Pebble SDK documentation that are beyond our scope here.

  • Merging Dictionaries: Occasionally, it is neccessary to merge dictionaries together. While you could write some code to do this, the Pebble SDK provides the capability to do this. The function is dict_merge() and provide a great deal of flexibility in the way dictionaries are merged. Read more about this here.
  • Data Logging: Obviously, communication between a smartwatch and a phone requires the two to be connected. When the two are not connected, the potential exists for errors and timeouts in an app that depends on that connection. Data logging allows a buffer of data to collect on the smartwatch when there is no connection and to be transferred when the connection is made. Read more here on how this is implemented.
  • Working with Image Data: Image formats usually compress image data for a variety of reasons. Portable Network Graphics format is used by the Pebble system as a means to exchange compressed image data with a phone. The Pebble SDK details a number of ways to work with PNG image data. Read about the here.
  • The Sports Interface: Every Pebble smartwarch has a Sports app as a system application. This app displays sports-related information in several different configurations. The Pebble system defines a standard way to transfer that information from a phone. You can read about the PebbleKit definitions here.

Project Exercises

For exercises that involve communication, we need both sender and receiver. MORE about JS

Project 20.1

The starter code for this project can be found here. The JavaScript component of this project produces the national debt of the United States. There are two message keys defined:

  • MESSAGE_KEY_ASK: This is a message sent from the smartwatch side to the JavaScript side. The message key should be sent as an integer, but can have any non-zero value. It tells the JavaScript side to find, process, and produce a string that depicts the U.S. national debt.
  • MESSAGE_KEY_DEBT: This is a message sent from the JavaScript side to the smartwatch side. It is has a string value indicating the U.S. national debt, including a starting "$" symbol.

You are write code to display the current U.S. national debt on the Pebble smartwatch screen. To do this, you will need to fill in the definition of the ask_for_debt() function and the in_received_handler() function. The ask_for_debt() function will have to send a message to the JavaScript side that contains the MESSAGE_KEY_ASK as a key and the in_received_handler() function will have to look for the MESSAGE_KEY_DEBT key in a received message and process the string that accompanies the key.

The U.S. national debt increases at a rate of approximately $44,000 per second. Add a timer to the code that will check the debt every 5 seconds and refresh the screen.

An answer for this project can be found here.

U.S. Debt Details

For an excruciatingly detailed look at the U.S. national debt, check out the US Debt Clock.

Project 20.2

Let's revisit the U.S. national debt to give us a little more information. Start with the answer to the previous project.

In addition to the national debt, let's display how much debt is on each American citizen. This means you need the debt number as an integer, not a string. Since the largest (signed) 32-bit integer is 2,147,483,647, we need to represent, and communicate, this number differently. We are going to send the debt number in two strings from the JavaScript side and "build" our floating point debt representation on the smartwatch side.

The following messages and keys are defined on the JavaScript side:

  • MESSAGE_KEY_RIGHTMOST: This message is sent with an integer value. The JavaScript side with reply with same key and a C string that comprises the rightmost number of characters requested by the original message.
  • MESSAGE_KEY_LEFTMOST: This message is sent with an integer value. The JavaScript side with reply with same key and a C string that comprises the leftmost number of characters requested by the original message.
  • MESSAGE_KEY_USPOPULATION: This message has no meaningful payload. The JavaScript side will reply with two keys: MESSAGE_KEY_USPOPULATION will have a 32-bit integer that is the current population of the U.S. and a MESSAGE_KEY_DEBT key that is paired with a string representation of the U.S. debt. This is to show that multiple key/value pairs can be sent with dictionaries.

You are to request the information you need to display the amount of the U.S. debt that each person might owe. Ask for each of "leftmost" and "rightmost" parts of the debt, assemble the debt, collect the number or people in the U.S., and do the simple arithmetic to figure out the per-person amount. Display this on the watch screen.

An answer for this project can be found here.

Project 20.3

This project is going to work with GPS coordinates and the address of your current location. Click here for the starter code for this project.

The JavaScript side of this project will respond to two types of requests, defined with the following keys:

  • MESSAGE_KEY_LATLONG: This request will cause the JavaScript side to respond with a dictionary with 4 key/value pairs:
    • MESSAGE_KEY_LATITUDE: the latitude of your current position.
    • MESSAGE_KEY_LONGITUDE: the longitude of your current position.
    • MESSAGE_KEY_ALTITUDE: the altitude in meters of your current position.
    • MESSAGE_KEY_ACCURACY: the accuracy in meters of your current position.
  • MESSAGE_KEY_ADDRESS: The payload of this message must have an array of unsigned, 8-bit integers that comprise a latitude and longitude (see below). The response from the JavaScript side is a string depicting the address of the location.

There is one issue with data transfer using the AppMessage system: there is no native way to transfer floating point data. And yet GPS coordinates are floating point numbers. So here are the formats of the data that gets transferred.

  • When the JavaScript side transfers data to the C side, it multiplies the float point number by 100, then truncates the number to an integer. For example, "39.0437" becomes "3904".
  • When the C side transfers data to the JavaScript side, the payload is a bundle: an array built from both latitude and longitude. In this case, we are further restricted that we can only send arrays of unsigned 8-bit numbers. So, the C side sends 4 unsigned integers as an array: the left of the decimal point and the right of the decimal point for each of latitude and longitude. Use dict_write_data() to set up the data.

Also note that the smartwatch emulator that CloudPebble uses only simulates the GPS location and it might not be accurate.

Use the starter implementation and complete the starter code. Your answer should display the longitude and latitude of your current location on the smartwatch screen when the "up" button is pressed and the address of your current location when the "down" button is pressed.

An answer to this project is online here.

results matching ""

    No results matching ""