Week 14 Interface and application programming

Doing our own interfaces

This week I built something extremely simple that requires nothing more than the microcontroller itself (in my case a XIAO RP2040). It's essentially an interface that lets you choose which USB port to connect to the board, and it takes advantage of the onboard RGB LEDs: with three sliders you can adjust the brightness of each LED independently. There's also a button that opens the native Windows color picker dialog, giving you greater freedom to select any color.

Program

void setup() {
    Serial.begin(115200);
    while (!Serial) { /* wait for USB serial */ }
    
    // Configure on-board LED channels
    pinMode(PIN_LED_R, OUTPUT);
    pinMode(PIN_LED_G, OUTPUT);
    pinMode(PIN_LED_B, OUTPUT);
    
    analogWriteResolution(8);    // 8-bit resolution
    
    Serial.println("XIAO Ready. Send R<0-255>, G<0-255> or B<0-255>");
    }
    
    void loop() {
    if (!Serial.available()) return;
    
    char channel = Serial.read();          // 'R', 'G' or 'B'
    int value    = Serial.parseInt();
    Serial.read();                         // consume newline
    value = constrain(value, 0, 255);
    
    int pin;
    switch (channel) {
        case 'R': pin = PIN_LED_R; break;
        case 'G': pin = PIN_LED_G; break;
        case 'B': pin = PIN_LED_B; break;
        default:  return;
    }
    
    analogWrite(pin, value);
    Serial.printf("%c set to %d\n", channel, value);
    }

In the setup function, the sketch initializes USB serial communication at 115200 baud. The while loop waits until the serial port is opened by the host computer, ensuring that subsequent serial output is not lost if the monitor isn't ready immediately.

    void setup() {
        Serial.begin(115200);
        while (!Serial) { /* wait for USB serial */ }      

These lines configure each RGB channel pin of the built-in LED as a digital output. This setup allows the program to use PWM on these pins to adjust the brightness of each color component.

    // Configure on-board LED channels
    pinMode(PIN_LED_R, OUTPUT);
    pinMode(PIN_LED_G, OUTPUT);
    pinMode(PIN_LED_B, OUTPUT);

This call sets the PWM resolution to 8 bits, meaning that analogWrite values from 0 to 255 will produce 256 discrete brightness levels for each LED channel.

    analogWriteResolution(8);    // 8-bit resolution

After configuring the hardware, the sketch sends a confirmation message over serial to inform the user that it is ready to receive commands in the format of a channel letter followed by a value between 0 and 255.

    Serial.println("XIAO Ready. Send R<0-255>, G<0-255> or B<0-255>");
    }

In the main loop, the code first checks whether any serial data has arrived. If no data is available, it returns immediately, avoiding unnecessary processing.

    void loop() {
        if (!Serial.available()) return;   

When data is available, the sketch reads the first character as the channel identifier ('R', 'G', or 'B'). It then reads the following integer value, consumes the newline character, and clamps the value to the valid range of 0-255 to protect against out-of-range inputs.

    char channel = Serial.read();          // 'R', 'G' or 'B'
    int value    = Serial.parseInt();
    Serial.read();                         // consume newline
    value = constrain(value, 0, 255);  

This switch statement maps the received channel character to the corresponding LED pin constant. If the character is not one of the recognized channels, the function returns without making any changes.

int pin;
switch (channel) {
    case 'R': pin = PIN_LED_R; break;
    case 'G': pin = PIN_LED_G; break;
    case 'B': pin = PIN_LED_B; break;
    default:  return;
}  

Finally, the sketch outputs a PWM signal on the chosen pin with the specified duty cycle, effectively setting that color's brightness. It then echoes the command and value back over serial as confirmation.

analogWrite(pin, value);
Serial.printf("%c set to %d\n", channel, value);
}  

Interface

I wrote this code in a very basic way because I didn't want to deal with the interface design (colors and layout), which I'm terrible at. So, using that code as a base, I gave GPT the following prompt:

“hola GPT, estoy haciendo una interfaz destinada a usarse en un microcontrolador XIAORP2040 por medio de comunicación UART, y el código lo que hace es cambiar el color de 3 leds que ya están en la placa de desarrollo del propio microcontrolador, necesito que le hagas una interfaz en la que se vean 3 sliders con las cuales se pueda mover el brillo de cada led por medio de PWM, que se vea el numero en que se encuentra el slider de 0 a 255, que sea posible poner un numero en esa misma casilla por si no se quiere usar algún slider, a parte necesito que pongas una barra desplegable para seleccionar que entrada de puerto USB estoy usando y un botón de conexión asi como un texto de confirmación para la comunicación. También quiero que añadas un circulo cromatico para ello. Para terminar, por favor dale un diseño oscuro y si ves algún fallo en mi código o algo que se pueda mejorar hazlo (code for XIAORP2040) (code for python)”

(This is the basic code I send to chatGPT)

    import serial,serial.tools.list_ports,tkinter as tk

BAUD=115200
ser=None
led_values={'R':0,'G':0,'B':0}

def list_ports():
    return [p.device for p in serial.tools.list_ports.comports()]

def connect(port):
    global ser
    if port:
        ser=serial.Serial(port,BAUD,timeout=1)

def send(ch,val):
    if ser and ser.is_open:
        ser.write(f"{ch}{255-val}\n".encode())

def update_slider(ch,val):
    led_values[ch]=val
    send(ch,val)

def update_entry(ch,val):
    try:
        v=int(val)
        v=max(0,min(255,v))
        update_slider(ch,v)
    except:
        pass

def update_swatch():
    r,g,b=led_values['R'],led_values['G'],led_values['B']
    return f"#{r:02x}{g:02x}{b:02x}"

def on_exit():
    if ser and ser.is_open:
        ser.close()

if __name__=="__main__":
    root=tk.Tk()
    root.title("XIAO RGB Controller")
    ports=list_ports()
    port_var=tk.StringVar(value=ports[0] if ports else "")
    tk.Label(root,text="Port").pack()
    tk.OptionMenu(root,port_var,*ports).pack()
    tk.Button(root,text="Connect",command=lambda:connect(port_var.get())).pack()
    sliders={}
    for ch in ['R','G','B']:
        tk.Label(root,text=ch).pack()
        s=tk.Scale(root,from_=0,to=255,orient='horizontal',command=lambda v,ch=ch: update_slider(ch,int(v)))
        s.pack()
        sliders[ch]=s
    tk.Button(root,text="Exit",command=lambda:(on_exit(),root.destroy())).pack()
    root.mainloop()

Then, GPT gave me this:

    # Python Tkinter App: Inverted RGB Slider + Color Wheel for XIAO RP2040 LEDs via USB Serial
    import serial
    import serial.tools.list_ports
    import tkinter as tk
    from tkinter import ttk, font, messagebox, colorchooser
    
    # Theme: Black background, dark grays for UI
    BG_COLOR      = "#000000"  # black
    TEXT_COLOR    = "#DDDDDD"  # light gray text
    BUTTON_BG     = "#333333"  # dark gray button
    BUTTON_ACTIVE = "#555555"  # medium gray on active
    ENTRY_BG      = "#222222"  # very dark gray entry
    ENTRY_FG      = "#EEEEEE"  # light text in entry
    TRough_COLOR  = "#333333"  # dark gray trough
    SLIDER_LENGTH = 300
    BAUD_RATE     = 115200
    
    # Gradient endpoints: black to channel color
    GRAD_COLORS = {
        'R': ('#000000', '#FF0000'),
        'G': ('#000000', '#00FF00'),
        'B': ('#000000', '#0000FF'),
    }
    
    class RGBApp(tk.Tk):
        def __init__(self):
            super().__init__()
            self.title("XIAO RGB LED Controller")
            self.configure(bg=BG_COLOR)
            self.resizable(False, False)
    
            self.ser = None
            self.led_values = {'R': 0, 'G': 0, 'B': 0}
            self.sliders = {}
            self.entries = {}
    
            # Fonts
            self.font_label = font.Font(size=12, weight="bold")
            self.font_entry = font.Font(size=10)
    
            # USB Port selection
            tk.Label(self, text="USB Port:", fg=TEXT_COLOR, bg=BG_COLOR,
                        font=self.font_label).grid(row=0, column=0, padx=5, pady=5, sticky="e")
            self.port_var = tk.StringVar()
            ports = [p.device for p in serial.tools.list_ports.comports()]
            self.port_menu = ttk.Combobox(self, textvariable=self.port_var,
                                            values=ports, state="readonly", width=20)
            self.port_menu.grid(row=0, column=1, pady=5)
            connect_btn = tk.Button(self, text="Connect", command=self.connect_serial,
                        bg=BUTTON_BG, fg=TEXT_COLOR, activebackground=BUTTON_ACTIVE)
            connect_btn.grid(row=0, column=2, padx=5)
            self.status_lbl = tk.Label(self, text="Not connected", fg="red",
                                        bg=BG_COLOR, font=self.font_label)
            self.status_lbl.grid(row=1, column=0, columnspan=3, pady=(0,10))
    
            # Color chooser button & swatch
            chooser_btn = tk.Button(self, text="Pick Color", command=self.choose_color,
                        bg=BUTTON_BG, fg=TEXT_COLOR, activebackground=BUTTON_ACTIVE)
            chooser_btn.grid(row=2, column=0, columnspan=2, pady=5)
            self.swatch = tk.Label(self, text="      ", bg="#000000", relief="ridge")
            self.swatch.grid(row=2, column=2, padx=5)
    
            # RGB sliders + entries
            for idx, ch in enumerate(['R','G','B']):
                row = 3 + idx*2
                # Label for channel
                tk.Label(self, text=ch, fg=TEXT_COLOR, bg=BG_COLOR,
                            font=self.font_label).grid(row=row, column=0, pady=(10,0))
                # Gradient canvas
                canvas = tk.Canvas(self, width=SLIDER_LENGTH, height=30,
                                    highlightthickness=0, bg=BG_COLOR)
                canvas.grid(row=row, column=1, padx=5)
                self.draw_gradient(canvas, *GRAD_COLORS[ch])
                # Slider
                slider = tk.Scale(self, from_=0, to=255, orient='horizontal', length=SLIDER_LENGTH,
                                    command=lambda v, ch=ch: self.update_from_slider(ch, int(v)),
                                    bg=BG_COLOR, troughcolor=TRough_COLOR,
                                    sliderlength=20, highlightthickness=0)
                slider.grid(row=row+1, column=1, pady=(0,10))
                self.sliders[ch] = slider
                # Numeric entry
                entry = tk.Entry(self, width=4, font=self.font_entry, justify='center',
                                    bg=ENTRY_BG, fg=ENTRY_FG, insertbackground=ENTRY_FG)
                entry.insert(0, '0')
                entry.bind('', lambda e, ch=ch: self.update_from_entry(ch))
                entry.grid(row=row+1, column=2, padx=5)
                self.entries[ch] = entry
    
            # Exit button
            exit_btn = tk.Button(self, text="Exit", command=self.on_exit,
                                    bg=BUTTON_BG, fg=TEXT_COLOR, activebackground=BUTTON_ACTIVE)
            exit_btn.grid(row=9, column=0, columnspan=3, pady=10)
    
        def draw_gradient(self, canvas, start_hex, end_hex):
            r1,g1,b1 = canvas.winfo_rgb(start_hex)
            r2,g2,b2 = canvas.winfo_rgb(end_hex)
            for i in range(SLIDER_LENGTH):
                t = i / (SLIDER_LENGTH-1)
                nr = int((r1 + (r2-r1)*t) / 256)
                ng = int((g1 + (g2-g1)*t) / 256)
                nb = int((b1 + (b2-b1)*t) / 256)
                canvas.create_line(i, 0, i, 30, fill=f"#{nr:02x}{ng:02x}{nb:02x}")
    
        def connect_serial(self):
            port = self.port_var.get().strip()
            if not port:
                messagebox.showwarning("Warning", "Select a serial port.")
                return
            try:
                self.ser = serial.Serial(port, BAUD_RATE, timeout=1)
                self.status_lbl.config(text=f"Connected: {port}", fg="green")
            except Exception:
                self.status_lbl.config(text="Connection failed", fg="red")
    
        def update_from_slider(self, ch, val):
            # Invert value (255->0, 0->255) before sending
            inv = 255 - val
            self.led_values[ch] = val
            self.entries[ch].delete(0, tk.END)
            self.entries[ch].insert(0, str(val))
            self.send_value(ch, inv)
            self.update_swatch()
    
        def update_from_entry(self, ch):
            try:
                val = int(self.entries[ch].get())
                val = max(0, min(255, val))
                self.sliders[ch].set(val)
            except ValueError:
                pass
    
        def choose_color(self):
            result = colorchooser.askcolor()[0]
            if result:
                r, g, b = map(int, result)
                for ch, v in zip(['R','G','B'], [r, g, b]):
                    self.sliders[ch].set(v)
    
        def send_value(self, ch, inv_val):
            if self.ser and self.ser.is_open:
                try:
                    self.ser.write(f"{ch}{inv_val}\n".encode())
                except:
                    pass
    
        def update_swatch(self):
            r, g, b = self.led_values['R'], self.led_values['G'], self.led_values['B']
            self.swatch.config(bg=f"#{r:02x}{g:02x}{b:02x}")
    
        def on_exit(self):
            if self.ser and self.ser.is_open:
                self.ser.close()
            self.destroy()
    
    if __name__ == "__main__":
        app = RGBApp()
        app.mainloop()
    

This section imports the necessary modules for serial communication, port enumeration, and graphical user interface components. The serial library handles USB communication to the XIAO, the tools.list_ports submodule finds available ports, and tkinter with ttk, font, messagebox, and colorchooser provides the widgets, styling, dialogs, and color picker used in the application.

    # Python Tkinter App: Inverted RGB Slider + Color Wheel for XIAO RP2040 LEDs via USB Serial
    import serial
    import serial.tools.list_ports
    import tkinter as tk
    from tkinter import ttk, font, messagebox, colorchooser    

These constants define the visual theme and operational parameters for the application. The background and widget colors use a black and dark gray palette. The slider length and serial baud rate set the size of each control and communication speed. The GRAD_COLORS dictionary specifies the start and end colors for each RGB channel's gradient, which will be drawn behind the sliders.

# Theme: Black background, dark grays for UI
BG_COLOR      = "#000000"  # black
TEXT_COLOR    = "#DDDDDD"  # light gray text
BUTTON_BG     = "#333333"  # dark gray button
BUTTON_ACTIVE = "#555555"  # medium gray on active
ENTRY_BG      = "#222222"  # very dark gray entry
ENTRY_FG      = "#EEEEEE"  # light text in entry
TRough_COLOR  = "#333333"  # dark gray trough
SLIDER_LENGTH = 300
BAUD_RATE     = 115200

# Gradient endpoints: black to channel color
GRAD_COLORS = {
    'R': ('#000000', '#FF0000'),
    'G': ('#000000', '#00FF00'),
    'B': ('#000000', '#0000FF'),
}

This code defines the main application class, inheriting from the tkinter root window. The window is given a title and black background, and resizing is disabled. Instance variables are initialized to track the serial connection, current LED values, slider and entry widgets. Two font objects are created for labels and entry fields.

class RGBApp(tk.Tk):
def __init__(self):
    super().__init__()
    self.title("XIAO RGB LED Controller")
    self.configure(bg=BG_COLOR)
    self.resizable(False, False)

    self.ser = None
    self.led_values = {'R': 0, 'G': 0, 'B': 0}
    self.sliders = {}
    self.entries = {}

    # Fonts
    self.font_label = font.Font(size=12, weight="bold")
    self.font_entry = font.Font(size=10)

This block builds the user interface for selecting and connecting to a USB serial port. A label, a combobox populated with available serial port names, and a Connect button are arranged in a grid layout. The status label below indicates whether the connection is active or has failed.

# USB Port selection
tk.Label(self, text="USB Port:", fg=TEXT_COLOR, bg=BG_COLOR,
            font=self.font_label).grid(row=0, column=0, padx=5, pady=5, sticky="e")
self.port_var = tk.StringVar()
ports = [p.device for p in serial.tools.list_ports.comports()]
self.port_menu = ttk.Combobox(self, textvariable=self.port_var,
                                values=ports, state="readonly", width=20)
self.port_menu.grid(row=0, column=1, pady=5)
connect_btn = tk.Button(self, text="Connect", command=self.connect_serial,
            bg=BUTTON_BG, fg=TEXT_COLOR, activebackground=BUTTON_ACTIVE)
connect_btn.grid(row=0, column=2, padx=5)
self.status_lbl = tk.Label(self, text="Not connected", fg="red",
                            bg=BG_COLOR, font=self.font_label)
self.status_lbl.grid(row=1, column=0, columnspan=3, pady=(0,10))

Here a button invokes the standard color chooser dialog to select an overall color, and a small label acts as a swatch displaying the mixed RGB color. The swatch background will be updated to reflect the current slider values.

# Color chooser button & swatch
chooser_btn = tk.Button(self, text="Pick Color", command=self.choose_color,
            bg=BUTTON_BG, fg=TEXT_COLOR, activebackground=BUTTON_ACTIVE)
chooser_btn.grid(row=2, column=0, columnspan=2, pady=5)
self.swatch = tk.Label(self, text="      ", bg="#000000", relief="ridge")
self.swatch.grid(row=2, column=2, padx=5)

This loop creates three groups of widgets, one for each color channel. Each group consists of a label showing the channel letter, a canvas showing a horizontal gradient from black to the channel’s pure color, a slider that updates the LED and entry when moved, and an entry widget where the user can type a numeric value and press Enter to update the slider and LED.

# RGB sliders + entries
for idx, ch in enumerate(['R','G','B']):
    row = 3 + idx*2
    # Label for channel
    tk.Label(self, text=ch, fg=TEXT_COLOR, bg=BG_COLOR,
                font=self.font_label).grid(row=row, column=0, pady=(10,0))
    # Gradient canvas
    canvas = tk.Canvas(self, width=SLIDER_LENGTH, height=30,
                        highlightthickness=0, bg=BG_COLOR)
    canvas.grid(row=row, column=1, padx=5)
    self.draw_gradient(canvas, *GRAD_COLORS[ch])
    # Slider
    slider = tk.Scale(self, from_=0, to=255, orient='horizontal', length=SLIDER_LENGTH,
                        command=lambda v, ch=ch: self.update_from_slider(ch, int(v)),
                        bg=BG_COLOR, troughcolor=TRough_COLOR,
                        sliderlength=20, highlightthickness=0)
    slider.grid(row=row+1, column=1, pady=(0,10))
    self.sliders[ch] = slider
    # Numeric entry
    entry = tk.Entry(self, width=4, font=self.font_entry, justify='center',
                        bg=ENTRY_BG, fg=ENTRY_FG, insertbackground=ENTRY_FG)
    entry.insert(0, '0')
    entry.bind('', lambda e, ch=ch: self.update_from_entry(ch))
    entry.grid(row=row+1, column=2, padx=5)
    self.entries[ch] = entry

An Exit button at the bottom closes the serial connection if open and then destroys the application window, terminating the program.

# Exit button
exit_btn = tk.Button(self, text="Exit", command=self.on_exit,
                        bg=BUTTON_BG, fg=TEXT_COLOR, activebackground=BUTTON_ACTIVE)
exit_btn.grid(row=9, column=0, columnspan=3, pady=10)

The draw_gradient method takes a canvas and two colors, converts them to RGB integer values, and draws vertical lines across the canvas whose color interpolates between the start and end colors. This produces a smooth gradient background behind each slider.

def draw_gradient(self, canvas, start_hex, end_hex):
    r1,g1,b1 = canvas.winfo_rgb(start_hex)
    r2,g2,b2 = canvas.winfo_rgb(end_hex)
    for i in range(SLIDER_LENGTH):
        t = i / (SLIDER_LENGTH-1)
        nr = int((r1 + (r2-r1)*t) / 256)
        ng = int((g1 + (g2-g1)*t) / 256)
        nb = int((b1 + (b2-b1)*t) / 256)
        canvas.create_line(i, 0, i, 30, fill=f"#{nr:02x}{ng:02x}{nb:02x}")

The connect_serial method retrieves the selected port name, shows a warning if none is selected, and attempts to open the serial connection at the configured baud rate. It updates the status label to green on success or red on failure.

def connect_serial(self):
    port = self.port_var.get().strip()
    if not port:
        messagebox.showwarning("Warning", "Select a serial port.")
        return
    try:
        self.ser = serial.Serial(port, BAUD_RATE, timeout=1)
        self.status_lbl.config(text=f"Connected: {port}", fg="green")
    except Exception:
        self.status_lbl.config(text="Connection failed", fg="red")

When a slider is moved, this method inverts the slider value so that a higher slider position lights the LED less brightly. It stores the raw slider value, updates the corresponding entry box, sends the inverted value over serial to the XIAO, and refreshes the color swatch to reflect the visible color mix.

def update_from_slider(self, ch, val):
    # Invert value (255->0, 0->255) before sending
    inv = 255 - val
    self.led_values[ch] = val
    self.entries[ch].delete(0, tk.END)
    self.entries[ch].insert(0, str(val))
    self.send_value(ch, inv)
    self.update_swatch()

aaa

void

This method is invoked when the user presses Enter in an entry field. It reads the typed value, clamps it between 0 and 255, and sets the corresponding slider to that value, which in turn updates the LED and swatch through the slider callback.

def update_from_entry(self, ch):
    try:
        val = int(self.entries[ch].get())
        val = max(0, min(255, val))
        self.sliders[ch].set(val)
    except ValueError:
        pass

The choose_color method opens a color picker dialog. If the user selects a color, the resulting RGB tuple is used to set each slider’s value so that the sliders, entry boxes, serial commands, and swatch all update to the chosen color.

def choose_color(self):
    result = colorchooser.askcolor()[0]
    if result:
        r, g, b = map(int, result)
        for ch, v in zip(['R','G','B'], [r, g, b]):
            self.sliders[ch].set(v)

This helper method writes a single command to the serial port in the format “R123\n” or “G45\n”, sending the inverted brightness value for the specified channel to the XIAO. Any errors during write are silently ignored.

    def send_value(self, ch, inv_val):
        if self.ser and self.ser.is_open:
            try:
                self.ser.write(f"{ch}{inv_val}\n".encode())
            except:
                pass

The update_swatch method sets the background color of the swatch label to the hexadecimal representation of the current slider values, giving a live preview of the mixed color as seen on the LED.

def update_swatch(self):
    r, g, b = self.led_values['R'], self.led_values['G'], self.led_values['B']
    self.swatch.config(bg=f"#{r:02x}{g:02x}{b:02x}")

The on_exit method closes the serial connection if it is open and then destroys the main application window, ensuring a clean shutdown when the user exits the app.

def on_exit(self):
    if self.ser and self.ser.is_open:
        self.ser.close()
    self.destroy()

This final block checks if the script is run as the main program, creates an instance of the RGBApp class, and starts the tkinter main event loop so that the GUI becomes responsive to user input and updates.

    if __name__ == "__main__":
    app = RGBApp()
    app.mainloop()

This is how the program looks like

The week was relatively easy. The only difficult part, as always, was figuring out what to do without overcomplicating things, so I made this simple project. All I did was take care of the logic and the most basic part of the interface—the rest (mainly the design) I left to ChatGPT. I didn't have any problems.

Here you have the link to our group page

Here you can find the files of each process:

Basic code for interface

Complete interface

Code for XIAORP2040