Generating Text with ChatGPT, Pico W, & CircuitPython
2023-06-20 | By Adafruit Industries
License: See Original Project Displays Programmers Wireless
Courtesy of Adafruit
Guide by Jeff Epler
Overview
"As an AI language model, I am thrilled to be on the Raspberry Pi Pico W! This small yet powerful device enables seamless integration with a wide range of applications and systems, making it an ideal platform for AI and machine learning projects. The Raspberry Pi Pico W's versatility, simplicity and affordability make it a game-changer in the world of technology!" -- ChatGPT
Quote blocks like the one above and photos in this guide typically show text generated by ChatGPT.
In this guide, you will learn how to use OpenAI's ChatGPT API to generate text from a prompt using CircuitPython on the Raspberry Pi Pico W.
At startup, or when the arcade button is pressed, a new, original snippet of text will be generated on OpenAI's servers and shown on the OLED screen connected to your Pico W. Because of the random factor in the text ChatGPT generates, it's unlikely that two responses would ever be the same.
Since ChatGPT generates plausible text rather than making true statements, use this project only for situations where the truth is unimportant. For example, by default the request to ChatGPT asks for a description of an "unconventional but useful superpower".
It's easy to customize the prompt using any text editor. This guide has some tips for creating prompts of your own. This works by writing a sentence or two in natural human language describing what you'd like ChatGPT to generate; no complicated coding is needed to get a description of an imaginary plant instead, or even to generate text in French instead of English!
The code in this guide does use a paid API at OpenAI, but based on the pricing in March 2023, the cost to access the API for this project is measured in fractions of a cent, not in dollars. During the whole development process of this guide, the author's costs on OpenAI were less than $0.25.
Parts
Fully Reversible Pink/Purple USB A to micro B Cable - 1m long
Monochrome 1.3" 128x64 OLED graphic display - STEMMA QT / Qwiic
Adafruit PiCowbell Proto for Pico - Reset Button & STEMMA QT
1 x 0.1" male header
1 x Quick-Connect Wires
1 x Right-angle headers
1 x M2 Stand-offs
Installing 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 working on your board.
Download the latest version of CircuitPython for the Raspberry Pi Pico W from circuitpython.org
Click the link above and download the latest UF2 file.
Download and save it to your desktop (or wherever is handy).
Start with your Pico W unplugged from USB. Hold down the BOOTSEL button, and while continuing to hold it (don't let go!), plug the Pico W into USB. Continue to hold the BOOTSEL button until the RPI-RP2 drive appears!
If the drive does not appear, unplug your Pico W, and go through the above process again.
A lot of people end up using charge-only USB cables and it is very frustrating! So, make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RPI-RP2.
Drag the adafruit_circuitpython_etc.uf2 file to RPI-RP2.
The RPI-RP2 drive will disappear, and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Flash Resetting UF2
If your Pico W ever gets into a really weird state and doesn't even show up as a disk drive when installing CircuitPython, try installing this 'nuke' UF2 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 nuking, re-install CircuitPython.
Create an account with OpenAI
The OpenAI platform is managed by OpenAI and changes at their discretion, and so the details may be slightly different from what is documented here.
In your web browser, visit https://platform.openai.com/
Click the "sign up" link. Then, you can use your e-mail to sign up, or an existing Google or Microsoft account.
OpenAI may require additional steps such as e-mail or phone verification before you can log in to your account.
Once you have completed the verification process and logged in, you will next create an API key. Use the menu in the far upper right corner (probably labeled "Personal") and then select "View API Keys".
Then, create a fresh API key by clicking "Create new secret key".
Save this secret key in the file settings.toml on the CIRCUITPY drive in a line that looks like
OPENAI_API_KEY="sk-b6...kP5"
This file also requires your WiFi credentials, see the next page of the guide for the details.
At the time of writing, OpenAI provides a free credit with new accounts. After the free credit is used or expires, you'll need to enter a credit card in your billing information to keep using the service.
Using the project tends to cost a few cents per session at most, and it's easy to limit your monthly bill to a pre-set amount such as $8.00.
To set a hard usage limit per month, visit the "Usage Limits" section of the OpenAI website.
This graph shows the author's usage costs while developing and playtesting an app, a total of $1.27 in API calls.
Configuring the settings.toml File
This project depends on you adding your WiFi settings and OpenAI API key in order to generate the text adventure.
Plug your CircuitPython board into your computer via a known good data + power USB cable. Your board should show up as a thumb drive in your File Explorer / Finder (depending on your operating system) named CIRCUITPY.
Create a file with the name settings.toml in the root directory of the CIRCUITPY drive.
Edit it to contain the keys WIFI_SSID, WIFI_PASSWORD, and OPENAI_API_KEY. (It's also OK for it to contain other keys)
Your file should look similar to the one shown below:
OPENAI_API_KEY="sk-b6...kP5" WIFI_SSID="GuestAP" WIFI_PASSWORD="i trust u"
3D Printing
It's like watching a delicious pizza come out of the oven, but instead of pizza, it's a perfectly printed part. – ChatGPT
This project uses a variant of the case from the guide Pico W HTTP Server with CircuitPython. The case body was remixed using the free and open source OpenSCAD (runs on Windows, Mac, & Linux) to make it deeper and add a hole for the arcade button. Use the face plate from the original project but grab the printable STL for the new case body using the link below or grab the OpenSCAD source if you want to customize it further. Follow the printing instructions from the HTTP Server guide.
enlarged_picow.stl (printable case body)
module base() { import("picowServerCase_mainCase_v1.stl", convexity=4); } module clip_removed() { difference() { base(); translate([-69,11,-40]) cube(25); } } module z_slice(z0, dz, sc, extent=300) { render(convexity=4) intersection() { translate([0,0,-z0]) children(); translate([-extent/2, -extent/2, 0]) cube([extent, extent, dz]); } } module elongated() { color("red") z_slice(-32, 4) clip_removed(); color("green") translate([0,0,4]) scale([1,1,8]) z_slice(-28, 1) clip_removed(); translate([0,0,12]) z_slice(-27, 30) clip_removed(); } module arcaded() { difference() { elongated(); translate([-45, 53/2, 18]) rotate([0,90,0]) cylinder(d=29.5, h=10); } } arcaded();
Assembly
As an AI language model, I don't have personal experiences to share, but I can generate a sentence for you: "Once, I accidentally soldered a piece of spaghetti onto my circuit board, and it surprisingly still worked." – ChatGPT
Some assembly photos are from the guide "Pico W HTTP Server with CircuitPython", so the parts shown don't match the rest of the project.
Solder socket headers to the PiCowbell. You can use the Pico W as a jig to keep the headers secure.
Attach four M2 standoffs to the Pico W's four mounting holes with M2 nuts.
Attach the Pico W to the case lid with four M2 screws.
Attach the OLED screen with four M2.5 screws and nuts.
Plug the PiCowbell into the Pico W. The STEMMA port on the PiCowbell should be below the USB port on the Pico W.
Connect the OLED to the STEMMA port on the PiCowbell with a STEMMA QT cable.
Solder two 2-pin sections of 90-degree header:
Pin 14 and the GND pin next to it
Pin 10 and the GND pin next to it
Point the long side of the header outwards.
Take two of the quick-connect wire harnesses. Press the ends gently but firmly onto the arcade button. Then pass the wire harnesses through the hole in the case, through the threaded nut, and attach them to the right-angle headers.
Plug the button in on Pin 14 and the LED on Pin 10. Check the polarity of the LED, so that side marked "-" on the Arcade Button is connected to GND.
At this point, you can load the code and make sure that the button and LED work. If the LED is not working, try reversing the connection by simply plugging the connector in the other way. If nothing's working, try swapping the two connectors.
Now, secure the arcade button in place with the nut, then carefully curl the wires around the interior of the box until you can snap the lid into place.
Coding the Text Generator
Learning to program is like having a superpower that enables you to create something out of nothing with just a few lines of code. – ChatGP
Text Editor
Adafruit recommends using the Mu editor for editing your CircuitPython code. You can get more info in this guide.
Alternatively, you can use any text editor that saves simple text files.
Download the Project Bundle
Your project will use a specific set of CircuitPython libraries and the code.py file. To get everything you need, click on the Download Project Bundle link below, and uncompress the .zip file.
Hook your Pico W to your computer via a known good USB data+power cable. It should show up as a thumb drive named CIRCUITPY.
Using File Explorer/Finder (depending on your Operating System), 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.
Once the code restarts, it will connect to WiFi and start using OpenAI to generate text according to the default prompt, "Write 1 sentence starting "you can" about an unconventional but useful superpower". The code uses OpenAI's "streaming" mode, so the response appears by chunks, also known as tokens.
Head on to the next pages for advice on how to modify it with your own original prompts as well as explanation of key parts of the code.
# SPDX-FileCopyrightText: 2023 Jeff Epler for Adafruit Industries # SPDX-License-Identifier: MIT import json import os import ssl import traceback import board import displayio import digitalio import keypad import socketpool import supervisor from wifi import radio import adafruit_requests import adafruit_displayio_ssd1306 from adafruit_bitmap_font.bitmap_font import load_font from adafruit_display_text import wrap_text_to_pixels from adafruit_display_text.bitmap_label import Label from adafruit_ticks import ticks_add, ticks_less, ticks_ms # Choose your own prompt and wait messages, either by changing it below inside # the """triple quoted""" string, or by putting it in your settings.toml file, # like so: # # MY_PROMPT="Give me an idea for a gluten free, keto dinner. Write one sentence" # PLEASE_WAIT="Cooking something up just for you" # # Experimentation is best to figure out what works. Usually you'll want to ask # for just one sentence or paragraph, since the 128x32 pixel screen can't hold # much text! # Here are some that the author found worked reasonably well: # Give me an idea for a plant-based dinner. Write one sentence # # Give jepler (they/them) a cliched and flowery description as a comic book # supervillain. write one sentence. # # Invent and describe an alien species. write one sentence # # Invent a zany 'as seen on' product that can't possibly work. One sentence # # Tell a 1-sentence story about a kitten and a funny mishap # # Make up a 1-sentence fortune for me # # In first person, write a 1-sentence story about an AI avoiding boredom in a creative way. # # Pick an everyday object (don't say what it is) and describe it using only the # ten hundred most common words. # # Invent an alien animal or plant, name it, and vividly describe it in 1 # sentence # # Invent and vividly describe an alien species. write one paragraph prompt=os.getenv("MY_PROMPT", """ Write 1 sentence starting "you can" about an unconventional but useful superpower """).strip() please_wait=os.getenv("PLEASE_WAIT", """ Finding superpower """).strip() openai_api_key = os.getenv("OPENAI_API_KEY") nice_font = load_font("helvR08.pcf") line_spacing = 9 # in pixels # i2c display setup displayio.release_displays() oled_reset = board.GP9 # STEMMA I2C on picowbell i2c = board.STEMMA_I2C() display_bus = displayio.I2CDisplay(i2c, device_address=0x3D, reset=oled_reset) WIDTH = 128 HEIGHT = 64 display = adafruit_displayio_ssd1306.SSD1306( display_bus, width=WIDTH, height=HEIGHT ) if openai_api_key is None: input("Place your\nOPENAI_API_KEY\nin settings.toml") display.auto_refresh = False class WrappedTextDisplay(displayio.Group): def __init__(self): super().__init__() self.offset = 0 self.max_lines = display.height // line_spacing for i in range(self.max_lines): self.make_label("", i * line_spacing) self.lines = [""] self.text = "" def make_label(self, text, y): result = Label( font=nice_font, color=0xFFFFFF, background_color=0, line_spacing=line_spacing, anchor_point=(0, 0), anchored_position=(0, y), text=text) self.append(result) def add_text(self, new_text): print(end=new_text) if self.lines: text = self.lines[-1] + new_text else: text = new_text self.lines[-1:] = wrap_text_to_pixels(text, display.width, nice_font) self.scroll_to_end() def set_text(self, text): print("\n\n", end=text) self.text = text self.lines = wrap_text_to_pixels(text, display.width, nice_font) self.offset = 0 def show(self, text): self.set_text(text) self.refresh() def add_show(self, new_text): self.add_text(new_text) self.refresh() def scroll_to_end(self): self.offset = self.max_offset() def scroll_next_line(self): max_offset = self.max_offset() self.offset = (self.offset + 1) % (max_offset + 1) def max_offset(self): return max(0, len(self.lines) - self.max_lines) def on_last_line(self): return self.offset == self.max_offset() def refresh(self): lines = self.lines # update labels from wrapped text, accounting for scroll offset for i in range(len(self)): offset_i = i + self.offset if offset_i >= len(lines): text = "" else: text = lines[offset_i] if text != self[i].text: self[i].text = text # Actually update the display all at once display.refresh() display.root_group = wrapped_text = WrappedTextDisplay() def wait_button_scroll_text(): led.switch_to_output(True) keys.events.clear() deadline = ticks_add(ticks_ms(), 5000 if wrapped_text.on_last_line() else 1000) while True: if (event := keys.events.get()) and event.pressed: break if wrapped_text.max_offset() > 0 and ticks_less(deadline, ticks_ms()): wrapped_text.scroll_next_line() wrapped_text.refresh() deadline = ticks_add(deadline, 5000 if wrapped_text.on_last_line() else 1000) led.value = False if radio.ipv4_address is None: wrapped_text.show(f"connecting to {os.getenv('WIFI_SSID')}") radio.connect(os.getenv('WIFI_SSID'), os.getenv('WIFI_PASSWORD')) requests = adafruit_requests.Session(socketpool.SocketPool(radio), ssl.create_default_context()) def iter_lines(resp): partial_line = [] for c in resp.iter_content(): if c == b'\n': yield (b"".join(partial_line)).decode('utf-8') del partial_line[:] else: partial_line.append(c) if partial_line: yield (b"".join(partial_line)).decode('utf-8') full_prompt = [ {"role": "user", "content": prompt}, ] keys = keypad.Keys((board.GP14,), value_when_pressed=False) led = digitalio.DigitalInOut(board.GP10) led.switch_to_output(False) try: while True: wrapped_text.show(please_wait) with requests.post("https://api.openai.com/v1/chat/completions", json={"model": "gpt-3.5-turbo", "messages": full_prompt, "stream": True}, headers={ "Authorization": f"Bearer {openai_api_key}", }, ) as response: wrapped_text.set_text("") if response.status_code != 200: wrapped_text.show(f"Uh oh! {response.status_code}: {response.reason}") else: wrapped_text.show("") for line in iter_lines(response): led.switch_to_output(True) if line.startswith("data: [DONE]"): break if line.startswith("data:"): content = json.loads(line[5:]) try: token = content['choices'][0]['delta'].get('content', '') except (KeyError, IndexError) as e: token = None led.value = False if token: wrapped_text.add_show(token) wait_button_scroll_text() except Exception as e: # pylint: disable=broad-except traceback.print_exception(e) # pylint: disable=no-value-for-parameter print(end="\n\n\nAn error occurred\n\nPress button\nto reload") display.root_group = displayio.CIRCUITPYTHON_TERMINAL display.auto_refresh = True while True: if (event1 := keys.events.get()) and event1.pressed: break supervisor.reload()
Customizing the Text Generator
Creating a chatbot prompt is like crafting a key that unlocks a door to productive conversation with your audience. -- ChatGPT
With this project, it's easy to customize the "prompt", meaning the text that is sent to ChatGPT and used to generate the response. You can do this by adding or editing two lines in the settings.toml file on your CIRCUITPY drive. This isn't coding per se (because you write in plain english), though there are a few simple rules you have to follow:
You can't embed newlines or blank lines, so keep each item on its own single line
Double quote characters (") have special meaning, so if you want to put quotes inside your prompt or the "please wait" text, the simplest solution is to use single quotes (') instead
If your editor replaces double quote characters ("...") with smart quote characters (“…”) it won't work, so disable this or use a simple text editor that doesn't do it.
Add your lines to the settings.toml but don't remove the lines for WiFi and API keys you made earlier!
For example, here are the settings.toml lines for a 'magical university' prompt, together with other settings required for the project:
PLEASE_WAIT="Somewhere in the halls of Jynx University..." MY_PROMPT="Write a vivid description of a magical mishap at the Jynx University for Witches and Warlocks (1 sentence; no frogs; no injuries; don't say the school's name)" OPENAI_API_KEY="sk-b6...kP5" WIFI_SSID="GuestAP" WIFI_PASSWORD="i trust u"
A few seconds after saving the file, CircuitPython will re-load and show text based on the new prompt.
It takes trial and error to transform a mediocre prompt into a good one. So, feel free to try slight re-wordings of your prompt to see what variations you like best! Here are some of the things I try to do when composing one:
It seems silly but asking for a "vivid description" actually does tend to make ChatGPT produce more descriptive language
Ask for a short length that's more likely to fit on the screen ("1 sentence")
You can tell it things you don't want ("no frogs; no injuries")
You can ask it to return the answer in a certain format
If the prompt doesn't end with a sentence-ending period sometimes ChatGPT's response starts with just a "." followed by a blank line, so always include a "."
Of course, these things are only guidelines and the text returned by ChatGPT may not precisely match what you've asked for.
If you want to keep a prompt around for later, you can put a comment character (#) in front of each line, then write your new prompt above and below it. Then, to switch prompts, simply put # in front of the current prompt and remove them from the next prompt you want to use. If all the lines are marked with # at the beginning of the line, then the prompt that is built into the program will be used. Here are some other prompts I tested, but commented out:
#PLEASE_WAIT="pip install --random" #MY_PROMPT="Make up a humorous, obviously fictional module on pypi (give it a name). Use the following format:\npip install <modulename>\n\n<1 sentence blurb>" #PLEASE_WAIT="This Person Does Not Exist" #MY_PROMPT="Invent a character and describe them vividly in 1 sentence"
You can write prompts in other languages as well (the font included in this project supports many European languages):
PLEASE_WAIT="Trouver votre superpuissance" MY_PROMPT="Ecrivez 1 phrase commençant par 'vous pouvez' à propos d'une superpuissance non conventionnelle mais utile."
While using Large Language Models like ChatGPT for "factual things" is not the best idea, and this guide has concentrated on fictional items, you may also find that there are practical things you could do with it, such as get meal ideas:
MY_PROMPT="Give me an idea for a gluten free, keto dinner. Write one sentence" PLEASE_WAIT="Cooking something up just for you"
Here are some more prompts the author thought were amusing or useful:
Invent a zany 'as seen on' product that can't possibly work. One sentence
Tell a 1-sentence story about a kitten and a funny mishap
Make up a 1-sentence fortune for me
In first person, write a 1-sentence story about an AI avoiding boredom in a creative way
Pick an everyday object (don't say what it is) and describe it using only the ten hundred most common words
Invent an alien animal or plant, name it, and vividly describe it in 1 sentence
Invent and vividly describe an alien species. write one paragraph
What's one possible synergy between CircuitPython & ChatGPT? (1 sentence, practical)
Code Walkthrough
CircuitPython could potentially be used to create a chatbot that can interact with users through hardware devices like sensors and LEDs -- ChatGPT
This program is large and complex. This guide will gloss over a lot of the details; if you'd like to learn more about using WiFi or displayio on CircuitPython there are dedicated guides for those topics. Below you'll find explanations of some key parts of the program functionality.
Fetching optional items from settings.toml
os.getenv can be used to fetch string values from the settings.toml file. Providing a second argument gives a default value when the key is not present. Calling strip() removes any whitespace from the start or end of the string, which can trip up ChatGPT. This is an easy way to add "no-code" customizations to your own CircuitPython programs:
prompt=os.getenv("MY_PROMPT", """ Write 1 sentence starting "you can" about an unconventional but useful superpower """).strip() please_wait=os.getenv("PLEASE_WAIT", """ Finding superpower """).strip()
Vertically scrolling wrapped text
There aren't yet any CircuitPython libraries for dealing with larger amounts of text. This project includes a class called WrappedTextDisplay which can help.
It allows showing a screen full of text which can be part of a larger document. The text can be scrolled by lines, and new words can be added at the end of the text incrementally, with relatively good performance.
This functionality is very closely matched to the needs of this program, but it could provide some ideas for a future library.
The text is parsed into lines as it arrives. A certain number of lines can be visible on the screen at one time, an each one of those visible lines of text gets its own bitmap label object. Scrolling consists of changing which logical line in the document is the first line visible on the display. This approach performs relatively well in terms of both repaint time—especially while streaming content from ChatGPT—and memory used.
class WrappedTextDisplay(displayio.Group): ...
Once a response from ChatGPT is complete, this function repeatedly scrolls through the full response if it's more than one screenful, then returns when the button is pressed:
def wait_button_scroll_text(): led.switch_to_output(True) deadline = ticks_add(ticks_ms(), 5000 if wrapped_text.on_last_line() else 1000) while True: if (event := keys.events.get()) and event.pressed: break if wrapped_text.max_offset() > 0 and ticks_less(deadline, ticks_ms()): wrapped_text.scroll_next_line() wrapped_text.refresh() deadline = ticks_add(deadline, 5000 if wrapped_text.on_last_line() else 1000) led.value = False
Streaming an HTTP response by lines
When the OpenAI API is used with "stream": True, the text is returned as it is generated. You can check the OpenAI API documentation for more details, but in short, each line starts "data:" followed by a JSON document all on one line.
By using the iter_lines function you can handle the HTTP response one line at a time:
def iter_lines(resp): partial_line = [] for c in resp.iter_content(): if c == b'\n': yield (b"".join(partial_line)).decode('utf-8') del partial_line[:] else: partial_line.append(c) if partial_line: yield (b"".join(partial_line)).decode('utf-8')
Prompt and Request
In this program, the Prompt is simple, it consists of just a single "user" message. The content of the message is the prompt, either from the top of the code or from settings.toml:
full_prompt = [ {"role": "user", "content": prompt}, ]
When there is a dialogue between ChatGPT and the user that takes place over multiple exchanges, then the full_prompt can consist of multiple messages with various roles (system, user, and assistant). But in this project, there's just one prompt and one response.
Within the program's forever loop, the full prompt is put together with other information in the json payload of the request (See OpenAI's dedicated documentation pages for more details on the API):
while True: wrapped_text.show(please_wait) with requests.post("https://api.openai.com/v1/chat/completions", json={"model": "gpt-3.5-turbo", "messages": full_prompt, "stream": True}, headers={ "Authorization": f"Bearer {openai_api_key}", }, ) as response:
If the response is successful, then it can be read line by line; each line may contain some additional part of the response (called the "delta"). This text is added to the display, which is refreshed. Just to emphasize that something is happening, the LED blinks during this process. At the end, the program waits for the button to be pressed before it repeats the process again.
# if response.status_code != 200: wrapped_text.show(f"Uh oh! {response.status_code}: {response.reason}") else: wrapped_text.show("") for line in iter_lines(response): led.switch_to_output(True) if line.startswith("data: [DONE]"): break if line.startswith("data:"): content = json.loads(line[5:]) try: token = content['choices'][0]['delta'].get('content', '') except (KeyError, IndexError) as e: token = None led.value = False if token: wrapped_text.add_show(token) wait_button_scroll_text()
Error handling
In the case of most errors, the program catches the error so that a press of the button can re-start from scratch. The original error is shown on the REPL for troubleshooting purposes.
except Exception as e: # pylint: disable=broad-except traceback.print_exception(e) # pylint: disable=no-value-for-parameter print(end="\n\n\nAn error occurred\n\nPress button\nto reload") display.root_group = displayio.CIRCUITPYTHON_TERMINAL display.auto_refresh = True while True: if (event1 := keys.events.get()) and event1.pressed: break supervisor.reload()
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.
Visit TechForum