Once you've finished setting up your ESP32-S3 Feather with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.
To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
# SPDX-FileCopyrightText: 2024 Liz Clark for Adafruit Industries # SPDX-License-Identifier: MIT import time import gc import board import displayio import fourwire import terminalio import adafruit_ble from adafruit_ble.advertising.standard import SolicitServicesAdvertisement from adafruit_ble_apple_media import AppleMediaService from adafruit_bitmap_font import bitmap_font from adafruit_button import Button from adafruit_display_text import label, wrap_text_to_lines import adafruit_hx8357 import adafruit_tsc2007 gc.collect() displayio.release_displays() small_font = bitmap_font.load_font("/Arial-16.bdf") # display spi = board.SPI() tft_cs = board.D9 tft_dc = board.D10 display_bus = fourwire.FourWire(spi, command=tft_dc, chip_select=tft_cs) display = adafruit_hx8357.HX8357(display_bus, width=480, height=320) # touch i2c = board.I2C() tsc = adafruit_tsc2007.TSC2007(i2c, invert_x=True, swap_xy=True) splash = displayio.Group() def ble_media_command(conn, command): gc.collect() # print("function started") if not conn.paired: print("trying to pair") conn.pair() print("paired") # print("connected, getting ready") ams = conn[AppleMediaService] # print(ams) tries = 10 for i in range(tries): try: # print("sending..") if command != "refresh": command(ams) time.sleep(2) # print("sent") song_text = f"{ams.title}" song_text = "\n".join(wrap_text_to_lines(song_text, 29)) song_label.text = song_text album_label.text = f"Album: {ams.album}" artist_label.text = f"Artist: {ams.artist}" app_label.text = f"App: {ams.player_name}" if ams.playing: buttons[0].label = "Pause" elif ams.paused: buttons[0].label = "Play" except Exception as error: # pylint: disable = broad-except print(error) # time.sleep(2) if i < tries - 1: continue break gc.collect() connection = None # commands commands = [ lambda ams: ams.toggle_play_pause(), lambda ams: ams.next_track(), lambda ams: ams.previous_track(), lambda ams: ams.advance_repeat_mode(), lambda ams: ams.advance_shuffle_mode(), lambda ams: ams.volume_up(), lambda ams: ams.volume_down(), ] # colors RED = (255, 0, 0) ORANGE = (255, 34, 0) YELLOW = (255, 170, 0) GREEN = (0, 255, 0) CYAN = (0, 255, 255) BLUE = (0, 0, 255) VIOLET = (153, 0, 255) MAGENTA = (255, 0, 51) PINK = (255, 51, 119) AQUA = (85, 125, 255) WHITE = (255, 255, 255) OFF = (0, 0, 0) gc.collect() spots = [ {'label': "Play/Pause", 'font': small_font, 'pos': ((display.width // 2) - 60, display.height - 70), 'size': (120, 60), 'color': RED, 'control': lambda: ble_media_command(connection, commands[0])}, {'label': ">>", 'font': small_font, 'pos': ((display.width // 2) + 70, display.height - 70), 'size': (60, 60), 'color': ORANGE, 'control': lambda: ble_media_command(connection, commands[1])}, {'label': "<<", 'font': small_font, 'pos': ((display.width // 2) - 130, display.height - 70), 'size': (60, 60), 'color': YELLOW, 'control': lambda: ble_media_command(connection, commands[2])}, {'label': "Repeat", 'font': terminalio.FONT, 'pos': (10, 250), 'size': (60, 60), 'color': GREEN, 'control': lambda: ble_media_command(connection, commands[3])}, {'label': "Shuffle", 'font': terminalio.FONT, 'pos': (410, 250), 'size': (60, 60), 'color': CYAN, 'control': lambda: ble_media_command(connection, commands[4])}, {'label': "Vol +", 'font': small_font, 'pos': (10, 10), 'size': (60, 120), 'color': BLUE, 'control': lambda: ble_media_command(connection, commands[5])}, {'label': "Vol -", 'font': small_font, 'pos': (410, 10), 'size': (60, 120), 'color': VIOLET, 'control': lambda: ble_media_command(connection, commands[6])}, {'label': "Refresh", 'font': small_font, 'pos': (70, 10), 'size': (340, display.height - 80), 'color': None, 'control': lambda: ble_media_command(connection, "refresh")}, ] buttons = [] for spot in spots: button = Button(x=spot['pos'][0], y=spot['pos'][1], width=spot['size'][0], height=spot['size'][1], label=spot['label'], label_font=spot['font'], style=Button.ROUNDRECT, fill_color=spot['color'], name=spot['label']) splash.append(button) buttons.append(button) header_label = label.Label(small_font, text="Now Playing:", color=WHITE) header_label.anchor_point = (0.5, 0.0) header_label.anchored_position = (display.width / 2, 10) splash.append(header_label) song_label = label.Label(small_font, text=" ", color=WHITE) song_label.anchor_point = (0.5, 0.0) song_label.anchored_position = (display.width / 2, 40) splash.append(song_label) artist_label = label.Label(small_font, text="Artist: ", color=WHITE) artist_label.anchor_point = (0.5, 0.0) artist_label.anchored_position = (display.width / 2, 124) splash.append(artist_label) album_label = label.Label(small_font, text="Album: ", color=WHITE) album_label.anchor_point = (0.5, 0.0) album_label.anchored_position = (display.width / 2, 154) splash.append(album_label) app_label = label.Label(small_font, text="App: ", color=WHITE) app_label.anchor_point = (0.5, 0.0) app_label.anchored_position = (display.width / 2, 184) splash.append(app_label) touch_state = False display.root_group = splash def scale_touch_coordinates(raw_x, raw_y): # raw coordinate ranges raw_x_min = 275 raw_x_max = 3900 raw_y_min = 487 raw_y_max = 3800 # scale the raw coordinates to display coordinates display_x = (raw_x - raw_x_min) * display.width / (raw_x_max - raw_x_min) display_y = (raw_y - raw_y_min) * display.height / (raw_y_max - raw_y_min) # clamp values to ensure they stay within display bounds display_x = max(0, min(display_x, display.width)) display_y = max(0, min(display_y, display.height)) return int(display_x), int(display_y) gc.collect() # PyLint can't find BLERadio for some reason so special case it here. radio = adafruit_ble.BLERadio() # pylint: disable=no-member a = SolicitServicesAdvertisement() a.solicited_services.append(AppleMediaService) if not radio.connected: print("advertising") radio.start_advertising(a) else: print("already connected") print(radio.connected) print(gc.mem_free()) while True: while not radio.connected: pass known_notifications = set() while radio.connected: if tsc.touched and not touch_state: gc.collect() touch_state = True p = tsc.touch point = scale_touch_coordinates(p["x"], p["y"]) connection = radio.connections[0] for button in buttons: b = buttons.index(button) if button.contains(point): print(gc.mem_free()) print("Touched", button.name) spots[b]['control']() if not tsc.touched and touch_state: touch_state = False print("disconnected") print("advertising") radio.start_advertising(a) print("reconnected")
Upload the Code, Font File, and Libraries to the ESP32-S3 Feather
After downloading the Project Bundle, plug your ESP32-S3 Feather into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the Feather's CIRCUITPY drive:
- lib folder
- Arial-16.bdf
- code.py
Your ESP32-S3 Feather CIRCUITPY drive should look like this after copying the lib folder, Arial-16.bdf font file and the code.py file.

# display spi = board.SPI() tft_cs = board.D9 tft_dc = board.D10 display_bus = fourwire.FourWire(spi, command=tft_dc, chip_select=tft_cs) display = adafruit_hx8357.HX8357(display_bus, width=480, height=320) # touch i2c = board.I2C() tsc = adafruit_tsc2007.TSC2007(i2c, invert_x=True, swap_xy=True) splash = displayio.Group()
Sending BLE Controls
The ble_media_command()
function takes care of sending and receiving BLE messages to your Apple device. Whenever the function is called, the now playing information is updated for the text elements on the display.
def ble_media_command(conn, command): gc.collect() # print("function started") if not conn.paired: print("trying to pair") conn.pair() print("paired") # print("connected, getting ready") ams = conn[AppleMediaService] # print(ams) tries = 10 for i in range(tries): try: # print("sending..") if command != "refresh": command(ams) time.sleep(2) # print("sent") song_text = f"{ams.title}" song_text = "\n".join(wrap_text_to_lines(song_text, 29)) song_label.text = song_text album_label.text = f"Album: {ams.album}" artist_label.text = f"Artist: {ams.artist}" app_label.text = f"App: {ams.player_name}" if ams.playing: buttons[0].label = "Pause" elif ams.paused: buttons[0].label = "Play" except Exception as error: # pylint: disable = broad-except print(error) # time.sleep(2) if i < tries - 1: continue break gc.collect()
List of Commands
The commands
list uses lambda
to store the Apple Media Service control functions before the BLE connection is established.
# commands commands = [ lambda ams: ams.toggle_play_pause(), lambda ams: ams.next_track(), lambda ams: ams.previous_track(), lambda ams: ams.advance_repeat_mode(), lambda ams: ams.advance_shuffle_mode(), lambda ams: ams.volume_up(), lambda ams: ams.volume_down(), ]
Dictionary of Buttons
The functions are assigned to buttons in the spots
dictionary. This allows each button to send an assigned BLE control in the loop alongside the ble_media_command()
function. The button labels denote their function and associated BLE control.
The last button, 'Refresh'
, uses None
as the color, making it transparent. If you touch the area of the screen that contains the now playing info, a call is made to only fetch the artist, media, album and app information.
spots = [ {'label': "Play/Pause", 'font': small_font, 'pos': ((display.width // 2) - 60, display.height - 70), 'size': (120, 60), 'color': RED, 'control': lambda: ble_media_command(connection, commands[0])}, {'label': ">>", 'font': small_font, 'pos': ((display.width // 2) + 70, display.height - 70), 'size': (60, 60), 'color': ORANGE, 'control': lambda: ble_media_command(connection, commands[1])}, {'label': "<<", 'font': small_font, 'pos': ((display.width // 2) - 130, display.height - 70), 'size': (60, 60), 'color': YELLOW, 'control': lambda: ble_media_command(connection, commands[2])}, {'label': "Repeat", 'font': terminalio.FONT, 'pos': (10, 250), 'size': (60, 60), 'color': GREEN, 'control': lambda: ble_media_command(connection, commands[3])}, {'label': "Shuffle", 'font': terminalio.FONT, 'pos': (410, 250), 'size': (60, 60), 'color': CYAN, 'control': lambda: ble_media_command(connection, commands[4])}, {'label': "Vol +", 'font': small_font, 'pos': (10, 10), 'size': (60, 120), 'color': BLUE, 'control': lambda: ble_media_command(connection, commands[5])}, {'label': "Vol -", 'font': small_font, 'pos': (410, 10), 'size': (60, 120), 'color': VIOLET, 'control': lambda: ble_media_command(connection, commands[6])}, {'label': "Refresh", 'font': small_font, 'pos': (70, 10), 'size': (340, display.height - 80), 'color': None, 'control': lambda: ble_media_command(connection, "refresh")}, ]
Display Attributes
The buttons are instantiated with entries from the spots
dictionary and then added to the splash
display group and buttons
array.
buttons = [] for spot in spots: button = Button(x=spot['pos'][0], y=spot['pos'][1], width=spot['size'][0], height=spot['size'][1], label=spot['label'], label_font=spot['font'], style=Button.ROUNDRECT, fill_color=spot['color'], name=spot['label']) splash.append(button) buttons.append(button)
Text labels are used for the "Now Playing"
header and media title, artist title, album title and app name. They are centered on the display using anchor_point
and anchored_position
.
header_label = label.Label(small_font, text="Now Playing:", color=WHITE) header_label.anchor_point = (0.5, 0.0) header_label.anchored_position = (display.width / 2, 10) splash.append(header_label) song_label = label.Label(small_font, text=" ", color=WHITE) song_label.anchor_point = (0.5, 0.0) song_label.anchored_position = (display.width / 2, 40) splash.append(song_label) artist_label = label.Label(small_font, text="Artist: ", color=WHITE) artist_label.anchor_point = (0.5, 0.0) artist_label.anchored_position = (display.width / 2, 124) splash.append(artist_label) album_label = label.Label(small_font, text="Album: ", color=WHITE) album_label.anchor_point = (0.5, 0.0) album_label.anchored_position = (display.width / 2, 154) splash.append(album_label) app_label = label.Label(small_font, text="App: ", color=WHITE) app_label.anchor_point = (0.5, 0.0) app_label.anchored_position = (display.width / 2, 184) splash.append(app_label)
Scaling Touch
The TSC2007 touch driver has a large range of touch coordinates that aren't necessarily tied to the display coordinates. The scale_touch_coordinates()
function scales the TSC2007 coordinates so that they match the display coordinates.
def scale_touch_coordinates(raw_x, raw_y): # raw coordinate ranges raw_x_min = 275 raw_x_max = 3900 raw_y_min = 487 raw_y_max = 3800 # scale the raw coordinates to display coordinates display_x = (raw_x - raw_x_min) * display.width / (raw_x_max - raw_x_min) display_y = (raw_y - raw_y_min) * display.height / (raw_y_max - raw_y_min) # clamp values to ensure they stay within display bounds display_x = max(0, min(display_x, display.width)) display_y = max(0, min(display_y, display.height)) return int(display_x), int(display_y)
Ready for Takeoff
Right before the loop, the BLE connection is advertised and a connection is initialized.
radio = adafruit_ble.BLERadio() # pylint: disable=no-member a = SolicitServicesAdvertisement() a.solicited_services.append(AppleMediaService) if not radio.connected: print("advertising") radio.start_advertising(a) else: print("already connected") print(radio.connected) print(gc.mem_free())
The Loop
In the loop, if the touchscreen is touched, its coordinates are scaled to the display coordinates. If one of the buttons is touched, then the associated BLE command is sent with spots[b]['control']()
. This is possible because lambda
is used in both the commands
array and the 'control'
entry of the spots
dictionary.
If the BLE connection is lost, then the board tries to reconnect by advertising again.
while True: while not radio.connected: pass known_notifications = set() while radio.connected: if tsc.touched and not touch_state: gc.collect() touch_state = True p = tsc.touch point = scale_touch_coordinates(p["x"], p["y"]) connection = radio.connections[0] for button in buttons: b = buttons.index(button) if button.contains(point): print(gc.mem_free()) print("Touched", button.name) spots[b]['control']() if not tsc.touched and touch_state: touch_state = False print("disconnected") print("advertising") radio.start_advertising(a) print("reconnected")
Page last edited February 14, 2025
Text editor powered by tinymce.