14. Interface and Application Programming
This week's assignment was to write an application for a embedded board that interfaces a user with an input and/or output device. I decided to build a pill dispenser desktop app that communicates over Serial with a XIAO ESP32-C6. The app shows the real time read from a DS3231 RTC directly on the screen, letting the user set an alarm with a specific time and choose which days of the week it should trigger. And when that alarm fires it activates a passive buzzer and a servo motor on the board to physically dispense a pill. I built the interface in Qt Designer and wrote the logic in Python using PySerial for the Serial connection.
The general workflow this week was:
- Learning the basics of Qt Designer and how to connect it to Python code.
- Setting up a Python virtual environment with the required libraries.
- Designing a custom serial communication protocol between the app and the XIAO ESP32-C6.
- Writing backend.py with a serial worker thread and all the UI logic.
- Writing the XIAO ESP32-C6 firmware to parse commands, drive the servo, buzzer, and OLED.
- Connecting all the hardware and testing the full system end-to-end.
Key Concepts
What is Qt Designer?
Qt Designer is a visual tool for building graphical user interfaces (GUIs) without writing layout code by hand. You drag and drop widgets (buttons, labels, line edits, checkboxes, etc.) onto a canvas and arrange them visually. When you save, Qt Designer generates a .ui file, which is an XML representation of the interface. This .ui file can then be loaded in Python to create the actual GUI at runtime, allowing you to iterate on the design quickly without touching the code until you are happy with the layout and look.
The main panels I used were the Widget Box, the Object Inspector, and the Property Editor. The most important property for connecting the interface to Python is objectName, because this is the name the backend code uses to reference each widget.
The main widgets I used were: Vertical and Horizontal Layout, Label, Group Box, Press Button, CheckBox and Time Edit
Step 1: Create the Main Window
I started by opening Qt Designer and creating a Main Window project. This gave me the base canvas where I could place the pill dispenser interface elements and organize the layout visually.
Step 2: Add the Main Widgets
From the Widget Box, I dragged the interface elements into the window: labels for the current time and status, a time picker for the alarm, checkboxes for the days of the week, a port selector, and buttons for connecting, setting the alarm, and confirming the pill was taken.
Step 3:Rename the Object Names
After placing the widgets, I renamed their objectName values in the Property Editor. This is important because the Python backend later calls widgets by name, such as self.portSelector, self.statusLabel, or self.btnPastilla.
Step 4: Adjust Text, Size, and Layout
I edited the visible text, font sizes, spacing, and alignment from the Property Editor. This helped make the interface readable and clear, especially for the time display, alarm controls, and user feedback messages.
Step 5: Apply the StyleSheet
To style the whole app, I selected MainWindow in the Object Inspector, opened the styleSheet property, and wrote custom CSS-like rules for the window background, labels, inputs, and buttons.
This will create a general CSS, however you can override specific styles for individual widgets.
Step 6: Save the .ui File
Finally, I saved the interface as pastillero.ui. This file stores the complete visual design and is later loaded by the Python backend, so I can keep changing the interface in Qt Designer without rewriting the Python layout code.
What is a Python Virtual Environment?
A virtual environment is an isolated Python installation that lives inside the project folder. Instead of installing libraries globally, the required packages are installed only inside this environment, which helps avoid version conflicts between different projects.
For this interface, I used a virtual environment to install the libraries needed by the pill dispenser app: PyQt6 for the graphical interface and pyserial for communication with the XIAO ESP32-C6 through the serial port.
Once the environment is active, the terminal shows (venv) at the beginning of the prompt. This confirms that any library installed with pip will stay inside the project environment.
How to create a Python Virtual Environment.
1: Create and select the Project Folder
First, select the folder where the Python interface code would be located. In here we are going to keep the code, the .ui file, and the virtual environment. Then inside the folder, type cmd in the File Explorer address bar. This opens the terminal directly in that folder, so the following commands run in the correct project path.
Step 2: Create the Virtual Environment
Once the terminal was open in the project folder, I created the environment with the following command:
python -m venv venv
This creates a new folder called venv, which contains the isolated Python installation for the project. For this is important you already have Python installed globally on your system so the command can find the Python executable to copy into the venv folder.
Step 3:Activate the Environment
To activate it on Windows, I used:
venv\Scripts\activate
When activation works, (venv) appears at the beginning of the terminal line.
Step 4: Install PyQt6
With the environment active, I installed the libraries needed for the app. PyQt6 allows Python to run the interface designed in Qt Designer,
pip install PyQt6
Step 5: Save Dependencies with pip freeze
After installing PyQt6, I ran pip freeze to see exactly what was installed
in the virtual environment, including all sub-packages that PyQt6 pulled in automatically.
pip freeze
This printed: PyQt6, PyQt6-Qt6, PyQt6-sip. After verifying that everything looked correct, I saved that list into a file so anyone else can install the exact same dependencies with one command:
pip freeze > requirements.txt
The > symbol redirects the output directly into the file instead of
printing it to the terminal. This creates requirements.txt
automatically.
Step 6: Install pyserial
I then installed pyserial separately, which allows Python to communicate with the XIAO ESP32-C6 through the serial port:
pip install pyserial
Note: I did not run pip freeze again after this step. The
requirements.txt already existed but to keep it updated you would run
pip freeze > requirements.txt one more time after all installs are done.
Always save it last, after everything is installed.
Step 7: Open VS Code
With all dependencies installed, I opened Visual Studio Code directly from the terminal in the project folder:
code .
The . means "open here". It launches VS Code with the current folder as the project. From this point on, all the code was written inside VS Code with the virtual environment already active.
Step 8: Generate frontend.py from the .ui file
To turn the .ui file into Python code, I ran this command in the terminal:
pyuic6 -x Interfaz_1.ui -o frontend.py
pyuic6 reads the XML and generates a Python class that builds the exact same interface in code. The -x flag adds a small test block so you can run it directly to preview the window. The -o flag sets the output filename. So change the name of the .ui file and the output .py file as needed for your project.
frontend.py is auto-generated so any change you make to it
will be lost the next time you run pyuic6. All visual changes go through
Qt Designer, and all logic goes into backend.py. Think of frontend.py as
a compiled file, not a source file.
Step 11: Create backend.py
backend.py is where all the logic lives. It imports the generated UI class
from frontend.py and wires everything together (serial communication,
button actions, and data processing).
from frontend import Ui_MainWindow
The main window class inherits from both QMainWindow and the UI class, so it has access to all the widgets defined in Qt Designer as attributes. For example, if you have a button with objectName "btn_conectar", you can reference it in backend.py as self.btn_conectar. You run the app with:
python backend.py
Step 12: Updating the UI
Every time you make a visual change in Qt Designer and save the .ui file,
you need to regenerate frontend.py before the changes appear in the app.
The full update cycle is:
# 1. Make changes in Qt Designer and save the .ui file
# 2. Regenerate frontend.py
pyuic6 -x pastillero.ui -o frontend.py
# 3. Run the app
python backend.py
# If you opened a new terminal window, make sure to activate the virtual environment first with:
venv\Scripts\activate
If you skip step 2, the app will still show the old interface because
frontend.py was not updated.
The Custom Serial Protocol
Serial communication is just text going back and forth over a USB cable, but both
sides need to agree on a format. I designed a simple protocol where every message
is a plain text string ending with a newline \n. This makes it easy
to parse on both sides: Python uses readline() and Arduino uses
readStringUntil('\n').
XIAO ESP32-C6 to PC: TIME message
Every second the XIAO ESP32-C6 reads the RTC and sends the current time to the app. The Python backend receives this line, strips the prefix, and updates the clock label on screen:
# Arduino sends:
Serial.println("TIME,14:32:07");
# Python receives and parses:
def _procesarDato(self, linea: str):
if linea.startswith("TIME,"):
self.ui.lbl_hora_rtc.setText(linea[5:])
linea[5:] slices the string starting at index 5, skipping the
TIME, prefix and leaving just HH:MM:SS.
PC to XIAO ESP32-C6: SET_ALARM message
When the user clicks Set Alarm, Python reads the time picker and the seven day checkboxes and assembles the command string:
# Python builds and sends:
hora = self.ui.time_alarma.time().toString("HH:mm")
dias = [
self.ui.cb_dom.isChecked(), # Sunday
self.ui.cb_lun.isChecked(), # Monday
...
]
bits = "".join("1" if d else "0" for d in dias)
self._worker.send(f"SET_ALARM,{hora},{bits}")
# Example result: "SET_ALARM,08:00,1011010"
# Arduino parses:
void parsearAlarma(String msg) {
int c1 = msg.indexOf(',');
int c2 = msg.indexOf(',', c1 + 1);
String tiempo = msg.substring(c1+1, c2); // "08:00"
String dias = msg.substring(c2 + 1); // "1011010"
alarma.hora = tiempo.substring(0,2).toInt();
alarma.minuto = tiempo.substring(3).toInt();
for (int i = 0; i < 7; i++)
alarma.dias[i] = (dias[i] == '1');
alarma.activa = true;
}
PC to XIAO ESP32-C6: PILL_TAKEN
When the user clicks the Pill Taken button, Python sends a single keyword.
Arduino checks for it in leerSerial() and triggers the
confirmation cycle:
# Python sends:
def _confirmar_pastilla(self):
if self._worker:
self._worker.send("PILL_TAKEN")
# Arduino receives:
void leerSerial() {
if (Serial.available()) {
String line = Serial.readStringUntil('\n');
line.trim();
if (line == "PILL_TAKEN") {
confirmarPastilla();
}
}
}
XIAO ESP32-C6 to PC: ALARM_TRIGGERED and PILL_CONFIRMED
When the alarm fires, Arduino sends ALARM_TRIGGERED so the app
can show a notification. After the pill is dispensed, it sends
PILL_CONFIRMED so the app knows the cycle is complete:
# Arduino sends:
void verificarAlarma(DateTime now) {
if (alarma.dias[dow] && now.hour() == alarma.hora
&& now.minute() == alarma.minuto
&& now.second() == 0) {
tocarAlarma();
Serial.println("ALARM_TRIGGERED");
}
}
void confirmarPastilla() {
activarDispensador();
Serial.println("PILL_CONFIRMED");
}
# Python reacts:
elif linea == "ALARM_TRIGGERED":
self.ui.label_4.setText("⚠️ TAKE YOUR PILL!")
elif linea == "PILL_CONFIRMED":
self.ui.label_4.setText("✅ Pill taken!")
QTimer.singleShot(5000,
lambda: self.ui.label_4.setText("Connected"))
The code in this project was developed iteratively with the help of Claude (Anthropic) not by asking for a finished result, but by sharing my existing sketches, explaining my hardware, and correcting and adapting the code step by step as the project evolved. Prompt example: I am building a pill dispenser for Fab Academy using a XIAO ESP32-C6, an OLED, an RTC DS3231, a servo, and a passive buzzer. I already have separate sketches for each component. Help me merge them into a unified firmware and build a PyQt6 desktop app that communicates with the XIAO ESP32-C6 over Serial. I will share my existing code and .ui file as we go so you can correct and adapt it to my actual setup instead of generating something generic.
Backend Code
This is the Python side of the project. It loads the interface generated from Qt Designer,
opens the Serial connection, listens for messages from the XIAO ESP32-C6, and sends commands when
the user sets an alarm or confirms that the pill was taken. In this section I will share the full code of backend.py and slides explaining the most important parts.
Full backend.py Code
"""
backend.py — Pill Dispenser MJ
Estilo profesor Raf: backend es el punto de entrada, importa el frontend generado.
Corre con: python backend.py
"""
import sys
import serial
import serial.tools.list_ports
from PyQt6 import QtWidgets
from PyQt6.QtCore import QThread, pyqtSignal, QTimer
from frontend import Ui_MainWindow # generado con pyuic6
# Hilo lector de Serial
class SerialWorker(QThread):
linea_recibida = pyqtSignal(str)
def __init__(self, puerto: str, baudrate: int = 115200):
super().__init__()
self._puerto = puerto
self._baudrate = baudrate
self._serial = None
self._corriendo = True
def run(self):
try:
self._serial = serial.Serial(self._puerto, self._baudrate, timeout=1)
while self._corriendo:
if self._serial.in_waiting:
linea = self._serial.readline().decode("utf-8", errors="ignore").strip()
if linea:
self.linea_recibida.emit(linea)
except Exception as e:
self.linea_recibida.emit(f"ERROR,{e}")
def send(self, comando: str):
if self._serial and self._serial.is_open:
self._serial.write((comando + "\n").encode())
def detener(self):
self._corriendo = False
if self._serial and self._serial.is_open:
self._serial.close()
self.quit()
self.wait()
# Backend lógica principal
class Backend(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
# Cargar UI generada por pyuic6
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self._worker: SerialWorker | None = None
# Poblar puertos
self._actualizar_puertos()
self._timer_puertos = QTimer()
self._timer_puertos.timeout.connect(self._actualizar_puertos)
self._timer_puertos.start(3000)
# Conectar botones
self.ui.btn_conectar.clicked.connect(self._toggle_conexion)
self.ui.Set_Alarm.clicked.connect(self._enviar_alarma)
self.ui.btn_confirmar.clicked.connect(self._confirmar_pastilla)
# Estado inicial
self.ui.btn_confirmar.setEnabled(False)
# Puertos
def _actualizar_puertos(self):
puertos = [p.device for p in serial.tools.list_ports.comports()]
actual = self.ui.combo_puerto.currentText()
self.ui.combo_puerto.clear()
self.ui.combo_puerto.addItems(puertos)
idx = self.ui.combo_puerto.findText(actual)
if idx >= 0:
self.ui.combo_puerto.setCurrentIndex(idx)
# Conectar / Desconectar XIAO ESP32-C6
def _toggle_conexion(self):
if self._worker:
self._worker.detener()
self._worker = None
self.ui.btn_conectar.setText("Connect")
self.ui.label_4.setText("Disconnected")
self.ui.btn_confirmar.setEnabled(False)
else:
puerto = self.ui.combo_puerto.currentText()
if not puerto:
QtWidgets.QMessageBox.warning(self, "No port", "Select a serial port first.")
return
self._worker = SerialWorker(puerto)
self._worker.linea_recibida.connect(self._procesarDato)
self._worker.start()
self.ui.btn_conectar.setText("Disconnect")
self.ui.label_4.setText(f"Connected to {puerto}")
self.ui.btn_confirmar.setEnabled(True)
# Procesar datos del XIAO ESP32-C6
def _procesarDato(self, linea: str):
if linea.startswith("TIME,"):
self.ui.lbl_hora_rtc.setText(linea[5:])
elif linea == "ALARM_TRIGGERED":
self.ui.label_4.setText("⚠️ TAKE YOUR PILL!")
msg = QtWidgets.QMessageBox(self)
msg.setWindowTitle("Pill Reminder")
msg.setText("Time to take your pill!\nPress 'Pill Taken' when done.")
msg.setIcon(QtWidgets.QMessageBox.Icon.Information) # PyQt6: usar .Icon.
msg.show()
elif linea == "PILL_CONFIRMED":
self.ui.label_4.setText("✅ Pill taken!")
QTimer.singleShot(5000, lambda: self.ui.label_4.setText("Connected"))
elif linea.startswith("ERROR,"):
self.ui.label_4.setText(linea)
# Enviar alarma
def _enviar_alarma(self):
if not self._worker:
QtWidgets.QMessageBox.information(self, "Not connected",
"Connect to the XIAO ESP32-C6 first.")
return
hora = self.ui.time_alarma.time().toString("HH:mm")
dias = [
self.ui.cb_dom.isChecked(),
self.ui.cb_lun.isChecked(),
self.ui.cb_mar.isChecked(),
self.ui.cb_mier.isChecked(),
self.ui.cb_jue.isChecked(),
self.ui.cb_vie.isChecked(),
self.ui.cb_sab.isChecked(),
]
bits = "".join("1" if d else "0" for d in dias)
self._worker.send(f"SET_ALARM,{hora},{bits}")
QtWidgets.QMessageBox.information(self, "Alarm sent",
f"Alarm set for {hora}.")
# Confirmar pastilla
def _confirmar_pastilla(self):
if self._worker:
self._worker.send("PILL_TAKEN")
# Cerrar limpiamente
def closeEvent(self, event):
if self._worker:
self._worker.detener()
event.accept()
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
win = Backend()
win.show()
sys.exit(app.exec())
Libraries and Generated Interface
The backend imports the system module, pyserial, Qt widgets, Qt thread/timer tools,
and the Ui_MainWindow class generated from Qt Designer. This separates the
interface layout from the logic that controls the project.
import sys
import serial
import serial.tools.list_ports
from PyQt6 import QtWidgets
from PyQt6.QtCore import QThread, pyqtSignal, QTimer
from frontend import Ui_MainWindow # generado con pyuic6
SerialWorker Thread
Serial reading waits for data from the XIAO ESP32-C6, so it runs in a separate
QThread. When a line arrives, the worker emits linea_recibida
so the main interface can react without freezing.
# Hilo lector de Serial
class SerialWorker(QThread):
linea_recibida = pyqtSignal(str)
def __init__(self, puerto: str, baudrate: int = 115200):
super().__init__()
self._puerto = puerto
self._baudrate = baudrate
self._serial = None
self._corriendo = True
def run(self):
try:
self._serial = serial.Serial(self._puerto, self._baudrate, timeout=1)
while self._corriendo:
if self._serial.in_waiting:
linea = self._serial.readline().decode("utf-8", errors="ignore").strip()
if linea:
self.linea_recibida.emit(linea)
except Exception as e:
self.linea_recibida.emit(f"ERROR,{e}")
def send(self, comando: str):
if self._serial and self._serial.is_open:
self._serial.write((comando + "\n").encode())
def detener(self):
self._corriendo = False
if self._serial and self._serial.is_open:
self._serial.close()
self.quit()
self.wait()
Main Backend Setup
The Backend class creates the window, loads the generated UI, starts with no
active Serial worker, refreshes the COM port list every three seconds, connects the
buttons to their functions, and disables the confirmation button at startup.
# Backend lógica principal
class Backend(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
# Cargar UI generada por pyuic6
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self._worker: SerialWorker | None = None
# Poblar puertos
self._actualizar_puertos()
self._timer_puertos = QTimer()
self._timer_puertos.timeout.connect(self._actualizar_puertos)
self._timer_puertos.start(3000)
# Conectar botones
self.ui.btn_conectar.clicked.connect(self._toggle_conexion)
self.ui.Set_Alarm.clicked.connect(self._enviar_alarma)
self.ui.btn_confirmar.clicked.connect(self._confirmar_pastilla)
# Estado inicial
self.ui.btn_confirmar.setEnabled(False)
COM Port List
_actualizar_puertos() scans the available Serial ports and fills the combo box.
It also tries to keep the previously selected port selected, which makes the interface
feel more stable while devices are connected or disconnected.
# Puertos
def _actualizar_puertos(self):
puertos = [p.device for p in serial.tools.list_ports.comports()]
actual = self.ui.combo_puerto.currentText()
self.ui.combo_puerto.clear()
self.ui.combo_puerto.addItems(puertos)
idx = self.ui.combo_puerto.findText(actual)
if idx >= 0:
self.ui.combo_puerto.setCurrentIndex(idx)
Connect / Disconnect
_toggle_conexion() works as a switch. If the app is connected, it stops the
worker and releases the port. If it is disconnected, it creates a worker for the selected
port, connects the received-line signal, starts the thread, and updates the interface.
# Conectar / Desconectar XIAO ESP32-C6
def _toggle_conexion(self):
if self._worker:
self._worker.detener()
self._worker = None
self.ui.btn_conectar.setText("Connect")
self.ui.label_4.setText("Disconnected")
self.ui.btn_confirmar.setEnabled(False)
else:
puerto = self.ui.combo_puerto.currentText()
if not puerto:
QtWidgets.QMessageBox.warning(self, "No port", "Select a serial port first.")
return
self._worker = SerialWorker(puerto)
self._worker.linea_recibida.connect(self._procesarDato)
self._worker.start()
self.ui.btn_conectar.setText("Disconnect")
self.ui.label_4.setText(f"Connected to {puerto}")
self.ui.btn_confirmar.setEnabled(True)
Messages from XIAO ESP32-C6
_procesarDato() reacts to each Serial message. TIME updates the RTC
label, ALARM_TRIGGERED opens the reminder dialog, PILL_CONFIRMED
confirms the pill was taken, and ERROR displays connection errors.
# Procesar datos del XIAO ESP32-C6
def _procesarDato(self, linea: str):
if linea.startswith("TIME,"):
self.ui.lbl_hora_rtc.setText(linea[5:])
elif linea == "ALARM_TRIGGERED":
self.ui.label_4.setText("⚠️ TAKE YOUR PILL!")
msg = QtWidgets.QMessageBox(self)
msg.setWindowTitle("Pill Reminder")
msg.setText("Time to take your pill!\nPress 'Pill Taken' when done.")
msg.setIcon(QtWidgets.QMessageBox.Icon.Information) # PyQt6: usar .Icon.
msg.show()
elif linea == "PILL_CONFIRMED":
self.ui.label_4.setText("✅ Pill taken!")
QTimer.singleShot(5000, lambda: self.ui.label_4.setText("Connected"))
elif linea.startswith("ERROR,"):
self.ui.label_4.setText(linea)
Sending the Alarm
_enviar_alarma() checks that the XIAO ESP32-C6 is connected, reads the time from the
interface, converts the seven day checkboxes into a binary day string, and sends the
command SET_ALARM,HH:MM,DDDDDDD to the firmware.
# Enviar alarma
def _enviar_alarma(self):
if not self._worker:
QtWidgets.QMessageBox.information(self, "Not connected",
"Connect to the XIAO ESP32-C6 first.")
return
hora = self.ui.time_alarma.time().toString("HH:mm")
dias = [
self.ui.cb_dom.isChecked(),
self.ui.cb_lun.isChecked(),
self.ui.cb_mar.isChecked(),
self.ui.cb_mier.isChecked(),
self.ui.cb_jue.isChecked(),
self.ui.cb_vie.isChecked(),
self.ui.cb_sab.isChecked(),
]
bits = "".join("1" if d else "0" for d in dias)
self._worker.send(f"SET_ALARM,{hora},{bits}")
QtWidgets.QMessageBox.information(self, "Alarm sent",
f"Alarm set for {hora}.")
Confirm and Close
_confirmar_pastilla() sends PILL_TAKEN when the user confirms in
the app. closeEvent() stops the worker before closing, so the COM port is not
left locked. The final block starts the Qt application.
# Confirmar pastilla
def _confirmar_pastilla(self):
if self._worker:
self._worker.send("PILL_TAKEN")
# Cerrar limpiamente
def closeEvent(self, event):
if self._worker:
self._worker.detener()
event.accept()
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
win = Backend()
win.show()
sys.exit(app.exec())
XIAO ESP32-C6 Firmware
This is the microcontroller side. The XIAO ESP32-C6 reads the RTC, updates the OLED, checks the alarm, moves the servo, plays the buzzer, reads the physical button, and communicates with Python through the Serial protocol.
Full pastillero_XIAO ESP32-C6.ino Code
/*
Pill Dispenser MJ — XIAO ESP32-C6 Firmware
Week: Interface and Application Programming
Hardware:
- OLED SH1106 I2C: SDA=D0 (GPIO0), SCL=D1 (GPIO1)
- RTC DS3231 mismo bus I2C
- Servo D10
- Buzzer pasivo D9
- Botón D2 (confirmar pastilla tomada)
Protocolo Serial (115200 baud):
PC to XIAO ESP32-C6: "SET_ALARM,HH:MM,SMTWRFS\n" (días: 1=activo, posición = Dom,Lun,Mar,Mier,Jue,Vie,Sab)
PC to XIAO ESP32-C6: "PILL_TAKEN\n"
XIAO ESP32-C6 to PC: "TIME,HH:MM:SS\n" (cada segundo)
XIAO ESP32-C6 to PC: "ALARM_TRIGGERED\n" (cuando suena)
XIAO ESP32-C6 to PC: "PILL_CONFIRMED\n" (cuando se presiona botón o llega PILL_TAKEN)
*/
#include <Wire.h>
#include <RTClib.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <ESP32Servo.h>
// Pines
#define PIN_SERVO D10
#define PIN_BUZZER D9
#define PIN_BOTON D2
// OLED SH1106
#define I2C_ADDR_OLED 0x3C
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SH1106G display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
// RTC
RTC_DS3231 rtc;
// Servo
Servo servo;
// Estado de la alarma
struct Alarma {
int hora = -1;
int minuto = -1;
bool dias[7] = {false}; // [Dom, Lun, Mar, Mier, Jue, Vie, Sab]
bool activa = false;
} alarma;
bool alarmaDisparada = false; // está sonando ahora?
bool esperandoPastilla = false; // esperando confirmación?
unsigned long tiempoAlarma = 0; // cuándo empezó
unsigned long ultimaHora = 0; // para enviar TIME cada segundo
// Melodía de alarma
void tocarAlarma() {
// Tres pitidos ascendentes
tone(PIN_BUZZER, 523, 200); delay(250);
tone(PIN_BUZZER, 659, 200); delay(250);
tone(PIN_BUZZER, 784, 400); delay(500);
}
void detenerAlarma() {
noTone(PIN_BUZZER);
servo.write(0);
}
void activarDispensador() {
servo.write(90); // gira para dispensar
tocarAlarma();
alarmaDisparada = true;
esperandoPastilla = true;
tiempoAlarma = millis();
Serial.println("ALARM_TRIGGERED");
}
// Confirmar pastilla tomada ─
void confirmarPastilla() {
detenerAlarma();
alarmaDisparada = false;
esperandoPastilla = false;
tone(PIN_BUZZER, 1047, 200); // pitido de confirmación (Do alto)
delay(250);
Serial.println("PILL_CONFIRMED");
}
// Dibujar OLED ─
void actualizarOLED(DateTime& ahora) {
display.clearDisplay();
// Título
display.setTextSize(1);
display.setCursor(20, 0);
display.println("CURRENT TIME");
display.drawFastHLine(0, 10, 128, SH110X_WHITE);
// Hora grande
display.setTextSize(2);
display.setCursor(15, 20);
if (ahora.hour() < 10) display.print('0');
display.print(ahora.hour()); display.print(':');
if (ahora.minute() < 10) display.print('0');
display.print(ahora.minute()); display.print(':');
if (ahora.second() < 10) display.print('0');
display.print(ahora.second());
// Alarma configurada
display.setTextSize(1);
display.setCursor(0, 44);
if (alarma.hora >= 0) {
display.print("Alarm: ");
if (alarma.hora < 10) display.print('0');
display.print(alarma.hora); display.print(':');
if (alarma.minuto < 10) display.print('0');
display.print(alarma.minuto);
} else {
display.print("No alarm set");
}
// Estado
display.setCursor(0, 54);
if (esperandoPastilla) {
display.print(">> TAKE PILL! <<");
} else {
// Fecha
display.print(ahora.day()); display.print('/');
display.print(ahora.month()); display.print('/');
display.print(ahora.year());
}
display.display();
}
// Parsear comando SET_ALARM ─
// Formato: "SET_ALARM,08:30,1011010"
// días = Dom Lun Mar Mier Jue Vie Sab (7 chars, '1'=activo)
void parsearAlarma(String cmd) {
// Extraer hora
int c1 = cmd.indexOf(',');
int c2 = cmd.indexOf(',', c1 + 1);
if (c1 < 0 || c2 < 0) return;
String horaStr = cmd.substring(c1 + 1, c2); // "08:30"
String diasStr = cmd.substring(c2 + 1); // "1011010"
diasStr.trim();
int col = horaStr.indexOf(':');
if (col < 0) return;
alarma.hora = horaStr.substring(0, col).toInt();
alarma.minuto = horaStr.substring(col + 1).toInt();
for (int i = 0; i < 7 && i < (int)diasStr.length(); i++) {
alarma.dias[i] = (diasStr[i] == '1');
}
alarma.activa = true;
Serial.println("ALARM_SET_OK");
}
// Verificar si toca la alarma
// dayOfWeek(): 0=Dom, 1=Lun … 6=Sab (igual que el array)
void verificarAlarma(DateTime& ahora) {
if (!alarma.activa || alarmaDisparada) return;
if (ahora.hour() != alarma.hora || ahora.minute() != alarma.minuto) return;
if (ahora.second() != 0) return; // solo dispara al segundo 0
int dow = ahora.dayOfTheWeek(); // 0=Dom … 6=Sab
if (alarma.dias[dow]) {
activarDispensador();
}
}
// Leer Serial de la PC
void leerSerial() {
if (!Serial.available()) return;
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd.startsWith("SET_ALARM")) {
parsearAlarma(cmd);
} else if (cmd == "PILL_TAKEN") {
if (esperandoPastilla) confirmarPastilla();
}
}
//
void setup() {
Serial.begin(115200);
// I2C
Wire.begin(0, 1); // SDA=GPIO0, SCL=GPIO1
Wire.setClock(100000);
// OLED
display.begin(I2C_ADDR_OLED, true);
display.clearDisplay();
display.setTextColor(SH110X_WHITE);
display.display();
// RTC
if (!rtc.begin()) {
display.setCursor(0, 0);
display.println("RTC ERROR");
display.display();
while (1);
}
if (rtc.lostPower()) {
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
// Servo
servo.attach(PIN_SERVO);
servo.write(0);
// Buzzer y Botón
pinMode(PIN_BUZZER, OUTPUT);
pinMode(PIN_BOTON, INPUT_PULLUP);
Serial.println("READY");
}
//
void loop() {
leerSerial();
// Botón físico: confirmar pastilla
if (digitalRead(PIN_BOTON) == LOW) {
delay(50);
if (digitalRead(PIN_BOTON) == LOW && esperandoPastilla) {
confirmarPastilla();
delay(500);
}
}
// Actualizar OLED y verificar alarma cada segundo
if (millis() - ultimaHora >= 1000) {
ultimaHora = millis();
DateTime ahora = rtc.now();
actualizarOLED(ahora);
verificarAlarma(ahora);
// Enviar hora a la PC
char buf[20];
snprintf(buf, sizeof(buf), "TIME,%02d:%02d:%02d",
ahora.hour(), ahora.minute(), ahora.second());
Serial.println(buf);
}
// Si lleva más de 60 segundos sonando y nadie confirmó se apaga solo
if (alarmaDisparada && millis() - tiempoAlarma > 60000) {
confirmarPastilla();
}
// Repetir pitido cada 5s mientras espera confirmación
if (esperandoPastilla && millis() - tiempoAlarma > 5000) {
tiempoAlarma = millis();
tocarAlarma();
}
}
Header and Serial Protocol
The first comment documents the hardware and the communication rules. It lists the exact
messages used by both sides, including SET_ALARM, PILL_TAKEN,
TIME, ALARM_TRIGGERED, and PILL_CONFIRMED.
/*
Pill Dispenser MJ — XIAO ESP32-C6 Firmware
Week: Interface and Application Programming
Hardware:
- OLED SH1106 I2C: SDA=D0 (GPIO0), SCL=D1 (GPIO1)
- RTC DS3231 mismo bus I2C
- Servo D10
- Buzzer pasivo D9
- Botón D2 (confirmar pastilla tomada)
Protocolo Serial (115200 baud):
PC to XIAO ESP32-C6: "SET_ALARM,HH:MM,SMTWRFS\n" (días: 1=activo, posición = Dom,Lun,Mar,Mier,Jue,Vie,Sab)
PC to XIAO ESP32-C6: "PILL_TAKEN\n"
XIAO ESP32-C6 to PC: "TIME,HH:MM:SS\n" (cada segundo)
XIAO ESP32-C6 to PC: "ALARM_TRIGGERED\n" (cuando suena)
XIAO ESP32-C6 to PC: "PILL_CONFIRMED\n" (cuando se presiona botón o llega PILL_TAKEN)
*/
Libraries, Pins, and State
This section imports the hardware libraries, defines the pins, creates the OLED, RTC, and servo objects, and stores the alarm information inside a struct with hour, minute, active days, and state flags.
#include <Wire.h>
#include <RTClib.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <XIAO ESP32-C6Servo.h>
// Pines
#define PIN_SERVO D10
#define PIN_BUZZER D9
#define PIN_BOTON D2
// OLED SH1106
#define I2C_ADDR_OLED 0x3C
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SH1106G display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
// RTC
RTC_DS3231 rtc;
// Servo
Servo servo;
// Estado de la alarma
struct Alarma {
int hora = -1;
int minuto = -1;
bool dias[7] = {false}; // [Dom, Lun, Mar, Mier, Jue, Vie, Sab]
bool activa = false;
} alarma;
bool alarmaDisparada = false; // está sonando ahora?
bool esperandoPastilla = false; // esperando confirmación?
unsigned long tiempoAlarma = 0; // cuándo empezó
unsigned long ultimaHora = 0; // para enviar TIME cada segundo
Alarm, Servo, and Confirmation
The alarm functions play buzzer tones, move the servo to dispense, mark the system as
waiting for confirmation, and send status messages back to Python. Confirmation stops the
alarm and replies with PILL_CONFIRMED.
// Melodía de alarma
void tocarAlarma() {
// Tres pitidos ascendentes
tone(PIN_BUZZER, 523, 200); delay(250);
tone(PIN_BUZZER, 659, 200); delay(250);
tone(PIN_BUZZER, 784, 400); delay(500);
}
void detenerAlarma() {
noTone(PIN_BUZZER);
servo.write(0);
}
void activarDispensador() {
servo.write(90); // gira para dispensar
tocarAlarma();
alarmaDisparada = true;
esperandoPastilla = true;
tiempoAlarma = millis();
Serial.println("ALARM_TRIGGERED");
}
// Confirmar pastilla tomada
void confirmarPastilla() {
detenerAlarma();
alarmaDisparada = false;
esperandoPastilla = false;
tone(PIN_BUZZER, 1047, 200); // pitido de confirmación (Do alto)
delay(250);
Serial.println("PILL_CONFIRMED");
}
// OLED
OLED Display
actualizarOLED() redraws the display with the current RTC time, the configured
alarm, and either the date or the warning text telling the user to take the pill.
void actualizarOLED(DateTime& ahora) {
display.clearDisplay();
// Título
display.setTextSize(1);
display.setCursor(20, 0);
display.println("CURRENT TIME");
display.drawFastHLine(0, 10, 128, SH110X_WHITE);
// Hora grande
display.setTextSize(2);
display.setCursor(15, 20);
if (ahora.hour() < 10) display.print('0');
display.print(ahora.hour()); display.print(':');
if (ahora.minute() < 10) display.print('0');
display.print(ahora.minute()); display.print(':');
if (ahora.second() < 10) display.print('0');
display.print(ahora.second());
// Alarma configurada
display.setTextSize(1);
display.setCursor(0, 44);
if (alarma.hora >= 0) {
display.print("Alarm: ");
if (alarma.hora < 10) display.print('0');
display.print(alarma.hora); display.print(':');
if (alarma.minuto < 10) display.print('0');
display.print(alarma.minuto);
} else {
display.print("No alarm set");
}
// Estado
display.setCursor(0, 54);
if (esperandoPastilla) {
display.print(">> TAKE PILL! <<");
} else {
// Fecha
display.print(ahora.day()); display.print('/');
display.print(ahora.month()); display.print('/');
display.print(ahora.year());
}
display.display();
}
// Parsear comando SET_ALARM
// Formato: "SET_ALARM,08:30,1011010"
// días = Dom Lun Mar Mier Jue Vie Sab (7 chars, '1'=activo)
Parsing SET_ALARM
parsearAlarma() receives a command like SET_ALARM,08:30,1011010.
It extracts the hour, minute, and seven day bits, saves them in the alarm struct, activates
the alarm, and sends ALARM_SET_OK.
void parsearAlarma(String cmd) {
// Extraer hora
int c1 = cmd.indexOf(',');
int c2 = cmd.indexOf(',', c1 + 1);
if (c1 < 0 || c2 < 0) return;
String horaStr = cmd.substring(c1 + 1, c2); // "08:30"
String diasStr = cmd.substring(c2 + 1); // "1011010"
diasStr.trim();
int col = horaStr.indexOf(':');
if (col < 0) return;
alarma.hora = horaStr.substring(0, col).toInt();
alarma.minuto = horaStr.substring(col + 1).toInt();
for (int i = 0; i < 7 && i < (int)diasStr.length(); i++) {
alarma.dias[i] = (diasStr[i] == '1');
}
alarma.activa = true;
Serial.println("ALARM_SET_OK");
}
// Verificar si toca la alarma
// dayOfWeek(): 0=Dom, 1=Lun … 6=Sab (igual que el array)
Checking Time and Serial Commands
verificarAlarma() compares the RTC time with the stored alarm and only triggers
at second zero. leerSerial() receives commands from Python and routes them to
either alarm parsing or pill confirmation.
void verificarAlarma(DateTime& ahora) {
if (!alarma.activa || alarmaDisparada) return;
if (ahora.hour() != alarma.hora || ahora.minute() != alarma.minuto) return;
if (ahora.second() != 0) return; // solo dispara al segundo 0
int dow = ahora.dayOfTheWeek(); // 0=Dom … 6=Sab
if (alarma.dias[dow]) {
activarDispensador();
}
}
// Leer Serial de la PC
void leerSerial() {
if (!Serial.available()) return;
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd.startsWith("SET_ALARM")) {
parsearAlarma(cmd);
} else if (cmd == "PILL_TAKEN") {
if (esperandoPastilla) confirmarPastilla();
}
}
Setup
setup() starts Serial, initializes I2C on GPIO0 and GPIO1, starts the OLED and
RTC, adjusts the RTC if it lost power, attaches the servo, configures the buzzer and button,
and prints READY when the board is prepared.
void setup() {
Serial.begin(115200);
// I2C
Wire.begin(0, 1); // SDA=GPIO0, SCL=GPIO1
Wire.setClock(100000);
// OLED
display.begin(I2C_ADDR_OLED, true);
display.clearDisplay();
display.setTextColor(SH110X_WHITE);
display.display();
// RTC
if (!rtc.begin()) {
display.setCursor(0, 0);
display.println("RTC ERROR");
display.display();
while (1);
}
if (rtc.lostPower()) {
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
// Servo
servo.attach(PIN_SERVO);
servo.write(0);
// Buzzer y Botón
pinMode(PIN_BUZZER, OUTPUT);
pinMode(PIN_BOTON, INPUT_PULLUP);
Serial.println("READY");
}
//
Main Loop
loop() reads Serial, checks the physical button, updates the OLED once per
second, sends the current time to Python, checks the alarm, stops it after 60 seconds if
nobody confirms, and repeats the reminder tone every five seconds.
void loop() {
leerSerial();
// Botón físico: confirmar pastilla
if (digitalRead(PIN_BOTON) == LOW) {
delay(50);
if (digitalRead(PIN_BOTON) == LOW && esperandoPastilla) {
confirmarPastilla();
delay(500);
}
}
// Actualizar OLED y verificar alarma cada segundo
if (millis() - ultimaHora >= 1000) {
ultimaHora = millis();
DateTime ahora = rtc.now();
actualizarOLED(ahora);
verificarAlarma(ahora);
// Enviar hora a la PC
char buf[20];
snprintf(buf, sizeof(buf), "TIME,%02d:%02d:%02d",
ahora.hour(), ahora.minute(), ahora.second());
Serial.println(buf);
}
// Si lleva más de 60 segundos sonando y nadie confirmó se apaga solo
if (alarmaDisparada && millis() - tiempoAlarma > 60000) {
confirmarPastilla();
}
// Repetir pitido cada 5s mientras espera confirmación
if (esperandoPastilla && millis() - tiempoAlarma > 5000) {
tiempoAlarma = millis();
tocarAlarma();
}
}
Arduino Setup and Pin Connections
I kept setup and wiring together because both are part of preparing the hardware before the Python app can communicate with the XIAO ESP32-C6.
Arduino IDE Setup
Select the XIAO ESP32-C6 board, choose the correct COM port, and install the libraries used in the firmware: RTClib, Adafruit SH110X, Adafruit GFX, and XIAO ESP32-C6Servo.
COM Port Rule
The COM port can only be used by one program at a time. Before uploading from Arduino IDE, I close the Python app or disconnect it so the uploader can access the board.
Pin Connections from the Firmware
| Component | XIAO ESP32-C6 Pin | Purpose |
|---|---|---|
| OLED SH1106 SDA | D0 / GPIO0 | I2C data |
| OLED SH1106 SCL | D1 / GPIO1 | I2C clock |
| RTC DS3231 | Same I2C bus | Real time clock |
| Button | D2 | Physical confirmation with INPUT_PULLUP |
| Passive buzzer | D9 | Alarm and confirmation tones |
| Servo | D10 | Dispensing movement |
How to Run
The full system works when the firmware is uploaded, the hardware is connected, and the Python backend is running.
Step 1: Upload Firmware
Upload pastillero_XIAO ESP32-C6.ino to the XIAO ESP32-C6. The firmware starts
Serial at 115200 baud and prints READY when setup finishes.
Step 2: Run Python
Run the backend file. The app loads the generated UI, scans available ports, and waits for the user to connect.
python backend.py
Step 3: Send Alarm
Select a port, connect, choose the alarm time and days, then press the Set Alarm button.
Python sends SET_ALARM,HH:MM,DDDDDDD to the XIAO ESP32-C6.
Step 4: Confirm Pill
When the alarm triggers, the XIAO ESP32-C6 sends ALARM_TRIGGERED. The user can confirm
with the app button, which sends PILL_TAKEN, or with the physical button on D2.
Final Result
Here is a video of the final system in action: setting an alarm, waiting for it to trigger, and confirming the pill taken. The OLED shows the current time, the alarm time, and the warning message when the alarm is active.
Screen recording result
Here is a screen recording of the Python app showing the Serial messages received from the XIAO ESP32-C6, including the time updates, alarm trigger, and pill confirmation.
Learning Outcomes
This week helped me connect interface design, Python programming, Serial communication, and embedded hardware into one complete pill dispenser system. These were my main takeaways:
- Qt Designer makes interface building much faster: Designing the pill dispenser window visually helped me organize the time display, alarm controls, day checkboxes, port selector, and action buttons before writing the Python logic. Renaming each widget with a clear objectName was essential because those names are what backend.py uses to control the interface.
- Keeping the interface and backend separate makes the project easier to manage: I used Qt Designer to create pastillero.ui, converted it with pyuic6 into frontend.py, and kept the behavior in backend.py. This structure made it easier to update the visual design, connect buttons, read values from the widgets, and debug the app without mixing layout code with logic.
- A virtual environment keeps the Python project reproducible: Creating a venv and installing PyQt6 and pyserial inside it helped keep the app's dependencies organized. Saving the installed packages in requirements.txt also makes the project easier to recreate on another computer.
- Serial communication needs a clear protocol: Defining messages such as TIME, SET_ALARM, PILL_TAKEN, ALARM_TRIGGERED, and PILL_CONFIRMED made communication between Python and the XIAO ESP32-C6 predictable. Because every message had a specific format, both sides could parse commands and respond without confusion.
- The desktop app and firmware have to be tested as one system: The final behavior depended on many parts working together: the DS3231 RTC sending the time, Python displaying it, the user setting an alarm, the XIAO ESP32-C6 activating the servo and buzzer, and the confirmation message returning to the app. Testing the full chain helped me find problems that would not appear when testing each component alone.
- Threading is important for responsive interfaces: Reading Serial data continuously can block a program, so using a worker thread allowed the app to keep receiving messages from the board while the PyQt6 interface stayed usable and responsive.