Overview
Display text superimposed over your live video stream with real-time updates using the Fruit Jam Chyron!
Just like "up to the minute" lower-thirds on a shopping network or newscast, you can composite text that is fed from a live data source. In our example, the product information, sale price, and up-to-the moment stock count for JP's Product Pick of the Week livestream.
Page last edited September 22, 2025
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Â
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Page last edited September 22, 2025
Text editor powered by tinymce.
Code the Fruit Jam
Download the Project Bundle
Your project will use a specific set of CircuitPython libraries and .py files. To get everything you need, click on the Download Project Bundle link below, and uncompress the .zip file.
Drag the contents of the uncompressed bundle directory onto your board's CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import time
import board
import supervisor
import terminalio
from adafruit_fruitjam import FruitJam
from adafruit_fruitjam.peripherals import request_display_config
BG_COLOR = 0x0000FF
# use built-in system font
OVERLAY_FONT = terminalio.FONT
# or un-comment to use a custom font. Fill in the path to your font file.
# OVERLAY_FONT = "Free_Mono_10.pcf"
request_display_config(320, 240)
display = supervisor.runtime.display
FETCH_DELAY = 12
# Set up where we'll be fetching data from
DATA_SOURCE = "https://www.adafruit.com/api/products/6358"
TITLE_LOCATION = ["product_name"]
SALE_PRICE_LOCATION = ["product_sale_price"]
REG_PRICE_LOCATION = ["product_price"]
STOCK_LOCATION = ["product_stock"]
config_index = 0
TEXT_POSITIONS = [
{
"title_anchor_point": (0, 0),
"title_anchored_position": (4, 205 + 4),
"stock_anchor_point": (1.0, 1.0),
"stock_anchored_position": (display.width - 4, display.height - 4),
"price_anchor_point": (1.0, 0),
"price_anchored_position": (display.width - 4, 200),
"outline_size": 1,
"outline_color": 0x000000,
},
{
"title_anchor_point": (0, 0),
"title_anchored_position": (4, 205 + 4),
"stock_anchor_point": (1.0, 1.0),
"stock_anchored_position": (display.width - 4, display.height - 4),
"price_anchor_point": (1.0, 0),
"price_anchored_position": (display.width - 4, 200),
"outline_size": 0,
"outline_color": 0x000000,
},
{
"title_anchor_point": (0, 0),
"title_anchored_position": (4, 4),
"stock_anchor_point": (1.0, 0),
"stock_anchored_position": (display.width - 4, 26),
"price_anchor_point": (1.0, 0),
"price_anchored_position": (display.width - 4, 4),
"outline_size": 0,
"outline_color": 0x000000,
},
{
"title_anchor_point": (0, 0),
"title_anchored_position": (4, 4),
"stock_anchor_point": (1.0, 0),
"stock_anchored_position": (display.width - 4, 26),
"price_anchor_point": (1.0, 0),
"price_anchored_position": (display.width - 4, 4),
"outline_size": 1,
"outline_color": 0x000000,
},
{
"title_anchor_point": (0, 0),
"title_anchored_position": (4, -104),
"stock_anchor_point": (1.0, 0),
"stock_anchored_position": (display.width - 4, -126),
"price_anchor_point": (1.0, 0),
"price_anchored_position": (display.width - 4, -104),
"outline_size": 1,
"outline_color": 0x000000,
},
]
def apply_hotkey_visuals(index):
config = TEXT_POSITIONS[index]
fruitjam.text_fields[0]["anchor_point"] = config["title_anchor_point"]
fruitjam.text_fields[0]["position"] = config["title_anchored_position"]
fruitjam.text_fields[0]["outline_color"] = config["outline_color"]
fruitjam.text_fields[0]["outline_size"] = config["outline_size"]
if fruitjam.text_fields[0]["label"] is not None:
fruitjam.text_fields[0]["label"].anchor_point = config["title_anchor_point"]
fruitjam.text_fields[0]["label"].anchored_position = config[
"title_anchored_position"
]
fruitjam.text_fields[0]["label"].outline_size = config["outline_size"]
fruitjam.text_fields[0]["label"].outline_color = config["outline_color"]
fruitjam.text_fields[1]["anchor_point"] = config["stock_anchor_point"]
fruitjam.text_fields[1]["position"] = config["stock_anchored_position"]
fruitjam.text_fields[1]["outline_color"] = config["outline_color"]
fruitjam.text_fields[1]["outline_size"] = config["outline_size"]
if fruitjam.text_fields[1]["label"] is not None:
fruitjam.text_fields[1]["label"].anchor_point = config["stock_anchor_point"]
fruitjam.text_fields[1]["label"].anchored_position = config[
"stock_anchored_position"
]
fruitjam.text_fields[1]["label"].outline_size = config["outline_size"]
fruitjam.text_fields[1]["label"].outline_color = config["outline_color"]
fruitjam.text_fields[2]["anchor_point"] = config["price_anchor_point"]
fruitjam.text_fields[2]["position"] = config["price_anchored_position"]
fruitjam.text_fields[2]["outline_color"] = config["outline_color"]
fruitjam.text_fields[2]["outline_size"] = config["outline_size"]
if fruitjam.text_fields[2]["label"] is not None:
fruitjam.text_fields[2]["label"].anchor_point = config["price_anchor_point"]
fruitjam.text_fields[2]["label"].anchored_position = config[
"price_anchored_position"
]
fruitjam.text_fields[2]["label"].outline_size = config["outline_size"]
fruitjam.text_fields[2]["label"].outline_color = config["outline_color"]
def format_data(json_data):
if "product_sale_price" in json_data:
json_data["product_sale_price"] = f'${json_data["product_sale_price"]}'
else:
json_data["product_sale_price"] = f'${json_data["product_price"]}'
json_data["product_stock"] = f'Stock: {json_data["product_stock"]}'
# the current working directory (where this file is)
cwd = ("/" + __file__).rsplit("/", 1)[0]
fruitjam = FruitJam(
url=DATA_SOURCE,
json_path=(TITLE_LOCATION, STOCK_LOCATION, SALE_PRICE_LOCATION),
status_neopixel=board.NEOPIXEL,
default_bg=BG_COLOR,
json_transform=[format_data],
debug=True,
)
fruitjam.remove_all_text()
fruitjam.add_text(
text_font=OVERLAY_FONT, text_wrap=35, text_maxlen=180, text_color=0xFFFFFF, outline_size=1
) # title
fruitjam.add_text(
text_font=OVERLAY_FONT, text_wrap=0, text_maxlen=30, text_color=0xFFFFFF, outline_size=1
) # stock
fruitjam.add_text(
text_font=OVERLAY_FONT, text_wrap=0, text_maxlen=30, text_color=0xFFFFFF, outline_size=1
) # price
apply_hotkey_visuals(config_index)
fruitjam.neopixels.brightness = 0.1
fetch_success = False
data_values = None
last_fetch_time = 0
while not fetch_success:
try:
data_values = fruitjam.fetch()
fruitjam._text[2]["label"].scale = 2 # pylint: disable=protected-access
fetch_success = True
last_fetch_time = time.monotonic()
except ValueError as e:
# no value found for product_sale_price
print("product_sale_price not found. Using regular price.")
print(fruitjam.json_path)
fruitjam.json_path = (TITLE_LOCATION, STOCK_LOCATION, ["product_price"])
print(fruitjam.json_path)
time.sleep(5)
except (RuntimeError, ConnectionError, OSError) as e:
print("Some error occured, retrying! -", e)
time.sleep(5)
old_btn1 = False
old_btn2 = False
old_btn3 = False
while True:
btn1_pressed = fruitjam.button1
if btn1_pressed and not old_btn1:
config_index = (config_index + 1) % len(TEXT_POSITIONS)
apply_hotkey_visuals(config_index)
now = time.monotonic()
if last_fetch_time + FETCH_DELAY <= now:
try:
data_values = fruitjam.fetch()
print(f"type: {fruitjam.text_fields[1]['label']}")
fruitjam._text[2]["label"].scale = 2 # pylint: disable=protected-access
fetch_success = True
last_fetch_time = time.monotonic()
except (ValueError, RuntimeError, ConnectionError, OSError) as e:
print("Some error occured, retrying! -", e)
old_btn1 = btn1_pressed
This project creates a live product information overlay that fetches data from Adafruit's website and displays it on your HDMI monitor via the RP2350's HSTX DVI output. Below breaks down how each section works:
Display Setup and Configuration
The code starts by requesting a 320x240 display configuration through the FruitJam peripheral system. This sets up the HSTX DVI video output to your HDMI display. The supervisor.runtime.display gives us access to CircuitPython's built-in display driver that's already configured for DVI output.
from adafruit_fruitjam.peripherals import request_display_config BG_COLOR = 0x0000FF request_display_config(320, 240) display = supervisor.runtime.display
Data Source Configuration
These variables define what data to fetch and where to find it in the JSON response. The lists contain JSONPath expressions. In this case, simple field names that tell the FruitJam library exactly which values to extract from Adafruit's product API.
DATA_SOURCE = "https://www.adafruit.com/api/products/6358" TITLE_LOCATION = ["product_name"] SALE_PRICE_LOCATION = ["product_sale_price"] REG_PRICE_LOCATION = ["product_price"] STOCK_LOCATION = ["product_stock"]
Visual Layout Presets
The TEXT_POSITIONS array contains five different overlay configurations. Each preset defines:
- anchor_point: How text is aligned (0,0 = top-left, 1,1 = bottom-right)
- anchored_position: Pixel coordinates for placement
- outline_size/color: Text outline styling for readability over any background
For example, the first preset places the title at the bottom-left with a black outline, while preset 3 moves everything to the top of the screen. This gives you different overlay styles depending on your content.
Dynamic Layout
This function updates both the internal FruitJam configuration and any existing on-screen labels when switching between visual presets. It handles both the data structure and the actual DisplayIO label objects.
def apply_hotkey_visuals(index):
config = TEXT_POSITIONS[index]
fruitjam.text_fields[0]["anchor_point"] = config["title_anchor_point"]
# ... applies config to all three text fields
Data Formatting
Before displaying the data, this function adds proper formatting - dollar signs for prices and "Stock:" labels. It also handles the case where a product isn't on sale by using the regular price instead.
def format_data(json_data):
if "product_sale_price" in json_data:
json_data["product_sale_price"] = f'${json_data["product_sale_price"]}'
else:
json_data["product_sale_price"] = f'${json_data["product_price"]}'
json_data["product_stock"] = f'Stock: {json_data["product_stock"]}'
FruitJam Library Initialization
The FruitJam library handles all the networking, JSON parsing, and display management. It's configured with:
- The API endpoint to fetch from
- Which JSON fields to extract
- A NeoPixel for network status indication
- The background color for the display
- The data formatting function to apply before display
fruitjam = FruitJam(
url=DATA_SOURCE,
json_path=(TITLE_LOCATION, STOCK_LOCATION, SALE_PRICE_LOCATION),
status_neopixel=board.NEOPIXEL,
default_bg=BG_COLOR,
json_transform=[format_data],
debug=True,
)
Text Field Configuration
This sets up three text fields with different properties. The title field has text wrapping enabled (35 characters per line) and can hold longer text, while the stock and price fields are shorter with no wrapping.
The text positions and sizes are optimized for the default typeface, but you could pick your own using the fruitjam.add_text(text_font=xxxx) call.
fruitjam.remove_all_text() fruitjam.add_text(text_wrap=35, text_maxlen=180, text_color=0xFFFFFF, outline_size=1) # title fruitjam.add_text(text_wrap=0, text_maxlen=30, text_color=0xFFFFFF, outline_size=1) # stock fruitjam.add_text(text_wrap=0, text_maxlen=30, text_color=0xFFFFFF, outline_size=1) # price
Network Error Handling
The main loop handles two key functions: button debouncing for layout switching and automatic data refresh every 12 seconds. The button press cycles through the five visual presets, while the timer ensures your product information stays current.
while True:
btn1_pressed = fruitjam.button1
if btn1_pressed and not old_btn1:
config_index = (config_index + 1) % len(TEXT_POSITIONS)
apply_hotkey_visuals(config_index)
now = time.monotonic()
if last_fetch_time + FETCH_DELAY <= now:
# Refresh data every 12 seconds
DVI Video Output
While not explicitly shown in this code, the magic happens through CircuitPython's built-in support for the RP2350's HSTX peripheral. When you call request_display_config(), it configures the HSTX pins for DVI output, and supervisor.runtime.display provides a standard DisplayIO interface that automatically sends your graphics to the HDMI display. The FruitJam library uses this display object to render text overlays with hardware acceleration.
Page last edited September 22, 2025
Text editor powered by tinymce.
Fruit Jam Chyron Setup & Use
Settings File
The Fruit Jam's ESP32-C6 WiFi co-processor allows us to go online for data. Edit or create a settings.toml file on the Fruit Jam to provide the SSID and password for your WiFi access point as shown here. A simple text editor on your computer is sufficient. Save the file to the root/main directory on the CIRCUITPY drive which appears when you plug the Fruit Jam into your computer via USB.
Plugs
Plug the Fruit Jam into the computer via USB-C.
Plug one end of the HDMI cable into the DVI-D Video Output jack on the Fruit Jam.
Capture Card
Plug the HDMI Video Capture card onto the HDMI cable, then plug it into a USB port on your computer. It will show up on the computer as a video source.
OBS Setup
On it's own, the Fruit Jam Chyron displays text on a solid blue screen. Not super interesting on its own! However, we'll composite it over live video using Open Broadcaster Software (OBS) to give our livestreams some extra spice.
OBS is a great choice because it is fully featured, free and open source, and includes filters for color-keying video layers.
First, add your webcam or other main camera as usual (the one pointing at the host) to OBS. Then, add the Chyron.
Add Video Capture Source
The HDMI capture card shows up like any video camera source. Add a video source, name it "fruitjam" and then pick your HDMI capture device.
Add Color Key Filter
With the fruijam source layer selected, click the Filters button.
In the Effects Filters section, select Color Key.
Now, adjust the Color Key settings to key out the blue. Pick Key Color Type = Blue.
You can then adjust the Similarity value to remove any fringing around the letters where the blue value has been darkened or lightened during the capture conversion. The hard, aliased pixels of the Fruit Jam text help keep this potential issue to a minimum.
You'll now see a nice, clean matte around the text in your output monitor view, as shown here. Now Lars can focus on his hosting duties while the lower third takes care of itself!
During Broadcast
The chyron will automatically update with live data every 12 seconds. You can adjust the position and text outline on-screen by pressing the Fruit Jam's Button 1 as shown here:
Page last edited September 22, 2025
Text editor powered by tinymce.