INTRODUCTION
Time-keeping is inherently stressful, especially when you can see the seconds ticking down. The Virtual Hourglass Timer takes all the pressure away through its relaxing visual display.
Inspired by thisĀ digital hourglass alarm clockĀ , the Virtual Hourglass Timer allows the user to set a timer and opts to display the time remaining in an hourglass format instead of the traditional hours, minutes, and seconds approach. The hourglass allows users to check the time remaining with a quick glance at the display, instead of having to closely read their phone or other digital timers. In addition, the Virtual Hourglass Timer iPhone application allows users to easily set timers via Bluetooth, giving users even more flexibility. For example, a user could be cooking and set a timer to remind them to take the food out of the oven; when their hands are full with other meal prep, a quick glance at the digital hourglass could let them know how much time is left without having to drop what they are doing to check their phone.
The Virtual Hourglass Timer is powered by a PIC32, an Arduino UNO, an Adafruit Bluefruit LE SPI Friend, and a TFT display. It can be controlled via our iPhone application.
STARTING CHALLENGES
Our two biggest challenges at the start of our product development were a malfunctioning LED matrix display and a difficult-to-use Bluetooth module.
LED MATRIX
After we received the LED matrix, we referred to the data sheet and several examples online to connect the display and run starter code on it. Unfortunately, even example code from the vendor was not driving the display correctly. Ultimately, we concluded the hardware was damaged and due to time restrictions, we decided to use the TFT LCD display from previous labs as our main timer display. While this certainly was not ideal, we believe it was enough to show proof of concept.
ADAFRUIT BLUEFRUIT LE SPI FRIEND
We initially tried to have the Big Board and the BLE module communicate directly. After hours of unsuccessful attempts, it came to our attention that in order to establish this communication channel, we would have to write the entire SDEP (SPI Data Transport) layer code. After discussing the issue with Bruce Land, we were allowed to use an Arduino UNO, and by extension the vast SPI Friend Arduino support, to facilitate this process.
DESIGN, IMPLEMENTATION, AND TESTING
For our project, we decided to build off of one of the suggested project ideas from the course website. This project seemed doable within the given timeframe and would involve heavy usage of the PIC32, which we felt was ideal for a project for this class. Our project can be be broken down into a few major components:
- Animation
- Custom Command Set
- UART
- Bluetooth
- Additional Threads
Additional hardware, outside of the PIC32, includes a TFT Display, an Adafruit Bluefruit LE SPI Friend, and an Arduino UNO. The role of each of these components will be discussed further as we break down the project piece by piece.
ANIMATION
Our project utilizes the TFT display used inĀ Lab 2Ā to animate an hourglass filled with sand. The basis of our animation code is the animation code from lab 2. We began with our ball collision code from the lab and altered it as necessary to model grains of sand. In the end, we were able to fill the top half of the hourglass with sand, then periodically drop grains down to the lower half to simulate sand falling down an hourglass as time passes. The rate at which these grains drop is determined by the amount of time that the timer is set for.
HARDWARE
The only hardware needed for this section is the TFT display, which connects directly to the Big Board. The PIC32 communicates with the TFT display using SPI.
SOFTWARE
The animation code in our project was very software intense. We began with the animation code from Lab 2 as the basis for our code and worked from there. We began by creating a function calledĀ DrawHourglassSmall()Ā which uses multiple calls toĀ tft_drawLine()Ā to draw the hourglass on our display.
void drawHourglassSmall(void) {
// top border
tft_drawLine(80, 40, 160, 40, ILI9340_BLUE);
// top side wall
tft_drawLine(80, 40, 80, 80, ILI9340_BLUE);
// top side wall
tft_drawLine(160, 40, 160, 80, ILI9340_BLUE);
// bottom border
tft_drawLine(80, 160, 160, 160, ILI9340_BLUE);
// bottom side wall
tft_drawLine(80, 120, 80, 160, ILI9340_BLUE);
// bottom side wall
tft_drawLine(160, 120, 160, 160, ILI9340_BLUE);
// top angle in
tft_drawLine(80, 80, 110, 90, ILI9340_BLUE);
// top angle in
tft_drawLine(160, 80, 130, 90, ILI9340_BLUE);
// bottom angle in
tft_drawLine(80, 120, 110, 110, ILI9340_BLUE);
// bottom angle in
tft_drawLine(160, 120, 130, 110, ILI9340_BLUE);
// tube wall
tft_drawLine(130, 110, 130, 90, ILI9340_BLUE);
// tube wall
tft_drawLine(110, 90, 110, 110, ILI9340_BLUE);
}
This function is called each time our animation thread runs in order to redraw the hourglass in case any of the grains of sand drew over the hourglass.
The grains of sand are items from ourĀ ballsĀ array, which is a 2D array. The first index represents the grain of sand we are referring to, and the second index represents the field of that grain of sand we wish to access. Each grain of sand has the following fields:Ā xc, yc, vxc, vyc,Ā andĀ spawn_timeĀ . TheĀ xcĀ andĀ ycĀ fields represent the x and y coordinates of the grains of sand whileĀ xvcĀ andĀ vycĀ represent the x and y velocities of the grain of sand. TheĀ spawn_timeĀ field is used if we wish to delay when the balls spawn relative to each other. In the end, we decided to spawn all the balls at the same time, so they were all given a value of 0. The functionĀ ball_init()Ā is used to initialize all the grains of sand. This function erases the grains from their current position and updates their fields to the spawn positions of each grain of sand. This function is called each time our timer receives a command to start the timer or a command to cancel the timer (that way the grains of sand are erased from the screen).
void ball_init(void) {
// init balls
int k;
for (k = 0; k < num_balls; k++) {
tft_fillCircle(Accum2int(balls[k][xc]), Accum2int(balls[k][yc]), BALL_RADIUS, ILI9340_BLACK);
live[k][0] = TRUE;
live[k][1] = 0;
balls[k][xc] = int2Accum(spawn_xc + ((k * 6) % 60));
if (k < 10) balls[k][yc] = int2Accum(spawn_yc);
else if (k < 20) balls[k][yc] = int2Accum(spawn_yc - 6);
else balls[k][yc] = int2Accum(spawn_yc - 12);
balls[k][vxc] = int2Accum(spawn_vxc);
balls[k][vyc] = int2Accum(spawn_vyc);
}
}
The animation thread begins by calling theĀ DrawHourglassSmall()Ā function in order to start our display and draw the hourglass on it. Next is a while loop to check that the system is out of standby mode and that the grains have been initialized. We update the variablesĀ standbyĀ andĀ initĀ to represent these conditions. Inside the while loop, we erase each grain, update its coordinates, and then redraw the ball at its new position. This is done for every grain. Positions are updated based on a few checks. First, we check if it is time to drop a new grain to the lower half of the hourglass. To do this, we compare the values of the variablesĀ hourglass_timeĀ andĀ drop_timeĀ , which represent the amount of time that has passed on the timer and the amount of time between sand drops respectively. The following check in the animation thread updates a booleanĀ dropĀ under two conditions. The first check is to make sure we have passed the amount of time we want to drop a grain of sand. This means that if we want to drop a grain every two seconds, that we do not want to drop the first grain until two seconds has passed. Without this check, a grain will always be dropped at timeĀ hourglass_time = 0Ā which is not what we desire. The second check performs a modulus ofĀ hourglass_timeĀ andĀ drop_timeĀ in order to see if it is time to drop a new grain of sand. Once again, if we desire to drop a grain every two seconds, then we do not want to drop a grain at timeĀ hourglass_time = 5Ā . In that case,Ā hourglass_time % drop_time = 1Ā and we will fail the if condition, leavingĀ drop = FALSEĀ .
if (hourglass_time >= drop_time && hourglass_time % (int) drop_time == 0) drop = TRUE;
We also select pseudo-randomĀ vxcĀ andĀ xcĀ values to assign to a grain that we may drop in this loop. This is because if all of the grains were to drop with identicalĀ xcĀ andĀ vxcĀ values, they would stack on top of each other and not emulate real sand. We select these values by with the statement
int mod = begin_time % 30;
where mod is used as an index into two, size 30 arrays that each contain a set of potentialĀ xcĀ andĀ vxcĀ values.
We decide to drop a ball based on the following conditions:
if (drop == TRUE && ball_dropped == FALSE && drop_dex < num_balls && i == drop_dex) ...
TheĀ drop == TRUEĀ statement is used to determine if we are on the appropriate second to drop a grain.Ā ball_dropped == FALSEĀ determines whether or not we have already dropped a grain this second (since the thread is running faster than once per second). This value is changed toĀ TRUEĀ after a grain is dropped and is reset toĀ FALSEĀ when we incrementĀ hourglass_timeĀ at the start of a new second. TheĀ drop_dex < num_ballsĀ check is used to make sure that we are not trying to drop more sand than we have on screen. TheĀ drop_dexĀ variable is incremented each time we drop a grain and is reset upon receiving a new start command. TheĀ num_ballsĀ field is used to track how many grains we are going to draw on the screen. The default value is 30, but if the timer is being set for a value less than 30 seconds, then we will draw a sand equal to the number of seconds and drop one grain per second. The final check is thatĀ i == drop_dexĀ whereĀ iĀ is the index of the current grain of sand. When dropping a grain of sand, we only want to drop the grain that the loop is currently handling. Otherwise, the grain will have its position updated but will never be erased from its prior position, leading to grains dropping down, but never being removed from the top half. We update a dropping grainās position as follows:
balls[drop_dex][yc] = 115;
balls[drop_dex][xc] = xcord;
balls[drop_dex][vyc] = spawn_vyc;
balls[drop_dex][vxc] = x_vel;
ball_dropped = TRUE;
drop_dex++;
If the grain is not dropping, then we simply set the grainās new x and y coordinates to be equal to the sum of the prior coordinates and the x and y velocities:
balls[i][xc] = balls[i][xc] + balls[i][vxc];
balls[i][yc] = balls[i][yc] + balls[i][vyc];
Next, each grain is checked for collisions. First, for collisions with the hourglass. If a grain collides with either side wall of the hourglass then the x-velocity is negated and damped to one tenth the original value. This damping factor is added because sand does not bounce all that much. If a grain manages to collide with the top of either half, then the y-velocity is negated and damped as well. This behavior is unlikely since every grain spawns with a downwards velocity and experiences downward gravity when not resting. If a ball collides with the floor of either the top or bottom half, then the x and y velocities are set to 0 and gravity is not factored in. We remove gravity in these cases to keep the balls from moving down a pixel each loop, eventually leaving the hourglass.
// Check border collision
// Top half of hourglass
if (balls[i][yc] < int2Accum(95)) {
if (balls[i][yc]<(40)) balls[i][vyc] = -0.1 * balls[i][vyc];
if (balls[i][yc]>(75)) {
balls[i][vyc] = 0
balls[i][vxc] = 0;
}
else balls[i][vyc] = balls[i][vyc] + g;
} // Bottom half of hourglass
else if (balls[i][yc] > int2Accum(105)) {
if (balls[i][yc]<(110)) balls[i][vyc] = -0.1 * balls[i][vyc];
if (balls[i][yc]>(155)) {
balls[i][vyc] = 0;
balls[i][vxc] = 0;
}
else balls[i][vyc] = balls[i][vyc] + g;
}
After checking border collision, we check sand to sand collisions. These collisions are split into two cases: head on collisions and angled collisions. In the case of a head on collision, we swap the x and y velocities of the grains and add a damping factor of 0.5 to the x velocities and a factor of 0.1 to the y velocities.
if (rij2 < int2Accum(1)) {
_Accum temp = 0.5 * balls[i][vxc];
balls[i][vxc] = 0.5 * balls[j][vxc];
balls[j][vxc] = temp;
temp = 0.1 * balls[i][vyc];
balls[i][vyc] = 0.1 * balls[j][vyc];
balls[j][vyc] = temp;
}
In the case of an angled collision, we compute changes in velocity as follows:
We make some slight adjustments to this calculation, such as adding the damping factors mentioned before and, instead of sending colliding grains away from each other, they both receive positive changes in y velocity and will thus fall down together. This change is made because if a grain lands on a pile of other grains, it should not bounce upwards. In code, this is seen as:
else {
_Accum vxi = balls[i][vxc];
_Accum vyi = balls[i][vyc];
_Accum vxj = balls[j][vxc];
_Accum vyj = balls[j][vyc];
_Accum vDeltX = vxi - vxj;
_Accum vDeltY = vyi - vyj;
_Accum nDeltX = deltX;
_Accum nDeltY = deltY;
_Accum dot = deltX * vDeltX + deltY*vDeltY;
_Accum nDot = dot / rij2;
_Accum deltVx = -nDeltX * nDot;
_Accum deltVy = -nDeltY * nDot;
balls[i][vxc] = 0.5 * (balls[i][vxc] + deltVx);
balls[i][vyc] = 0.1 * (balls[i][vyc] + deltVy);
balls[j][vxc] = 0.5 * (balls[j][vxc] - deltVx);
balls[j][vyc] = 0.1 * (balls[j][vyc] + deltVy);
}
Finally, we draw the grain at its new x and y coordinates. The thread will either remain in the while loop if the timer is still running, or exit the while loop and finish the thread if the system has been put into standby or the balls need to be re-initialized.
CUSTOM COMMAND SET
For this project, we devised a simple command set to communicate between our app and the PIC32. The first character of the command set is the command itself. Depending on the command, the character will then be followed by a set of digits. Finally, the ending character is an āeā which signals the end of the command. Here is our command set:
āSā: The āsā character signals a start command. It is then followed by the amount of seconds the timer is to be set for. Finally, there is an āeā to end the command. For example, to start the timer for 30 seconds, the command ās30eā would be sent from the app to the PIC32. This command is only sent from the app to the PIC32.
āPā: The āpā character signals a pause command. This command is sent from the app to the PIC32 in the form āpeā. This command pauses the timer.
āRā: The ārā character signals a resume command. This command is sent from the app to the PIC32 in the form āreā. This command resumes the timer.
āCā: The ācā character signals a cancellation command. This command is sent from the app to the PIC32 in the form āceā. This command cancels the current timer and resets the timer.
āTā: The ātā character signals a time update command. This command is sent from the PIC32 to the app in the form ātxxeā where the āxxā is the amount of time remaining on the timer. This command is used to keep the timer app and PIC32 timer in sync.
āFā: The āfā character signals a finish command. This command is sent from the PIC32 to the app in the form āfeā. This command is used to tell the app that the timer has finished.
In our PIC32 code, we have a command thread that handles each of the possible commands it can receive. At the start of the command thread, we first check to see if the command we have received is different from the previous command. This is because we send commands continuously in case a command is missed.
int ii;
int diff_cmd = FALSE;
for (ii = 0; ii < 30; ii++) {
if (cmd[ii] != cmd_p[ii]) diff_cmd = TRUE;
}
Once it is determined that the commands are different, this thread enters a while loop under the condition thatĀ diff_cmd = TRUEĀ . Inside this loop, the thread will checkĀ cmd[0]Ā in aĀ switchĀ statement.
If the received command is aĀ startĀ command, the thread will searchĀ cmdĀ for the index of the āeā character. Between this index and index 0 lies the amount of time that the timer needs to be started for (in seconds). We splice theĀ cmdĀ string between these two indexes using theĀ slice_str()Ā function.
void slice_str(const char * str, char * buffer, int start, int end) {
int j = 0;
int i;
for (i = start; i < end; ++i) {
buffer[j++] = str[i];
}
buffer[j] = 0;
}
With the use of this function, theĀ time_stringĀ variable now holds a string of the time in seconds that the timer needs to start for. The time is extracted from the string and converted to an int usingĀ sscanf()Ā and is stored inĀ input_timeĀ .
sscanf(time_string,"%d", &input_time);
This thread now sets the fields needed to run the timer.Ā hourglass_timeĀ is restarted and the amount of sand is set (max of 30 grains, and only use less than 30 grains if the time is less than 30 seconds). Next, the drop time is calculated and set, the grains are initialized, andĀ drop_dexĀ is reset. Finally, our semaphore values are updated (Ā initĀ ,Ā standbyĀ ,Ā finĀ ,Ā diff_cmdĀ ).Ā initĀ represents if the sand has been reset (Ā ball_initĀ has been called).Ā standbyĀ represents if the system is in standby mode and is used to determine whether or not the sand should be animated.Ā finĀ represents if the timer has finished. When this field is true, the PIC32 sends a āfinishedā command to the app.Ā diff_cmdĀ is set toĀ FALSEĀ in order to exit the while loop to compare the next command to the command that was just processed.
case 's':
for (ii = 0; ii < 30; ii++) {
if (cmd[ii] == 'e') end_idx = ii;
}
input_time = 0;
char time_string[30];
slice_str(cmd, time_string, 1, end_idx);
sscanf(time_string,"%d", &input_time);
hourglass_time = 0;
if (input_time < NUM_BALLS) num_balls = input_time;
else num_balls = NUM_BALLS;
drop_time = input_time / num_balls;
ball_init();
init = TRUE;
drop_dex = 0;
standby = FALSE;
fin = FALSE;
diff_cmd = FALSE;
break;
In the case of aĀ pauseĀ command, the system simply enters standby mode.Ā diff_cmdĀ is once again reset for the aforementioned reasons. This will happen in every case.
case 'p':
standby = TRUE;
diff_cmd = FALSE;
break;
AĀ resumeĀ command takes the system out of standby mode.
case 'r':
standby = FALSE;
diff_cmd = FALSE;
break;
AĀ cancelĀ command puts the system into standby mode and re-initializes the ball.Ā initĀ is set toĀ FALSEĀ andĀ hourglass_timeĀ is reset since the timer is no longer running the last start command.
case 'c':
standby = TRUE;
diff_cmd = FALSE;
init = FALSE;
hourglass_time = 0;
ball_init();
break;
Finally, there is aĀ defaultĀ case that simply setsĀ diff_cmd = FALSEĀ .
Default:
diff_cmd = FALSE;
break;
UART
To establish communication between the Arduino UNO and the Big Board, UART serial communication was used. To set up serial communication between these two devices, we created a serial thread ā which is discussed below ā along with the following hardware.
HARDWARE
For this part of the project, the only necessary additions were the wiring between the Big Board and the Arduino to establish serial communication and a voltage divider. We wired the transmit line of the Arduino through a voltage divider and then to the receive line on the board because of the voltage output differences between the two devices. We also added the UART to USB serial cable as inĀ Lab 3Ā to enable us to use PuTTY, which was used to see the input and output commands received and transmitted by the Big Board. This required three pin connections: GND, RX, and TX which were connected to GND, RA1, and RB10 respectively. The other end of this device is a USB which was simply connected to the lab computer. After reading which COMM port that USB was connected to, we opened a PuTTY serial connection to that port.
Arduino | Big Board |
---|---|
RX pin 0 | U1TX pin RB7 |
TX pin 1 | U1RX pin RB13 |
GND | GND |
Note that the TX to U1RX connection goes through a voltage divider because of the voltage output differences between the Arduino and the Big Board.
SOFTWARE
To establish serial communication between the Arduino and the Big Board, we set up UART serial communication in mode 2, which is machine mode. We do so by adding an interface to UART1 ā called in our code AUX UART. The AUX interface is only suitable for machine to machine communication because it supports DMA send/receive only, which is why we use it for communication between the Arduino and the Big Board. The following serial aux thread was created to support this interface.
// semaphores to sync threads
int ready_to_send = 1, ready_to_receive = 0;
// ========================================================
// AUX uart loopback receive thread
static PT_THREAD(protothread_serial_aux(struct pt *pt)) {
PT_BEGIN(pt);
// termination for machine buffer on AUX channel
// terminate on 'e'
PT_terminate_char_aux = 'e';
while (1) {
// wait for data transmission start
PT_YIELD_UNTIL(pt, ready_to_receive == 1);
ready_to_receive = 0;
// get sent data on AUX channel (UART1 loopback)
PT_SPAWN(pt, &pt_DMA_input_aux, PT_GetMachineBuffer_aux(&pt_DMA_input_aux));
// reset semaphore to indicate data received
// (or timed out)
ready_to_send = 1;
// NEVER exit while
} // END WHILE(1)
PT_END(pt);
} // aux uart thread
Before the thread, we initialize semaphoresĀ ready_to_sendĀ andĀ ready_to_receiveĀ to be able to synchronize the serial threads. We set a termination character to signal when to stop reading the receive buffer. In our case, that termination character is āeā. We then yield until there is data to receive ā indicated by the semaphoreĀ ready_to_receiveĀ . When there is data to be received, we spawn the machine buffer aux thread, which reads the data in the machine buffer, and put that data in the DMA input aux channel. The last thing to do is to reset the semaphores to indicate that the data has been received.
In addition to this thread, we also created a serial thread that enabled us to use PuTTY to visualize the commands that the Big Board was sending and receiving. We also handle transmission of commands from the Big Board to the Arduino and copy the commands in the aux buffer to the actual serial buffer in this thread.
static PT_THREAD(protothread_serial(struct pt *pt)) {
PT_BEGIN(pt);
// static char cmd[30], cmd_p[30], t0;
static float value;
static int i;
static int mode = 2;
static int v1, v2;
// termination for machine buffer
PT_terminate_char = '\r';
PT_terminate_count = 0;
// time in milliseconds!
PT_terminate_time = 1000;
while (1) {
if (mode == 2) {
PT_SPAWN(pt, &pt_DMA_input, PT_GetMachineBuffer(&pt_DMA_input));
}
// spawn a print thread
PT_SPAWN(pt, &pt_DMA_output, PT_DMA_PutSerialBuffer(&pt_DMA_output));
// read a string into the aux send buffer
sscanf(PT_term_buffer, "%s %s", "t120e", PT_send_buffer_aux);
//send a string by loopback thru UART1 (AUX))
// wait for the read to be done
PT_YIELD_UNTIL(pt, ready_to_send == 1);
// start the read, THEN start the write
// signal the receive thread that data is coming
ready_to_receive = 1;
// clear the ready flag until read thread is done
ready_to_send = 0;
// wait a little so that receive thread is ready
// and the DMA channel is ready
PT_YIELD(pt);
if (msg_ready == TRUE && fin == FALSE) {
sprintf(PT_send_buffer_aux, "t%de", hourglass_time);
// send using AUX UART
PT_SPAWN(pt, &pt_DMA_output_aux, PT_DMA_PutSerialBuffer_aux(&pt_DMA_output_aux));
msg_ready = FALSE;
} else if (msg_ready == TRUE && fin == TRUE) {
sprintf(PT_send_buffer_aux, "fe");
// send using AUX USART
PT_SPAWN(pt, &pt_DMA_output_aux, PT_DMA_PutSerialBuffer_aux(&pt_DMA_output_aux));
msg_ready = FALSE;
standby = TRUE;
}
// wait for the AUX receive to actually happen
// (including time outs)
PT_YIELD_UNTIL(pt, ready_to_send == 1);
// test for AUX channel timeout otherwise
// copy the data
if (PT_timeout_aux == 1) {
sprintf(PT_send_buffer, " AUX uart TimeOut");
} else {
// copy the AUX uart input buffer to the normal uart output buffer
strcpy(PT_send_buffer, PT_term_buffer_aux);
strcpy(cmd_p, cmd);
strcpy(cmd, PT_term_buffer_aux);
}
// spawn a print thread to the terminal
PT_SPAWN(pt, &pt_DMA_output, PT_DMA_PutSerialBuffer(&pt_DMA_output));
// never exit while
} // END WHILE(1)
PT_END(pt);
} // thread serial
As before, we use semaphoresĀ ready_to_sendĀ andĀ ready_to_receiveĀ to synchronize the serial threads. We initialized valuesĀ msg_readyĀ andĀ finĀ to keep track of when there is a message ready to be sent and when the timer has run out, respectively. This allows us to send update messages to the Arduino every second so that it knows how much time is left on the timer and updates the Arduino when the timer is finished. The thread spawns a new machine buffer if the mode is machine mode and spawns a print thread to be able to send data through the channel. We yield the thread until the thread is ready to send using theĀ ready_to_sendĀ semaphore. When this condition is satisfied, we check if we have a command to send usingĀ msg_readyĀ . If we have a command ready and the timer has not yet finished, we send how much time is left on the timer. If there is a command ready and the timer has run out, then we send the finished command. The last condition we must check for in this thread is timeouts. If the channel has timed out, we print a timeout message to PuTTY. If there is no timeout, then we copy the data in the aux serial buffer to the serial buffer so that it can be sent to the Arduino.
ADDITIONAL THREADS
The PIC32 uses six total threads:Ā timerĀ ,Ā serialĀ ,Ā serial_auxĀ ,Ā animĀ ,Ā cmdĀ , andĀ hourglassĀ . TheĀ serialĀ ,Ā serial_auxĀ ,Ā animĀ , andĀ cmdĀ threads have been discussed above, leaving just theĀ timerĀ andĀ hourglassĀ threads. These two threads serve similar purposes. All of the threads are scheduled using a round robin scheduler.
TheĀ timerĀ thread is assigned toĀ thread_num_timerĀ which is used to set thread parameters. In theĀ timerĀ thread, the unsigned intĀ sys_time_secondsĀ is incremented every second and is never reset. Because theĀ timerĀ thread is used by the scheduler, we chose not to use it for our timer. As such, we never changeĀ sys_time_secondsĀ beyond incrementing it. This thread also displaysĀ input_time ā hourglass_timeĀ (the remaining time) on the TFT display every second using theĀ printLine2()Ā function. If the remaining time is less than 0, then 0 will be displayed on the TFT to show that the time has expired.
static PT_THREAD(protothread_timer(struct pt *pt)) {
PT_BEGIN(pt);
// while (standby == TRUE);
while (1) {
// yield time 1 second
PT_YIELD_TIME_msec(1000);
sys_time_seconds++;
// draw sys_time
if (hourglass_time < input_time) {
sprintf(buffer, "Time=%d", input_time - hourglass_time );
}
else sprintf(buffer, "Time=%d", 0);
printLine2(0, buffer, ILI9340_BLACK, ILI9340_GREEN);
// NEVER exit while
} // END WHILE(1)
PT_END(pt);
} // timer thread
TheĀ hourglassĀ thread is also a timer thread. This thread incrementsĀ hourglass_timeĀ every second in the same way that theĀ timerĀ thread incrementsĀ sys_time_secondsĀ every second. The key difference is theĀ hourglassĀ thread will only incrementĀ hourglass_timeĀ when the system is not in standby mode. This thread will also updateĀ ball_droppedĀ andĀ msg_readyĀ .Ā ball_droppedĀ is setĀ FALSEĀ because a ball has not been dropped that second.Ā msg_readyĀ is setĀ TRUEĀ because a time update message needs to be sent that second. Finally, ifĀ hourglass_time > input_timeĀ then the system has finished, soĀ fin = TRUEĀ andĀ init = FALSEĀ since the sand must be re-initialized.
static PT_THREAD(protothread_hourglass(struct pt *pt)) {
PT_BEGIN(pt);
// while (standby == TRUE) PT_YIELD_TIME_msec(1000);
PT_YIELD_TIME_msec(1000);
while (standby == FALSE) {
PT_YIELD_TIME_msec(1000);
hourglass_time++;
ball_dropped = FALSE;
msg_ready = TRUE;
if (hourglass_time > input_time) {
fin = TRUE;
init = FALSE;
}
}
PT_END(pt);
}
BLUETOOTH
HARDWARE
The hardware used for this part of the project was an Arduino UNO and an Adafruit Bluefruit LE SPI Friend. The pinout for these connections is shown below.
Arduino Uno to Bluetooth pinout:
Bluefruit LE SPI Friend | Arduino Uno |
---|---|
SCK | 13 |
MISO | 12 |
MOSI | 11 |
CS | 8 |
IRQ | 7 |
VIN | 5V |
GND | GND |
Each pin on the Bluefruit LE SPI friend and its function is as follows:
- VIN: This is the power supply for the module
- GND: The common/GND pin for power and logic
- SCK: This is the serial clock pin, connected to SCK on the Arduino
- MISO: This is the Master In Slave Out SPI pin
- MOSI: This is the Master Out Slave In SPI pin
- CS: This is the Chip Select SPI pin, which is used to indicate that the SPI device is currently in use
- IRQ: This is the āinterruptā pin that lets the Arduino know when data is available on the device, indicating that a new SPI transaction should be initiated by the Arduino
SOFTWARE
When it came to creating an iOS application that could communicate with the Bluetooth module, we were able to use Bluefruitās official iOS application as a starting point. Their iOS app, Bluefruit LE Connect, can be downloadedĀ hereĀ and the user guide can be foundĀ hereĀ . Fortunately, their application is open source and the repository can be foundĀ hereĀ .
With the source code available, the first step was to create a custom view that the user can access to set timers. This required adding a timer option to the module section once a bluetooth connection is established. The original application has many modules as seen below.
We modified it to only have a timer as seen below:
Note there is no device info because there was no device connected at the time the screenshot was taken, but the device info would show during normal operation.
This modification was made by setting the options to only be a timer as follows. The original code was:
// Data
enum Modules: Int {
case info = 0
case uart
case plotter
case pinIO
case controller
case neopixel
case calibration
case thermalcamera
case imagetransfer
case dfu
}
fileprivate func menuItems() -> [Modules] {
if connectionMode == .multiplePeripherals {
return [.uart, .plotter]
} else if hasUart && hasDfu {
return [.info, .uart, .plotter, .pinIO, .controller, .neopixel, .calibration, .thermalcamera, .imagetransfer, .dfu]
} else if hasUart {
return [.info, .uart, .plotter, .pinIO, .controller, .calibration, .thermalcamera, .imagetransfer]
} else if hasDfu {
return [.info, .dfu]
} else {
return [.info]
}
}
The modified code is:
// Data
enum Modules: Int {
case info = 0
case uart
case plotter
case pinIO
case controller
case neopixel
case calibration
case thermalcamera
case imagetransfer
case dfu
case timer
}
fileprivate func menuItems() -> [Modules] {
return [.timer]
}
Once the user chooses the timer module, they are taken to a custom view that allows them to set, pause, resume, and cancel timers. The view looks as follows:
The time selector (known as a pickerview) is populated with possible timer options as follows:
fileprivate func initPickerData() {
var hours: [String] = []
for hour in 0...23 {
hours.append("\(hour)")
}
var minAndSec: [String] = []
for min in 0...59 {
minAndSec.append("\(min)")
}
pickerData.append(hours)
pickerData.append(minAndSec)
pickerData.append(minAndSec)
}
// MARK:- UIPickerView
extension TimerModeViewController: UIPickerViewDataSource, UIPickerViewDelegate {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return pickerData.count
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return pickerData[component].count
}
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
let title = pickerData[component][row]
return NSAttributedString(string: title, attributes: [NSAttributedString.Key.foregroundColor: UIColor.white])
}
}
The userās selections in the pickerview are stored in temporary variables for future use when the user selects start as follows:
fileprivate var selectedHours = 0
fileprivate var selectedMinutes = 0
fileprivate var selectedSeconds = 0
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
switch component {
case 0:
selectedHours = Int(pickerData[component][row]) ?? 0
case 1:
selectedMinutes = Int(pickerData[component][row]) ?? 0
case 2:
selectedSeconds = Int(pickerData[component][row]) ?? 0
default:
print("Unexpected component selected.")
}
}
Once the user selects start, the view looks as follows:
When the user selects start, the pickerview is replaced with a text label that displays the time remaining on the timer. In addition, the start command is sent to the Arduino to be transmitted to the Big Board. First, the time, minutes, and seconds selected by the user must be converted to seconds and the start message must be created (āsXXeā where āXXā is the time the user selected in seconds). This is all done as follows:
fileprivate func toSecondsFrom(hours: Int, minutes: Int, seconds: Int) -> Int {
return seconds + (minutes * 60) + (hours * 60 * 60)
}
fileprivate func toHoursFrom(seconds: Int) -> Int {
return seconds / 3600
}
fileprivate func toMinutesFrom(seconds: Int) -> Int {
let hrsInSeconds = toHoursFrom(seconds: seconds) * 3600
return (seconds - hrsInSeconds) / 60
}
fileprivate func toSecondsFrom(seconds: Int) -> Int {
let hrsInSeconds = toHoursFrom(seconds: seconds) * 3600
let minInSec = toMinutesFrom(seconds: seconds) * 60
return seconds - hrsInSeconds - minInSec
}
fileprivate func startCommand(with seconds: Int) -> String {
// From iOS App to Dev Board.
// Format: s[0-9]+e
// Format Description: āsā, then 1 or more digits, then āeā
return "s\(seconds)e"
}
var delaySeconds = 1
var delayTimer = Timer()
func createDelayTimer(){
delayTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(TimerModeViewController.delay), userInfo: nil, repeats: true)
}
@objc func delay() {
if delaySeconds == 0 {
delayTimer.invalidate()
delaySeconds = 1
let hrs = String(format: "%02d", selectedHours)
let min = String(format: "%02d", selectedMinutes)
let sec = String(format: "%02d", selectedSeconds)
timeLabel.text = "\(hrs):\(min):\(sec)"
runTimer()
} else {
delaySeconds -= 1
}
}
func runTimer() {
clockTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(TimerModeViewController.countdown), userInfo: nil, repeats: true)
}
@objc func countdown() {
if seconds == 0 {
clockTimer.invalidate()
commandToSend = cancelCommand
status = .notStarted
// update UI
timePickerView.isHidden = false
timeLabel.isHidden = true
startButton.setTitle("START", for: .normal)
cancelButton.isHidden = true
} else {
seconds -= 1
let hrs = String(format: "%02d", toHoursFrom(seconds: seconds))
let min = String(format: "%02d", toMinutesFrom(seconds: seconds))
let sec = String(format: "%02d", toSecondsFrom(seconds: seconds))
timeLabel.text = "\(hrs):\(min):\(sec)"
}
}
@IBAction func onClickStart(_ sender: UIButton) {
switch status {
case .notStarted:
// start timer
let timerSeconds = toSecondsFrom(hours: selectedHours, minutes: selectedMinutes, seconds: selectedSeconds)
commandToSend = startCommand(with: timerSeconds)
status = .started
// update UI
timePickerView.isHidden = true
timeLabel.isHidden = false
cancelButton.isHidden = false
startButton.setTitle("PAUSE", for: .normal)
seconds = timerSeconds
createDelayTimer()
timeLabel.text = "Initializing Timer"
break
...
}
As seen above, the app maintains its own timer in case the status/finished commands are not received from the Big Board. Before starting this timer, a delay is added to allow for the start message to be sent and received.
Now that the timer is started, the app listens for status and finished messages and updates its UI accordingly as follows:
fileprivate func parseCommand() {
// removing invalid characters
cache = cache.replacingOccurrences(of: "\n", with: "", options: NSString.CompareOptions.literal, range: nil)
cache = cache.replacingOccurrences(of: "\r", with: "", options: NSString.CompareOptions.literal, range: nil)
cache = cache.replacingOccurrences(of: "\t", with: "", options: NSString.CompareOptions.literal, range: nil)
// first index of character that marks end of command
var firstEnd = cache.firstIndex(of: "e")
while firstEnd != nil {
// getting next command
let fullCommand = String(cache[...firstEnd!])
let len = fullCommand.length
// getting type of command
let instruction = fullCommand[0]
switch instruction {
case "t":
// status command received
let timer = Int(fullCommand[1..<;len-1]) ?? 0
let hrs = String(format: "%02d", toHoursFrom(seconds: timer))
let min = String(format: "%02d", toMinutesFrom(seconds: timer))
let sec = String(format: "%02d", toSecondsFrom(seconds: timer))
// update timer
timeLabel.text = "\(hrs):\(min):\(sec)"
case "f":
// finished command received
timeLabel.text = "00:00:00"
timeLabel.isHidden = true
timePickerView.isHidden = false
cancelButton.isHidden = true
startButton.setTitle("START", for: .normal)
status = .notStarted
default:
print("Unrecognized command received: \(fullCommand).")
}
// deleting command that has been already processed
cache = cache.substring(fromIndex: len)
// getting next index of character that marks end of command
firstEnd = cache.firstIndex(of: "e")
}
}
The user can pause the timer, which changes the view to look as follows:
// MARK:- UIActions
@IBAction func onClickStart(_ sender: UIButton) {
switch status {
...
case .started:
// pause timer
commandToSend = pauseCommand
clockTimer.invalidate()
status = .paused
// update UI
startButton.setTitle("RESUME", for: .normal)
break
...
}
The user can then resume the timer, which is handled as follows:
// MARK:- UIActions
@IBAction func onClickStart(_ sender: UIButton) {
switch status {
...
case .paused:
// resume timer
commandToSend = resumeCommand
runTimer()
status = .started
// update UI
startButton.setTitle("PAUSE", for: .normal)
break
}
}
The user can cancel the timer at any time, which is handled as follows:
// MARK:- UIActions
@IBAction func onClickCancel(_ sender: UIButton) {
// cancel timer
commandToSend = cancelCommand
clockTimer.invalidate()
status = .notStarted
// update UI
timePickerView.isHidden = false
timeLabel.isHidden = true
startButton.setTitle("START", for: .normal)
cancelButton.isHidden = true
}
In regard to how Bluetooth is set up and how messages are sent and received, the full code can be seen in Appendix B. Ultimately, there is a lot of initialization that needs to be done, but once that is complete, there are several callbacks provided by Adafruit that can be used to send and receive messages.
Sending messages is done as follows:
fileprivate func send(message: String) {
print("send called with message: \(message)")
guard let uartData = self.uartData as? UartPacketManager else { DLog("Error send with invalid uartData class"); return }
if let blePeripheral = blePeripheral { // Single peripheral mode
uartData.send(blePeripheral: blePeripheral, text: message)
} else { // Multiple peripheral mode
let peripherals = BleManager.shared.connectedPeripherals()
if let multiUartSendToPeripheralId = multiUartSendToPeripheralId {
// Send to single peripheral
if let peripheral = peripherals.first(where: {$0.identifier == multiUartSendToPeripheralId}) {
uartData.send(blePeripheral: peripheral, text: message)
}
} else {
// Send to all peripherals
for peripheral in peripherals {
uartData.send(blePeripheral: peripheral, text: message)
}
}
}
}
Receiving messages is done as follows:
// MARK: - UartPacketManagerDelegate
extension TimerModeViewController: UartPacketManagerDelegate {
func onUartPacket(_ packet: UartPacket) {
// Check that the view has been initialized before updating UI
guard isViewLoaded && view.window != nil else { return }
onUartPacketText(packet)
self.enh_throttledReloadData() // it will call self.reloadData without overloading the main thread with calls
}
@objc func reloadData() {
parseCommand()
}
fileprivate func onUartPacketText(_ packet: UartPacket) {
guard packet.mode == .rx else { return }
if let string = stringFromData(packet.data, useHexMode: Preferences.uartIsInHexMode) {
cache += string
}
}
}
In order for this to work, the connection must be established and set up be completed (again, refer to Appendix B for the set up since it is too lengthy and uninteresting to be included here).
RESULTS
We produced a functioning timer that could communicate with a smartphone application. The app could send a time to the PIC32, which would then count down that time and animate grains of sand that would drop periodically in accordance with the time remaining. When the PIC32 finished counting down, it would send a finish command back to the app. In the end, both the app and the PIC32 ran independent timers as not every timer update would reach the application. This resulted in the two timers being a little off (typically Ā± 2 seconds) however, this was more consistent than sending time updates. The grains of sand would drop based off of how much time was remaining and what the timer was set for. A maximum of 30 grains of sand would be animated. In the case that the timer was set for less than 30 seconds, then a number of grains equal to the amount of time in seconds would be animated. Here are videos of our timer in action:
The PIC32 runs six total threads. Two of which are timers (Ā timerĀ andĀ hourglassĀ ), two are used for serial connections (Ā serialĀ andĀ serial_auxĀ ), and the remaining two are for animation and command interpretation. The six threads run concurrently, with the two timer threads yielding a majority of the time before updating values each second.
Our serial thread receives messages typically at a rate of 2 messages per second. Our project utilized two UART instances. One to send and receive data from the Arduino and another to display data on a serial port using PuTTy. This additional UART was used for testing if messages were being properly received and sent, as the messages could be displayed in a PuTTy terminal. Additionally, the Arduino Serial Monitor was used to see the status of messages being passed from the PIC32 to the Arduino, which would then be sent to the phone application.
As seen in the video, our system experiences a lot of flicker upon initialization. We believe this to be the result of bothĀ ball_init()Ā and the animation thread running at the same time, as these are the only places where sand is drawn. We attempted to resolve this issue by adding conditions to theĀ whileĀ loop that the animation thread runs in, but this did not correct the issue. Another issue was that we were unable to send consistent timer updates from the PIC32 to the smartphone application. We were unable to locate where exactly in the chain of connections this issue was, so we were unable to resolve it. As a workaround, both the app and PIC32 kept their own timers. Since there was a delay in the PIC32 starting, a āTimer Initializingā state was added to the smartphone application to get the timers to start at around the same time. When the phone timer ends, it will send a signal to the PIC32 to tell it to stop.
Another key error that we encountered was potential interference from other present devices. Our system is receiving messages constantly. If the application sends a start command, it will continue sending start commands until a new command is selected. This was done in case the PIC32 missed the first start command. One issue we encountered was that, on some occasions, one of the repeated start commands would not reach the PIC32. As a result, the next start command would be seen as a new command and the system would restart. Unfortunately, due to time constraints, we were unable to tackle this issue.
Overall, we were able to achieve the desired basic functionality of our system. The timer properly drops grain of sand, which follow similar physics rules to real sand. The app and PIC32 both run a timer for the desired amount of time and can properly start, resume, pause, cancel, and finish as desired.
CONCLUSIONS
Our project met our expectations for what we had hoped to accomplish. All of the basic and necessary functionality was present. There were additional features we had hoped to implement, but were cut due to time constraints and part issues. We did not intend to use an Arduino as an intermediary between the bluetooth and the PIC32, but needed to do to the complexity of our bluetooth part. We had also planned to use an LED matrix instead of the TFT display, but received a broken matrix and would not have been able to get a replacement by the deadline. Another intended feature was to use an accelerometer to allow users to rotate the hourglass and pause or reset it by placing it on its side or flipping it over respectively. This feature was cut due to ordering an incorrect part and not having the time to try and implement it. If we were to redo this project, we would use the Bluefruit LE UART Friend instead of the SPI version and try a different accelerometer. We would also try to use the small board to make the system less bulky and attempt to enclose it in a 3D printed frame so the system looks neater.
That said, we feel our design meets user expectations for an hourglass and timer app. The smartphone application is similar to other standard timer apps, which makes it easy for users to interface with. The hourglass periodically drops grains of sand, which is the expected behavior of an hourglass. Hourglasses are something most people are familiar with, so they could correctly interpret the dropping of sand as the passing of time, and when the sand runs out, the time will end.
Our project is based off of the āĀ Digital Hourglass Alarm ClockĀ ā designed by students at the Potsdam University of Applied Sciences. While their design focuses more on the usage of an accelerometer and moving the hourglass, we focused on bluetooth and using a smartphone to set the timer.
Our project had few safety concerns, as the largest voltage present was a 5V signal from the Arduino which was lowered to a 3.3V signal to enter into the PIC32. With more time, we would do more to enclose the system and keep the wires hidden. This would help keep users from potentially removing wires or exposing themselves to small voltages.
In conclusion, our project met our expectations for a final design project. We felt that a significant focus of our project was based around the PIC32, which is the aim for this course. While not able to implement all the features that we wanted to, our group prioritized the most important features and succeeded in implementing them.
Source: VIRTUAL HOURGLASS TIMER