-
Notifications
You must be signed in to change notification settings - Fork 10
4 Code Overview
This section explains the software design choices.
The project is built modularly and has the following directory structure:
-
wp_apps/
: Contains the logic and display functions related to the Smartwatch's User Interfaces. -
wp_bt/
: Bluetooth interface of the Smartwatch, which contains the communication protocol and its utility functions. -
wp_common
: Common data structures and functions shared among different modules. -
wp_dev/
: Stores functions and data about the global state and hardware abstractions. -
wp_res/
: Contains the array representations of all fonts, images and miscellaneous items.resources.h
declares helper functions.
The resources directory contains all images and fonts. All images for the user interfaces are developed on Figma. Images are then converted to JPG or PNG image formats and exported.
Exported images had to be converted to byte arrays for our C program.
The Waveshare SDK does not support standard image encodings and accepts
only the following RGB formats: RGB444, RGB565 and RGB666.
After the research, lvgl.io was found suitable to
convert images to the desired formats.
Exported images are then moved under the wp_res
directory.
Originally, each screen was stored individually with a dedicated image for each possible scenario. This approach initially accelerated development but caused the project to hit the storage limit earlier than expected. Pico has 2MB of flash memory. After the competition of the media application, the disk size passed 3MB. Since the compiled binary was too big to fit into the Pico, and the compilation failed.
The images contained a lot of replicated bytes. Initially, the RLE encoding algorithm was found to be fit to solve this problem.
According to the solution, images would be stored and encoded to trim the binary size. Whenever an image is needed, it is decoded and displayed on the screen.
The table displays the disk usage of the source codes under the resources directory before and after the RLE encoding algorithm.
File | Before Encoding | After Encoding |
---|---|---|
alarm.c | 1.2M | 56K |
decompress.c | 8.0K | 4.0K |
font.c | 856K | 104K |
media.c | 2.0M | 144K |
menu_alarm.c | 1016K | 188K |
menu_events.c | 1016K | 184K |
menu_media.c | 1016K | 180K |
menu_pedometer.c | 1016K | 212K |
menu_stopwatch.c | 1016K | 208K |
pedometer.c | 1016K | 80K |
popup_alarm.c | 1016K | 80K |
popup_call.c | 1016K | 84K |
popup_notify.c | 1016K | 104K |
stopwatch.c | 2.0M | 124K |
tray.c | 52K | 52K |
watch.c | 1016K | 4.0K |
Raspberry Pi Pico W contains 264KB of memory. The display buffer and
its backup buffer take up 112.5KB of memory, which leaves just enough space
to encode and decode
a single image. Although it might work in theory due to the memory
fragmentation on the
embedded devices, allocating and freeing arbitrary bytes of memory causes
malloc
to freeze the system altogether.
Memory fragmentation is common in computer systems, including embedded devices like the Raspberry Pico. It refers to the situation where free memory is scattered in small, non-contiguous blocks, making it challenging to allocate large, contiguous chunks of memory. This fragmentation can lead to inefficient memory usage and reduced system performance. Eventually, it may cause the system to run out of memory even when there is technically enough free memory.
Instead, stack memory and static memory allocation are suggested to prevent this issue.
One of the biggest problems in the old design was that, despite removing the duplicate bytes during compile time, the memory usage was still higher when decoded.
The display system migrated to a component-based system UI programming to solve this. In a Component Based UI, the final image consists of various frequently used components, and them being drawn on top of each other.
This approach significantly reduces the memory usage to store the images but costs extra CPU time since images are drawn individually.In old NES (Nintendo Entertainment System) and similar hardware, game developers often used sprite sheet animation to display animated characters and objects efficiently on the screen. The hardware at that time had limited graphical capabilities compared to modern systems, so developers had to be creative and optimise their use of resources.
The character is stored in a single image file in the figure . The image contains multiple sprites with constant width and height. The code iterates over the images to quickly switch the buffer.
Component and sprite sheet-based images reduced the file sizes so that they became compilable again. Furthermore, since the memory is saved to the static memory, it did not cause fragmentation.
The following code block displays how the Smartwatch captures the right image from the sprite sheets for the title bar and menu screen.
typedef struct {
const unsigned char* img;
size_t width;
size_t height;
} Resource;
static inline Resource _get_sprite(int idx, const unsigned char* res,
const int w, const int h)
{
return (Resource){res + 2 * (idx)*w * h, w, h};
}
Resource res_get_titlebar(enum screen_t s_title, enum popup_t p_title)
{
if ((p_title < 0 && p_title >= POPUP_T_SIZE)
|| (s_title < 0 && s_title >= SCREEN_T_SIZE))
return (Resource){NULL, 0, 0};
const int w = 160;
const int h = 30;
const unsigned char* img;
if (p_title == POPUP_NONE)
img = _res_titlebar + 2 * (s_title - 3) * w * h;
else
img = _res_titlebar + 2 * (SCREEN_T_SIZE - 3) * w * h
+ 2 * (p_title - 1) * w * h;
return (Resource){img, w, h};
}
Resource res_get_menu_screen(enum menu_t selected)
{
const int w = 160;
const int h = 160;
if (selected < 0 && selected >= MENU_T_SIZE)
return (Resource){NULL, 0, 0};
return _get_sprite(selected, _res_menu, w, h);
}
The Raspberry Pi Pico's CPU consists of two cores. The cores run independently on a shared memory and communicate over a FIFO queue.
- On Raspberry Pi Pico, when an interrupt occurs, by default, the interrupt handler will run on core 0.
- This creates a problem. When a background task such as a chronometer or a Bluetooth service runs, it may block the user interface or vice versa. To solve this problem, the user interface and all background tasks had to be separated. The user interface has been moved to the second core.
- The core one requires a function with the type of
void (*)(void)
.
static void _core1_cb()
{
apps_load(SCREEN_CLOCK);
}
int main(int argc, char* argv[])
{
stdio_init_all();
os_init();
apps_init();
multicore_launch_core1(_core1_cb);
bt_init();
while (true) {
/* ... */
}
return 0;
}
- The figure displays the interrupts happening to both cores. The core 0 receives most of the interrupts and handles them. Due to the frequency of those interrupts, making a single-core application would cause the application to freeze.
The state is a singleton object that stores the data throughout the watch's lifetime. State fields can be found in the figure . While different cores can independently write to and read from struct fields and their sub-structs, it is essential to note that only one core is responsible for writing a field at a time, ensuring exclusive access to prevent race conditions.
Note: While no hard lock mechanism prevents the race condition, no instance of access may cause such a condition.
The State is a singleton struct that serves as a container for consolidating diverse data about the smartwatch's State and functionalities.
-
It holds a DateTime type that describes the current date and time.
__dt_timer
is used to count using Pico's timer module. Flags such asshow_sec
andis_connected
to manage display preferences and connectivity status.__last_connected
tracks the last time a packet is received via Bluetooth. Thepop_up
and__popup_req
enable the handling of user interface elements. -
The dev sub-structure includes a GPIO pin stack for robust operation, temperature, accelerometer and gyroscope data.
__step_timer
facilitates step tracking. -
Alarms are managed through an array (list) within the
alarms
sub-structure, withlen
denoting the number of alarms present. -
Similarly reminder events are also stored as an array under the
reminder
sub-structure. -
A Chrono sub-structure handles chronometer functionality, while the media sub-structure manages media playback details, including the current song, artist, and playback status.
-
Lastly, a global step count (step) overviews the user's physical activity.
The user interface starts by running the apps_init(void)
function. This
function initialises
a Display struct instance.
typedef struct {
UWORD* buffer;
UDOUBLE buffer_s;
UWORD* canvas_buffer;
bool is_saved;
enum screen_t sstate;
enum disp_t redraw;
int post_time;
repeating_timer_t __post_timer;
} Display;
- The
buffer
holds the memory for the image being displayed. Andbuffer_s
stores it's size. -
canvas_buffer
andis_saved
are for the notepad application. -
sstate
stores the active screen. -
redraw
holds the data about the next redraw call. -
post_time
and__post_timer
are for the timer to automatically trigger post_procesing.
A screen can be initialised using the enum app_status_t apps_load(enum screen_t)
.
Before each screen or pop-up loads, the apps_set_module
function is called,
which
initialises the touch type for the module type.
The Display struct keeps track of the current state at any given time. A function call represents each screen state/application. They are stacked on top of each other using the function stack. Whenever a module function is called, the watch enters a new state; when a state ends, it returns its status. At any given time, the currently running module tracks three things:
- Whether screen's
enum disp_t
is notDISP_SYNC
or not. - Whether the type of the current
sstate
matches with the running function's state. - Whether a pop-up request has been attempted.
If any of the conditions above are true, it recalculates the buffer and calls the drawing. This mechanism prevents unnecessary redraw calls.
A redraw can be either DISP_PARTIAL
or DISP_REDRAW
. The entire buffer is
redrawn on
redraw, while only the text or buttons are partially updated.
A full redraw will be called when a module returns since the current state is not equal to the existing screen.
The sequence diagram displays an example in which the user performs the following actions:
- The smartwatch boots, and the user opens the menu.
- User moves to alarm and clicks to open the Alarm application.
- User exits the Alarm.
- User exits the menu.
When the Display Scheduler agrees on redraw after the frame is set, the tray post-processor runs. This function places the tray icons according to the current state of the Smartwatch struct. If no post_process function is called for more than 30 seconds, the interrupt will trigger it automatically. That way, the clock at the top will be synchronised.
The pedometer is a repeating event that captures the current acceleration and
temperature every
50 milliseconds. The square root of every call is inserted into an array. At
every 20th call,
all elements in the array are analyzed. In our case, the analysis algorithm has
an arbitrary
threshold, which is STEP_THRESHOLD
. Anytime a value in the list passes the
threshold,
a flag is raised until it drops below the threshold again. When it drops, the
flag is lowered.
Then, the number of times the flag is raised is added to today's score.
The code below is the pedometer's algorithm.
static void _step_count_analyze()
{
bool peaked = false;
int peak_count = 0;
for (int i = 0; i < SAMPLE_SIZE; i++) {
if (buffer[i] > STEP_THRESHOLD && !peaked) {
peaked = true;
peak_count++;
} else if (buffer[i] < STEP_THRESHOLD && peaked) {
peaked = false;
}
}
if (peak_count / 2 > 0) { peak_count /= 2;
} else { state.dev.step += peak_count; }
}
static bool _step_count_cb(UNUSED(repeating_timer_t* r))
{
GyroData* data = &state.dev;
_mpu6050_read_raw(data->acc, data->gyro, &data->temp);
data->temp = (data->temp / 340.0) + 36.53;
buffer[cursor++] = sqrt(pow(data->acc[0], 2) +
pow(data->acc[1], 2) + pow(data->acc[2], 2));
if (cursor >= SAMPLE_SIZE) {
_step_count_analyze();
cursor = 0;
}
return true;
}
The Android device and the Smartwatch communicate over Bluetooth.
Bluetooth is a short-range wireless technology standard for exchanging data between fixed and mobile devices over short distances and building personal area networks (PANs). Unlike the client-server model, which is seen in most network-based communication systems, in Bluetooth, both sides can send or receive independent requests.
The Raspberry Pi Pico W has a built-in Bluetooth module implemented by a third-party library called BtStack. The library and Pico W integration are new since the series was released in 2022. Due to the lack of examples and documentation, it's hard to build Bluetooth applications with the built-in library.
The existing examples of the BtStack found in Github are mostly blocking examples, which are not ideal for this project since they rely heavily on interrupts and asynchronous programming.
HC-06 is a Bluetooth slave module generally used with Arduino and the original Raspberry Pi units. Due to the ease of integration, the number of examples made it the ideal choice for the project. In addition to that, since the HC-06 is an external module, the Bluetooth connection can be done without blocking the core.
HC-06 has four pins and uses UART for communication:
HC-06 | Pico W | Description |
---|---|---|
VCC | VSYS | 5V Power supply |
GND | GND | Ground |
RX | TX | Receiver pin hooked up to the TX pin of the Pico |
TX | RX | Transmission pin hooked up to the RX pin of the Pico |
|
Table 2: HC-06 Pico W Connections
To set up the HC-06 module, we need a USB serial adaptor. Then, we can send AT
commands to
configure it. On Linux, the USB devices will be connected to /dev/ttyUSB
.
Running the following shell script will assign the name and pin code to the
HC-06.
#!/bin/bash
SERIAL_PORT="/dev/ttyUSB0"
stty -F $SERIAL_PORT 9600
echo "AT+NAMEWearPico Smartwatch" > "$SERIAL_PORT"
echo "AT+PIN1234" > "$SERIAL_PORT"
The following code initialises the uart0
for the UART protocol.
void bt_init(void)
{
bt = (BtFd) {
.id = uart0, .baud_rate = 9600,
.tx_pin = 0, .rx_pin = 1,
.is_enabled = true,
};
uart_init(bt.id, bt.baud_rate);
gpio_set_function(bt.tx_pin, GPIO_FUNC_UART);
gpio_set_function(bt.rx_pin, GPIO_FUNC_UART);
}
Pico's UART mechanism can store up to 32 bytes of data before processing. Any data received from that point is overflowed.
size_t bt_read(char* str, size_t str_s)
{
if (!bt_is_readable()) return 0;
memset(str, '\0', str_s);
uint i = 0;
size_t remaining;
while ((remaining = uart_is_readable(bt.id)) > 0 && i < str_s)
str[i++] = uart_getc(bt.id);
if (i > 0) state.__last_connected = get_absolute_time();
return i;
}
enum bt_fmt_t bt_receive_req()
{
if (bt.packet_lock) {
return ERROR(READ_PACKET_LOCK);
}
bt.packet_lock = true;
size_t bytes = bt_read(bt.packet, 240);
if (bytes > 0) bt_handle_req(bt.packet, bytes);
bt.packet_lock = false;
return BT_FMT_OK;
}
An interrupt handler version is written to solve this overflow issue.
- Packet and cursor fields are added to the struct. The packet stores a single received packet while the cursor stores the current index.
typedef struct {
uart_inst_t* id;
int baud_rate;
int tx_pin;
int rx_pin;
bool is_enabled;
char packet[240];
uint cursor;
} BtFd;
-
is_str_complete
function checks whether the received data is completed. When completed, every received data from the HC-06 ends with|\r\n\0
. Our protocol always ends with the|
character. So, the following function returns true if the string is completed.
static bool is_str_complete()
{
if (bt.cursor < 4) { return false; }
return bt.packet[bt.cursor] == '\0'
&& bt.packet[bt.cursor - 1] == '\n'
&& bt.packet[bt.cursor - 2] == '\r'
&& bt.packet[bt.cursor - 3] == '|';
}
- The following code block is the UART receive interrupt handler, which reads one byte at a time and increases the cursor until the string is completed. When the string is completed, it handles the received message and resets the bt's buffer and cursor.
void on_uart_rx()
{
size_t remaining = uart_is_readable(bt.id);
if (remaining > 0 && bt.cursor < 240) {
bt.packet[bt.cursor++] = uart_getc(bt.id);
}
if (is_str_complete()) {
bt_handle_req(bt.packet, bt.cursor - 2);
memset(bt.packet, '\0', 240);
bt.cursor = 0;
}
}
void bt_init()
{
/* ... */
irq_set_exclusive_handler(UART0_IRQ, on_uart_rx);
irq_set_enabled(UART0_IRQ, true);
uart_set_irq_enables(bt.id, true, false);
}
This section describes the medium of the embedded system and the Android application. Both systems will communicate through Bluetooth using a serialised common protocol. The requests and responses are denoted with a number followed by their arguments. Protocol uses | character as the separator.
REQUEST_TYPE[int]|ARG_1|ARG_2...|
6|3|2210|1930|0945|0855|
The Smartwatch can receive the following requests:
-
0 - BT_REQ_CALL_BEGIN
: Request declares that the phone is being ringed. The request's payload contains the caller's full name.
0|Name Surname|
-
1 - BT_REQ_CALL_END
: Request declares to stop the phone call pop-up. This request is sent when the phone call is answered on the phone.
1|
-
2 - BT_REQ_NOTIFY
: Request declares that a notification has been received.
2|Title|Description|
-
3 - BT_REQ_REMINDER
: Request declares that a reminder has been received.
3|NumberOfReminders|title|yyyyMMddhhmm...|
-
4 - BT_REQ_OSC
: Request declares that information about the playing media has changed. The second argument is whether the playing media is paused or not.
4|t/f|Song Name|Artist|
-
5 - BT_REQ_FETCH_DATE
: Request provides the information about the current date to update.
5|yyyymmddhhMMss|
-
6 - BT_REQ_FETCH_ALARM
: Request provides the information about alarms. The second argument provides the number of alarms to send up to four. Arguments after that provide the alarm's hour and minute one after the other.
6|NumberOfAlarms|hhMM|hhMM|hhMM...|
-
7- BT_REQ_STEP
: The request requests the daily step count. A response is sent after the arrival of this message.
7|
-
8 - BT_REQ_HB
: The request is sent after not sending any requests for 15 seconds to test the connection's status.
8|
-
9 - BT_REQ_CONFIG
: The request is sent to configure various settings on the watch. Brightness is a 3-digit integer; the rest are flag values defined in the struct.
9|Brightness|Alarm|Call|Notify|Reminder|
The Smartwatch can send responses.
-
0 - BT_RESP_OK
: Request is handled successfully.
0|
-
1 - BT_RESP_ERROR
: An error is occurred during handling.
1|
-
2 - BT_RESP_CALL_OK
: Answer the call.
2|
-
3 - BT_RESP_CALL_CANCEL
: Dismiss the call.
3|
-
4 - BT_RESP_OSC_PREV
: switch to previous song.
4|
-
5 - BT_RESP_OSC_PLAY_PAUSE
: Play/Pause the current song.
5|
-
6 - BT_RESP_OSC_NEXT
: Skip to the next song.
6|
-
7 - BT_RESP_STEP
: Send the current step amount.
7|123|
Wear-Pico An open-source Smartwatch firmware written in C for the Raspberry Pi Pico.
Developed by Umut Sevdi
Smartwatch case is designed and printed by Kozha Akhmet Abdramanov.
- WearPico Source Code: umutsevdi/wear-pico
- WearPico App Source Code: umutsevdi/wear-pico-app
Sponsored by: