Skip to content

4 Code Overview

Umut Sevdi edited this page Jan 30, 2024 · 5 revisions

This section explains the software design choices.

Folder Structure

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.

Resources

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.

Waveshare Image Format

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.

Storage Limitations

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.

RLE Encoding

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

Memory Limit

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.

Component(Sprite sheet) Based Design

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.

Sprite sheet Based Design

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.

Final Image Display

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);
}

Event Scheduling

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

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 as show_sec and is_connected to manage display preferences and connectivity status. __last_connected tracks the last time a packet is received via Bluetooth. The pop_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, with len 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

The user interface starts by running the apps_init(void) function. This function initialises a Display struct instance.

The Buffer

    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. And buffer_s stores it's size.
  • canvas_buffer and is_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 Scheduler

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 not DISP_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.

Tray Processor

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

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;
}

Communication

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.

Initial Approach

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.

Introduction of HC-06

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);
}

Synchronous Version

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;
}

Interrupt Handler Version

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);
    }

The Protocol

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|

Requests

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|

Responses

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|