We designed a custom laboratory timer using a UNSW COAST board (with a NUCELO-F303RE board). We used an STM32 ARM microcontroller with the HAL C library to accomplish this. There is both a standard clock mode, with an alarm, stopwatch, countdown timer, and date/time, as well as a timer mode that allows for 4 concurrent timers to be run at once.
The project is built for the purposes of biochemistry lab technicians - some example use cases are outlined below. This naturally leads to this mapping of user needs, resulting in the below diagram, where we decide the essential features.
These features can be grouped into the more general standard clock features (Date/time, Alarms, stopwatch, sequential timers), and lab timer features (multiple parallel timers)
Here are the basic steps to get set up and flash the project onto the board.
- Install STM32CubeIDE - you need to sign up to download the installer
- Open the project from your file system
- Import the project
- Press the run button with the USB connected to your computer and board
The M Timer is designed to manage various functionalities such as clocks, alarms, down timers, and lab timers. It integrates multiple peripherals, including an LCD display, buzzer, LEDs, and a keypad.
The system is designed such that time insensitive components are put on the main loop, whilst things that require more precision are done via interrupts. Time insensitive components include the keypad and lcd, and time sensitive components include starting and stopping the stopwatch via switches.
The project's IOC contains several settings to automate the creation of code to initialize the various hardware components such as the RTC, Timer interrupts, PWM and EXTI.
The real time clock is a hardware component on the board that ticks a preset date and time. The purpose of the component is to independently tick a set date and time without requiring CPU cycles. The CPU only interacts with the RTC during retrieving or setting the dates and times.
The RTC hardware is also able to generate an alarm interrupt by setting an alarm. The check on whether the alarm should trigger or not is also done by the RTC component without requiring CPU cycles. The CPU only interacts with the alarm system when setting an alarm or an alarm interrupt was generated.
Timer interrupts are periodically generated (in this software's case, every second and millisecond) to run periodic routines such as ticking timers.
The PWM generator is used solely for the buzzer by providing a square wave signal in which its frequency and duty cycles can be set by the software at will.
External interrupts are used to retrieve inputs for time sensitive tasks. The software mainly uses EXTIs with Switches 1 and 2 to maintain precision in time sensitive functions such as the stopwatch for start/stop and resetting.
Whenever a HAL_UART_Receive_DMA() is called, the UART sleeps until a user input has been detected, in which a UART interrupt is generated. This allows the software to not busy wait for user inputs and run other functions while UART communications are handled in the background.
The diagram offers a quick overview of the board and the components it includes. The blue button seen in the top-middle portion of the board for example, transitions between the lab timer mode and the standard clock mode.
Concerning input validation, take note that apart from UART inputs, the available switches and keypad buttons means that users will not be able to give inputs that hardware buttons aren't linked to in software. Inputs are validated with the following methods
- In the main loop, a keypad input is recorded at the start. Then the focused function is displayed (
displayClock(), displayStopwatch(), displayLabTimers(), etc. ). Inside this display function, the keypad input is checked on whether it corresponds to an action, otherwise the function continues, display the current state, and return to the main loop. - For times and dates, the functions,
isValidTime()andisValidDate()in clock.c, which ensure times are within the 24h time format, and that dates are valid for the Gregorian calendar. - For inputs to the timer, the function
isValidTimerInput()within labTimers.c, is used to check that thenewTimestring is parsed correctly, and that each time field is within a valid range (minutes < 60, seconds < 60, hours within 99)
Below are a few examples of how formatting between strings and integers are handled.
It is preferential to store everything in seconds in the .currTime and configTime fields of Timer-like structs, and convert these into sec/min/hr when required. The only exception to this is rule is the stopwatch where it stores its time in milliseconds. You can see more explanations of this in the library deep dive further down.
Extracting the time from the RTC
void getAlarm(char *time) {
// Get RTC alarm
RTC_AlarmTypeDef currAlarm = {0};
HAL_RTC_GetAlarm(&hrtc, &currAlarm, RTC_ALARM_A, RTC_FORMAT_BIN);
// Extract time fields and format
sprintf(time, "%02d:%02d:%02d %cM",
currAlarm.AlarmTime.Hours,
currAlarm.AlarmTime.Minutes,
currAlarm.AlarmTime.Seconds,
currAlarm.AlarmTime.TimeFormat == RTC_HOURFORMAT12_AM ? 'A' : 'P');
}Parsing a string into respective time variables
// Parse time string to alarm struct
int hour, min, sec;
sscanf(time, "%d:%d:%d", &hour, &min, &sec); // Scanning the time string in
newAlarm.AlarmTime.Hours = (uint8_t) hour;
newAlarm.AlarmTime.Minutes = (uint8_t) min;
newAlarm.AlarmTime.Seconds = (uint8_t) sec;Formatting integers to strings (from getDownTimer)
int hours, minutes, seconds;
int totalSec = countDownTimer.currTime;
hours = totalSec / (60 * 60);
totalSec %= 60 * 60; // Using modulo to breakdown the time properly
minutes = totalSec / 60;
seconds = totalSec % 60;
// Format to string
sprintf(time, "%02d:%02d:%02d", hours, minutes, seconds);Subsequently, diagrams are used here to give you an understanding of how the main loop operates / how each of the subsystem loops work.
Overview diagram
The diagram below is a visual of how the timer queue interacts with the rest of the system.
The diagram below shows the how the timer ticks when a timer interrupt is generated.
This function handles GPIO (General Purpose Input/Output) pin interrupts. Below is an explanation of the key conditions and actions taken:
-
Standard Clock Mode (STDCLOCKMODE)
- SW1_PIN Pressed
- If the alarm is ringing (
alarmStatus == ALARM_TRIGGERED), the alarm is snoozed (snoozeAlarm). - If the countdown timer is ringing (
downTimerStatus == DWN_TRIGGERED), the countdown timer is snoozed (snoozeDownTimer). - If the timer queue is ringing (
TQ_Status == TQ_TRIGGERED), the timer queue is snoozed (snoozeTimerQueue). - If the stopwatch is focused (
currClockFocus == STOPWATCHFOCUS), the stopwatch is started or stopped depending on its current state (setStopwatchMode).
- If the alarm is ringing (
- SW2_PIN Pressed
- If the stopwatch is focused (
currClockFocus == STOPWATCHFOCUS), the stopwatch is reset (resetStopwatch).
- If the stopwatch is focused (
- SW1_PIN Pressed
-
Lab Timer Mode (LABTIMERMODE)
- SW1_PIN Pressed
- If the lab timer is triggered (
timerStateManager.timerState == LBT_TRIGGERED), the lab timer is snoozed (snoozeLabTimer()). All switch presses are ignored for the next four presses (switchesPressed).
- If the lab timer is triggered (
- SW1_PIN Pressed
This function handles timer interrupts. It processes different actions based on whether the interrupt was triggered by htim6 or htim7, two of the timers we use in our program
-
htim6 (Triggers every millisecond) -> stopwatch requires more precision
- Ticks the stopwatch (
tickStopwatch(&watch)). - Ticks the DxLEDs (
DxLED_tick()). - Ticks the buzzer (
buzzer_tick()).
- Ticks the stopwatch (
-
htim7 (Triggers every second) -> other timers require less precision
- Ticks the countdown timer (
tickDownTimer()) which is now deprecated. - Ticks the timer queue (
tickTimerQueue()). - Ticks the lab timer (
tickLabTimer()).
- Ticks the countdown timer (
This function handles RTC (Real-Time Clock) alarm interrupts. When an alarm interrupt occurs, the current mode is temporarily set to STDCLOCKMODE to handle the alarm:
- The current mode is saved (
deviceMode oldMode = currMode). - The mode is set to
STDCLOCKMODE. - The alarm status is updated to indicate the alarm has triggered (
alarmStatus = ALARM_TRIGGERED). - The DxLEDs (D1 to D4) are set to blink (
DxLED_set(DxLED, LED_BLINK)). - The original mode is restored (
currMode = oldMode).- The reason for switching modes is to ensure that when user switched to lab timer mode, DxLED sets the LED to operate only the blinking in the correct mode as it uses the currMode global variable to determine which mode the function call came from.
This callback function is primarily used for capturing user input when the user enters set mode for the sequential timers function. Support for future features involving the UART can be achieved by adding encapsulating the existing timer queue instructions in an if statement, checking that the sequential timer is focused. Else if statements can then be used for control flow to perform the appropriate function's UART logic.
The state diagrams below show the state transitions for both sequential and lab timers, the stopwatch and alarm.
Additionally, the following flow diagrams detail the logic of each function when focused or their interrupt was triggered.
The most important struct in the lab timer library. The 4 lab timers in the library comprise of a static array of 4 of these structs. The code definition for the struct is as follows.
typedef struct {
LabTimerMode mode; // Is the timer in display mode or edit mode?
int currTime; // Time left in seconds
int configTime; // The time configured in seconds
int isLabTimerOn; // Whether the timer is currently running
int newLabTimerHr; // The Hr/Min/Sec during editing
int newLabTimerMin;
int newLabTimerSec;
char labTimerInput[3]; // The two digit input for min/sec/hour when users are editing times
int labTimerDigitSel; // Which digit is selecte currently
ClkEditSelect labEditSelect;
LabTimerState state; // State of the timer - is it running? paused? going off?
char labTimerName[11]; // User defined label for the timer
} LabTimer;Values are initialized to 0 apart from the newLabTimerHr/Min/Sec, which is set to -1, as this value is used to indicate the user left that field untouched.
We use global variables to act as states across subsystems, that are defined in main.h. We need to know the following things.
typedef enum {
STDCLOCKMODE,
LABTIMERMODE
} deviceMode;
extern deviceMode currMode;The deviceMode is the current state of the device - either in the standard clock state or the lab timer mode. We need to know this because alarms that go off in one mode should not go off in another - and neither should accomanying LEDs, Progress bars, or Buzzer alerts
typedef enum {
ALARM_IDLE,
ALARM_TRIGGERED,
ALARM_SNOOZED
} AlarmState;The AlarmState is the current state of the alarm - this is checked in the main loop, and if it is set to triggered, then we set off an alert with the LCD and buzzer, then set the status back to idle. This is a code snippit of the alarm check in the main while loop that specifically checks whether the alarm was set to triggered by an interupt.
if (alarmStatus == ALARM_TRIGGERED) {
lcd_writeStringTrailing("Alarm Triggered!"); // Write to the LCD
lcd_setCursor(1, 0); // Set cursor to next line
lcd_writeStringTrailing("SW1 to snooze"); // Write to LCD again
buzzer_play(262, BUZZER_BEEPING); // Play a beeping noise
// Skip remaining iterations in main loop, ignoring inputs
continue;
// If alarm has been snoozed, clean up LCD
// and put back to idle
} else if (alarmStatus == ALARM_SNOOZED) {
buzzer_mute(); // Mute the alarm
lcd_clear(); // Clear alarm popup on screen
alarmStatus = ALARM_IDLE; // Set the alarm back to an idle state, until user starts it again
}Following this logic, we have states for the downtimer (now deprecated and replaced by the sequential timerss), Sequential Timers, and labTimers.
The LabTimerFocus enum shows which lab timer the user is currently on, and this enum corresponds to the index of the labTimer in the labTimers list (defined in labTimers.c)
All libraries extensively interact with the Keypad and LCD libraries through their functions so we discuss those first. All init() commands are related to setting operating modes for pins & setting their default states.
We provide a 2d array of the keypad layout, defined by KEYPAD_LAYOUT, in order to make code easy to understand. Changing the press delays may cause issues with ghost inputs, so leave at 200ms unless changes are strictly required. Features of library is as follows.
- getKeypadInput() - The only function the library provides, which returns the character that has been pressed by the user. Returns
\0when no key has been pressed.
This function is ran at the beginning of the main loop, to continuously check for user input.
Arguably the most important library as this is the main 'O' part of the user IO. The library is well commented, and it is important to note that the display is set to 4 bit mode, so all commands are sent using only the lower or upper nibbles (4 bits).
The LCD requires a delay of 40ms (we set to 50ms for a safety net) to stabilize - do not remove this from the code.
lcd_sendCmd()- First transmits upper then lower nibble using command
lcd_cmd4
- First transmits upper then lower nibble using command
lcd_cmd4()- Sets up data pins then pulses the enable pin to latch the data
lcd_clear() & lcd_clearLine()- Clears the entire display/current line on LCD
lcd_writechar() & lcd_writeString()- Writes a character or string respectively to the current position of the cursor.
lcd_writeStringTrailing()fills rest of line with trailing space to clear pre-existing text.
- Writes a character or string respectively to the current position of the cursor.
lcd_setCursor() & lcd_toggleCursor()- Sets cursor position and toggles the cursor modes (on/off, blinking or not blinking)
This library is designed to interface with a shift register, enabling control of the 16 LED lights at the bottom of the board. This is used for the progress bar feature.
shiftReg_init()- Initializes the GPIO pins connected to the shift register and sets their initial states. It also clears any previous data by calling
shiftReg_display(0).
- Initializes the GPIO pins connected to the shift register and sets their initial states. It also clears any previous data by calling
shiftReg_display()- Takes a 16-bit number and serially feeds it to the shift register: 0 is off and 1 is on for an LED.
This defines the interface for a clock system, including time management, alarms, countdown timers, and sequential timerss. The code is documented extensively, so only a quick rundown of features will be provided here.
The following structs and enums will be detailed below:
- General date/time
- Alarm
- Sequential timers
- Stopwatch (will be covered in stopwatch.h/stopwatch.c sections)
Note that the countdown timer is deprecated and was replaced by the timer queue.
This is used to set and get the hardware RTC (real time clock)
ClockModeenum- Used to track the current state of the clock, in edit or display mode
ClkEditSelectenum:- Used to manage cursor position when editing the times
setDateTime() & getDateTime():- Sets & gets hardware RTC and parses into 12-hr format with dd-mm-yyyy date format using the following functions:
displayClock()getSelectStr()getDateTimeInput()convert24to12()
- Sets & gets hardware RTC and parses into 12-hr format with dd-mm-yyyy date format using the following functions:
isValidTime()&isValidDate()- Checks for user input validity
When the alarm is triggered, it is caught in the main loop via the alarmStatus variable which is set to the alarm being triggered.
Note that we use the supplied hardware alarm in the real time clock, so we use provided functions/types such as RTC_AlarmTypeDef to create and manage an alarm.
AlarmModeenum: Used to track current state of alarm, in edit or display modedisplayAlarm(): Displays alarm- if '0' is pressed as input then we deactivate/set the alarm again with the HAL_RTC_DeactivateAlarm function. There isn't a set function so we just create the alarm again
- Edit modes are similar to those previously discussed
getAlarmInput()- Creates a new alarm based on user input. We cycle between 1st and 2nd digit for each field (sec/min/hr)
getAlarm()- Creates an alarm given a time in a string - read the 'handling time conversion formatting' section for further details
setAlarm()SnoozeAlarm()- Disables the LEDs using the
Dx_LEDslibrary so they stop flashing, then update the alarm status.
- Disables the LEDs using the
The timer queue runs off an array of TimerQueueNodes, defined as follows
typedef struct {
TimerQueueMode mode; // Either inactive, idle, running, or being edited
int currTime; // Time left in seconds
int configTime; // Configured time in seconds
} TimerQueueNode;displayTimerQueue()- Displays the sequential timer dashboard
- Pressing 'C' again will enter set mode, in which configuring the timers will then be done on via UART communication.
getTimerQueue()- Retrieves the current timer's time in mm:ss format
setTimerQueue()- Given a string of space separated times and the number of new timers, configures the timer queue to the new set of times.
snoozeTimerQueue()- Disables the notification of the timer queue being complete and resets the timer queue triggered signal.
tickTimerQueue()- Ticks the active timer and is placed in the htim7 interrupt that triggers every second.
isValidSeqTime()- Goes through the space separated set of timers to check if the given times are a valid input to use.
On device startup, the timers are all set to inactive at the start. The index of the currently tracked timer is done through the currentTimerIdx variable, which is set to 0 (the first timer in the queue) at startup. The standard clock mode flow's timer queue flow diagram to understand how the timer queue provides a visualisation of the control logic of the timers. The diagram below presents an example of a timer queue of 3, and the timer queue states each timer goes through.
This library simply lists the pin connections of each I/O and hardware component. It is used as a convenient library to quickly reference the appropriate ports and pins without having to look at the board's pin connections data sheet.
This library is easily open to expandability for future user configuration settings. Its current purpose is to provide a simple interface for giving the lab timers custom names.
// CONFIGURE LAB TIMER NAMES
// A timer name must not exceed this limit
// If the character limit is exceeded or an empty string is given
// a default name will be used instead
#define LABTIMERNAME_MAX 10
#define LABTIMERNAME_1 "Custom 1"
#define LABTIMERNAME_2 "Enzyme 2"
#define LABTIMERNAME_3 "ElectroPho"
#define LABTIMERNAME_4 "12345678910"The buzzer library is a conveniently simplified interface to abstract the complexities of PWM signal generation as well as computing sound frequencies to its corresponding ARR and PSC values.
buzzer_play(int freq, BuzzerMode mode)- Sets the buzzer to play at the given frequency and whether it should buzz in beeps or continuously.
buzzer_mute() / buzzer_unmute()- Sets the buzzer to stop/resume playing sounds
buzzer_tick()- Used in timer interrupts to tick the buzzer's internal counter to track the durations of its beeps.
The LEDs library is used for LEDs D1 - D4 and is able to also detect which mode is currently being run to appropriately display the LEDs when needed. E.g. If the LEDs were set to blinking in clock mode, they won't blink in lab timer mode.
-
DxLED_set(DxSel selected, DxLEDMode newMode)- Sets an LED (DxSel selected) to operate in a new mode, such as on, flashing and off.
- It will set the given LED to the new mode for the current operating mode. E.g. If function was called during lab timer mode, the LED's mode will only display when in lab timer mode, not clock mode.
-
DxLED_tick()- Ticks the internal counters of each LED to track the durations of its blinks.
This library contains all functions relating to the lab timer mode. The code takes on a nearly identical approach to the countdown timer (deprecated) and sequential timer.
setTimerModeFocus()- Checks if the user has pressed any of the keys 'A'-'D' and switches focus to the other lab timer if necessary.
getLabTimerInput()- Used during set mode to configure the time fields with the new time the user gave.
getLabTimer()- Retrieves the lab timer's elapsed time in hh:mm:ss format
setLabTimer()- Sets the given lab timer to the new time given in the string.
snoozeLabTimer()- Stops the notifications displaying that the lab timer triggered and resets any lab timer triggered signals.
tickLabTimer()- Used in htim7 interrupts to tick the lab timers every second.
isValidTimerInput()- Checks that the given new time string is a valid input.
getProgressVal()- A helper function for the progress bar feature. Used to calculate a 16-bit value of 1's and 0's that would illustrate the progress of the timer. E.g. 0x00FF would indicate the timer is 50% complete.
The stopwatch library is used to operate the stopwatch and is responsible for displaying, starting, stopping and resetting the function.
newStopwatch()- Used during initialisation phase to setup a stopwatch instance.
setStopwatchMode()- Used to set the stopwatch to either be running or idle.
readStopwatchMode()- Used to get the mode the stopwatch is currently at.
tickStopwatch()- Used in htim6 and ticks the stopwatch every millisecond.
resetStopwatch()- Resets the stopwatch's elapsed time back to 0 and sets it to idle mode.
readStopwatch()- Returns the time elapsed in milliseconds.
formatStopwatchTime()- Writes to the given string the stopwatch's elapsed in in the format hh:mm:ss.sss
displayStopWatch()- Displays the stopwatch dashboard on the LCD display.
This project was created from a 3-person team using UNSW CSE gitlab for version control. These are the steps for contributing changes
- Clone the repository to your local disk from gitlab
- Create a new branch from the current main branch
- Commit your changes and ensure they work via technical validation, before committing them with a descriptive and short commit message.
- Push the commits to the branch
- Submit a merge request to main if new feature is complete.














