Badge Hardware
The badge uses the Raspberry Pi RP2040 microcontroller - a dual-core ARM processor running at a default speed of 125MHz. (CircuitPython currently only uses one core.) The badge has 8MB of FLASH storage, of which about 7.1MB is available to the user. The badge also has three break-out headers for even more customization.
The basic badge has the following hardware:
- Indicator LED (on the back)
- 9 Neopixels (addressable RGB LEDs)
- 9 touch pads
- Mini Speaker with Amplifier
- 200x200 ePaper Display - Monochrome (Black White)
- STEMMA-QT Connector (I2C)
- USB-C (power, serial, storage)
The badge has the following assignments:
Pin | Name | Description | Pin | Name | Description |
---|---|---|---|---|---|
GP0 | TX | Transmit pin for IR | GP16 | I2S_DATA | I2S audio data pin * |
GP1 | RX | Receive pin for IR | GP17 | I2S_BCK | I2S audio bit clock pin * |
GP2 | I2C_SDA | I2C data line | GP18 | I2S_LRCK | I2S audio left/right clock pin * |
GP3 | I2C_SCL | I2C clock line | GP19 | TOUCH1 | Touch pad 1 |
GP4 | LED | Indicator LED | GP20 | TOUCH2 | Touch pad 2 |
GP5 | NEOPIXEL | Addressable RGB LEDs | GP21 | TOUCH3 | Touch pad 3 |
GP6 | SOUND | Mini Speaker with Amplifier | GP22 | TOUCH4 | Touch pad 4 |
GP7 | SOUND_EN | Sound enable pin | GP23 | TOUCH5 | Touch pad 5 |
GP8 | DISPLAY_EN | Display enable pin | GP24 | TOUCH6 | Touch pad 6 |
GP9 | SPI_BUSY | ePaper busy pin | GP25 | TOUCH7 | Touch pad 7 |
GP10 | SPI_RESET | ePaper reset pin | GP26 | TOUCH8 | Touch pad 8 |
GP11 | SPI_DC | ePaper data/command select pin | GP27 | TOUCH9 | Touch pad 9 |
GP12 | SPI_MISO | not exposed | -- | DISPLAY() | ePaper display interface |
GP13 | SPI_CS | ePaper chip select pin | -- | I2C() | I2C interface on STEMMA-QT connector |
GP14 | SPI_SCK | ePaper clock pin | -- | SPI() | SPI interface |
GP15 | SPI_MOSI | ePaper receive pin | -- | VID() | function returns board code |
* the I2S pins are broken out on an unpopulated header on the back of the PCB. If you are not using I2S, these pins may be used as general purpose digital IO pins.
When writing CircuitPython, you may reference the pins using the pin name - e.g. board.GP5. For pins with an assigned function, you may also reference the pins using the functional name - e.g. board.NEOPIXEL.
Note: There is important additional information for the badge at AoSC.cc
The board is an object (an instance of a class with properties and methods). To access and of the hardware, we use the board object. The most common use of the board object is to access all of the GPIO pins. For the badge, the I2C adn DISPLAY` interfaces are also convenient to use.
CircuitPython Firmware and Libraries
The Explorer Badge has CircuitPython pre-installed along with several libraries which help support the hardware. There is also a custom libraries to demonstrate coding to each of the hardware features (listed above). More on that library at the end.
CircuitPython is constantly being developed and improved. From time to time you may want to update the CircuitPython core and libraries. You will find the CircuitPython firmware at circuitpython.org. Go to the Downloads tab and search for the Explorer board.
When you update the CircuitPython firmware, you will also want to update any community libraries you have installed. The libraries are contained in a ZIP file available at circuitpython.org/libraries There are lots of libraries you may wish to use. The badge comes with the following pre-integrated (aka frozen* or built-in) libraries:
- neopixel
- adafruit_bitmap_font
- adafruit_display_shapes
- adafruit_display_text
- adafruit_hid
- adafruit_irremote
* The frozen buildt-in libraries do not need to be installed separately.
There are two additional libraries which are maintained by the badge project repository:
- explorer.py* - This library provides a simplified interface to all of the hardware features of the badge.
- simpletimer.py - This library provides a timer class for use in your projects.
* It is not necessary to use the explorer.py library. All of the excises in this tutorial are written without the library. The library does serve as a great learning tool. It provides examples of creating Python classes, properties, and methods.
These libraries may be updated from time to time with new features or bug fixes. You will find the latest code in GitLab repository CircuitPython lib folder.
Coding the Badge
The badge has a lot of integrated hardware. Covering everything in detail is beyond the time limit of an in-person workshop. This workshop content is intended to be a stepping off point for your further exploration. There is a section here for each hardware area and an Exercise with suggestions for exploration.
The badge is a typical RP2040 configuration (similar to the Raspberry Pi PICO). All of the CircuitPython tutorials apply ... and there are over 1000 of them!
All of the examples in this workshop are written using typing. Python typing is a way to document the intended type of data of variables. The syntax is to add a colon : After the variable name and then the data type. While Python is a dynamically typed language, using the typing syntax can help catch errors and make code easier to understand.
CircuitPython makes all of the hardware available through GPIO pins (they have a naming convention of GPn with some have special labels). There are also a few interfaces. The two most useful for this tutorial are I2C and DISPLAY.
We can view all of the available references from the REPL by importing the board library. Once we have the board, we can look at what is available using the dir() function.
Review: the CircuitPython REPL (Read Evaluate Print Loop) is accessed from the serial terminal by pressing CTRL-C. You know you have REPL when you see the prompt >>>.
Let's look at the hardware:
>>> import board
>>> dir(board)
['__class__', '__name__', 'DISPLAY', 'DISPLAY_EN',
'GP0', 'GP1', 'GP10', 'GP11', 'GP12', 'GP13', 'GP14', 'GP15', 'GP16',
'GP17', 'GP18', 'GP19', 'GP2', 'GP20', 'GP21', 'GP22', 'GP23', 'GP24',
'GP25', 'GP26', 'GP27', 'GP28', 'GP29', 'GP3', 'GP4', 'GP5', 'GP6', 'GP7',
'GP8', 'GP9', 'I2C', 'I2S_BCK', 'I2S_DATA', 'I2S_LRCK', 'IR_RX', 'IR_TX',
'LED', 'NEOPIXEL', 'SCL', 'SDA', 'SPEAKER', 'SPEAKER_EN', 'SPI',
'SPI_BUSY', 'SPI_CS', 'SPI_DC', 'SPI_MISO', 'SPI_MOSI', 'SPI_RESET', 'SPI_SCK',
'TOUCH1', 'TOUCH2', 'TOUCH3', 'TOUCH4', 'TOUCH5', 'TOUCH6', 'TOUCH7', 'TOUCH8', 'TOUCH9',
'VID']
>>>
>>> print(board.__dict__)
{
'__name__': 'board', 'board_id': 'bradanlanestudio_explorer_rp2040',
'GP0': board.GP0, 'GP1': board.GP1, 'IR_TX': board.GP0, 'IR_RX': board.GP1,
'GP2': board.GP2, 'GP3': board.GP3, 'SDA': board.GP2, 'SCL': board.GP3,
'GP4': board.GP4, 'LED': board.GP4,
'GP5': board.GP5, 'NEOPIXEL': board.GP5,
'GP6': board.GP6, 'GP7': board.GP7, 'SPEAKER': board.GP6, 'SPEAKER_EN': board.GP7,
'GP8': board.GP8, 'DISPLAY_EN': board.GP8,
'GP9': board.GP9, 'GP10': board.GP10, 'GP11': board.GP11, 'GP12': board.GP12,
'GP13': board.GP13, 'GP14': board.GP14, 'GP15': board.GP15,
'SPI_BUSY': board.GP9, 'SPI_RESET': board.GP10, 'SPI_DC': board.GP11, 'SPI_MISO': board.GP12,
'SPI_CS': board.GP13, 'SPI_SCK': board.GP14, 'SPI_MOSI': board.GP15,
'GP16': board.GP16, 'GP17': board.GP17, 'GP18': board.GP18,
'I2S_DATA': board.GP16, 'I2S_BCK': board.GP17, 'I2S_LRCK': board.GP18,
'GP19': board.GP19, 'GP20': board.GP20, 'GP21': board.GP21,
'GP22': board.GP22, 'GP23': board.GP23, 'GP24': board.GP24,
'GP25': board.GP25, 'GP26': board.GP26, 'GP27': board.GP27,
'TOUCH1': board.GP19, 'TOUCH2': board.GP20, 'TOUCH3': board.GP21,
'TOUCH4': board.GP22, 'TOUCH5': board.GP23, 'TOUCH6': board.GP24,
'TOUCH7': board.GP25, 'TOUCH8': board.GP26, 'TOUCH9': board.GP27,
'GP28': board.GP28, 'GP29': board.GP29,
'I2C': <function>, 'SPI': <function>, 'DISPLAY': <EPaperDisplay>, 'VID': <function>
}
>>>
>>> print(board.__dict__['TOUCH1'])
board.GP19
>>>
Simple Indicator LED
On the back of the badge, near the lanyard hole is a tiny LED. CircuitPython using this to indicate status.
- 1 flash = code completed without an error
- 2 flash = code ended due to an exception
- 3 flash = safe mode
The LED is also available when writing code.
From the previous exercise, we know we can access this as either board.LED or board.GP4.
CircuitPython considers this a digital GPIO pin.
A digital pin is either on or off - some documentation will described this as high or low, referring to the voltage level of the pin. Python uses the values True and False to represent the state of the pin.
There is a build-in library for working with digital pins. We will import the digitalio library to interact with the pin.
We first need to create a DigitalInOut object and then set the direction of the pin. To control the LED, we set its pin to OUTPUT. Once we have a digital pin that is set for output, we change its value between True and False to give the pin power or not.
Here is an example:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | import time import board import digitalio ''' setup the on-board LED ''' led = digitalio.DigitalInOut(board.LED) led.direction = digitalio.Direction.OUTPUT led.value = False # the initial status LED is OFF counter: int = 0 try: while True: time.sleep(0.5) print(counter) counter += 1 led.value = not led.value finally: led.value = False |
The LED should be blinking at 1Hz (once per second) because we are turing off for 0.5 seconds and then on for 0.5 seconds.
Tip: We are using a simple logic syntax in the code to toggle the value - not True is False and not False is True.
The LED is handy to use as a diagnostic tool as well as a status indicator for your code when you do not use the serial terminal.
Tip: The same code can be used (on one of the breakout pins) to create a signal which may be monitored with an oscilloscope. It's great for experiments and for performing timing tests. An oscilloscope can respond much faster than the human eye. This makes an oscilloscope a great tool for debugging timing issues.
Neopixel LEDs
A Neopixel LED is an addressable RGB LED. Addressable LEDs have a tiny built-in controller chip with takes a digital signal and uses it to control the color and intensity of the LED. Neopixels use a single GPIO pin to receive data to control the mix and intensity of of red, green, and blue for each LED. Additionally, the LEDs are chained together - with out and in connections - so the single pin controls any number of Neopixel LEDs.
The badge has 9 Neopixel LEDs on the PCB.
Controlling a Neopixel LED is more complex than a simple LED because the signals sent to the LEDs represent data and have specific timing requirements.
CircuitPython provides a library which handles all of the complexity. We will import the neopixel library to interact with the Neopixels.
Here is an example:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | import time import board import neopixel ''' setup the Neopixel LEDs ''' NEOPIXEL_COUNT: int = 9 leds = neopixel.NeoPixel(board.NEOPIXEL, NEOPIXEL_COUNT, brightness=0.15, auto_write=True) # lets quickly cycle all the neopixels through a range of colors colors: list = [0x0000FF, 0x00FF00, 0x00FFFF, 0xFF0000, 0xFF00FF, 0xFFFF00, 0xFFFFFF, 0x000000] COLOR_COUNT: int = 8 color_index: int = 0 counter: int = 0 try: while True: if not (counter % NEOPIXEL_COUNT): # each time the counter is a multiple of NEOPIXEL_COUNT color_index = (color_index + 1) % COLOR_COUNT # we change the color leds[counter % NEOPIXEL_COUNT] = colors[color_index] # change the color of a single neopixel counter += 1 print(counter) time.sleep(0.05) finally: leds.fill(0x000000) # set all the Neopixels to black |
This code loops forever, using the counter variable to both cycle through the Neopixels and cycle through the colors.
There are a few interesting things in this code.
First, colors are represented as hexadecimal numbers. This makes it somewhat easy to understand how to make a specific color. Each red, green, or blue color has 256 possible values (0 to 255 or 0x00 to 0xFF). The complete neopixel color is represented as 0xRRGGBB.
Second, Neopixels support being treated like a list. We can access any individual Neopixel as an index into the list. An example of this is in the code leds[counter % NEOPIXEL_COUNT] = colors[color_index] where we access one element of the leds and assign it a color from our colors list.
Third, with short strings of Neopixels, its easiest to use the auto_write option. For really long strings or when using multiple strings, it may be desirable to turn off auto_write and use the show() method. An example where this is handy is when a large number of Neopixels are organized as a matrix and used to display text, images, or glyphs.
Fourth, this example introduces the syntax of try, except, and finally (we are not using ``except`` in this example). Any code within the try section is executed. If an exception is raised, the code will jump to the optional except section. When the code ends, it will execute the optional finally section. Using try and finally is a convenient way to perform any cleanup operations - in this case we turn off the Neopixels.
Touch Pads
Each of the dots on the badge is a capacitive touch sensor.
Controlling a touch sensor requires special timing measurements and changing the GPIO pin state (input vs output).
CircuitPython provides a library which handles all of the complexity. We will import the touchio library to interact with the touch sensors.
Here is an example:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | import time import board import neopixel import touchio ''' setup the Neopixel LEDs ''' NEOPIXEL_COUNT: int = 9 leds = neopixel.NeoPixel(board.NEOPIXEL, NEOPIXEL_COUNT, brightness=0.15, auto_write=True) # lets quickly cycle all the neopixels through a range of colors colors: list = [0x0000FF, 0x00FF00, 0x00FFFF, 0xFF0000, 0xFF00FF, 0xFFFF00, 0xFFFFFF, 0x000000] COLOR_COUNT: int = 8 color_index: int = 0 ''' setup the capacitive touch sensors ''' TOUCH_PINS: list = [board.TOUCH1, board.TOUCH2, board.TOUCH3, board.TOUCH4, board.TOUCH5, board.TOUCH6, board.TOUCH7, board.TOUCH8, board.TOUCH9] TOUCH_COUNT:int = 9 touch_sensors = [None for i in range(TOUCH_COUNT)] # start with a list of `None` data for i in range(TOUCH_COUNT): touch_sensors[i] = touchio.TouchIn(TOUCH_PINS[i]) # the minimum sensitivity (aka threshold) is set when the touch sensor is created print(f"initialized touch {i:d} with threshold {touch_sensors[i].threshold:d}") try: while True: for i in range(TOUCH_COUNT): if touch_sensors[i].value: # teh raw touch value is greater that the threshold value leds[i] = 0x00FF00 print(f"touch #{i:d} has a value of {touch_sensors[i].raw_value:d}") pass else: leds[i] = 0x000000 pass time.sleep(0.05) finally: leds.fill(0x000000) |
Because we have the same number of touch pads as Neopixels, it is convenient to extend the previous example.
We setup each of the capacitive touch sensors. Unlike the Neopixels which used a single pin for all of the LEDs, touch sensors each have their own pin.
We need to initialize each pin. By creating a list of all of the pins, we can use a loop. Within the loop we create a TouchIn object and assign it to another list - the touch_sensors. When each touch sensor is created, the library automatically determines a minimum usable value - aka a threshold.
Notice how each touch sensor has a slightly different threshold.
In the while True: loop, we test each of the touch sensors. When the value is True it means the touch sensor has a raw value greater than its threshold.
Tip: By reading the raw value, it is possible to determine how much touch is present for each sensor.
If we ignore the value and only read the raw_value we can even detect proximity to the touch sensor. It is possible to make a crude Theremin using the raw_value data!
AI Prompt: for all touch sensors, create a rolling average of each touch sensor with a window of 10
Note: There is a extra code sample named badge_touch_plot.py which lets you visualize the touch sensor data as a graph using the thonny plotter feature.
I2C and 2-Wire Communication
I2C is a communication protocol.
What is special - and convenient - about I2C is its design to make one-to-many communications easy. What is one-to-many? Its mean that one device - the host - can communication with many devices - the guests - using the same two wires.
Older documentation will refer to the host as the master and the guests as the slaves.
The badge used I2C for the onboard EEPROM chip. It also connects the I2C to a STEMMA-QT connector on the left, near the USB-C connector.
Note: STEMMA-QT is a popular connector for CircuitPython hardware. It provides the two wires used for I2C and it provides 3.3V and GND pins. Adafruit and Sparkfun have popularized the STEMMA-QT connector with a wide range of boards and sensors.
There are lots of sensors and other small PCBs which act as guest I2C devices and use the STEMMA-QT connector. Because I2C is one-top-many, most STEMMA-QT guest devices will have two connectors so various devices may be chained together.
Scanning I2C
I2C supports up to 127 connected guest devices at a time. After connecting a new I2C device, it is helpful to test for connectivity. We can scan the I2C bus and listing all of the guest devices detected.
The badge configures the I2C bus as board.I2C. This eliminates the need to explicitly set the pins for the I2C bus.
Here is the example:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | import board # when using I2C, we need to lock the bus, before read/right tasks, and then unlock the bus i2c_bus = board.I2C() try: # wrapping code is 'try:except' lets us catch errors which would otherwise halt our code if i2c_bus.try_lock(): print("Scanning I2C bus: ", end="") # the 'end' lets us suspend the default newline count = 0 for x in i2c_bus.scan(): print(hex(x), end=" ") count += 1 print("= {:d} device(s) found".format(count)) i2c_bus.unlock() except Exception as e: print("We failed to scan the I2C bus - ", e) i2c_bus.unlock() |
The badge has EEPROM which appears as an I2C device. The accelerometer also appears ans an I2C device.
The EEPROM is a bit of an odd duck. It is 2KB is size and is accessed as a series of 256 byte blocks. This is why it appears as 8 consecutive I2C devices in the scan (0x50 through 0x57).
If one or more devices are connected to the STEMMA-QT connector, they will also be listed in the scan.
On-board EEPROM
I2C guest devices often have a library to handle the specific commands they understand.
Unlike the Neoplixels and the capacitive touch sensors, CircuitPython does not have a published library for the the EEPROM used on the badge. This repository includes support for the EEPROM chip as part of the explorer hardware library. More about the ``explorer`` library at the end of this workshop.
See the Explorer API for EEPROM for the code.
Note: The EEPOM chip used on the badge provides 2KB of storage, divided up into pages which are 256 bytes each. Each page is a consecutive I2C address. For this reason, a scan of all I2C devices will list 8 consecutive addresses for the EEPROM.
Here is the example:
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | MARKER_ADDRESS = 0x00 MARKER_VALUE = 0xA0 DATA_ADDRESS = 0x10 eeprom = expEEPROM() MESSAGE = "Hello, World!" if eeprom.readByte(addr=MARKER_ADDRESS) != MARKER_VALUE: eeprom.writeByte(MARKER_VALUE, addr=MARKER_ADDRESS) print("The EERPOM has not been used before") eeprom.writeByte(1, addr=DATA_ADDRESS) print("Writing a message to the EEPROM") eeprom.position = DATA_ADDRESS + 1 eeprom.writeByte(len(MESSAGE)) message_bytes = bytearray(MESSAGE, 'utf-8') for byte in message_bytes: eeprom.writeByte(byte) else: val = eeprom.readByte(addr=DATA_ADDRESS) print(f"The EERPOM been used {val:d} time(s) before") val += 1 eeprom.writeByte(val, addr=DATA_ADDRESS) print("Reading a message from the EEPROM") eeprom.position = DATA_ADDRESS + 1 count = eeprom.readByte() message_bytes = bytearray() for i in range(count): message_bytes.append(eeprom.readByte()) print("".join(map(chr, message_bytes))) |
The expEEPROM class is defined in the code. Please open the full source file to read the full code.
Each time the code is executed, a marker byte is read from the EEPROM. The absence of the marker indicates the code has never been run before. If it has not, then this marker is written to the EEPROM. Then a message is written to the EEPROM. The message data is preceded by its length.
Run the code a few times and watch the serial output.
Full detains of the explorer library are at the end of this workshop.
Accelerometer
I2C guest devices often have a library to handle the specific commands they understand.
Unlike the Neoplixels and the capacitive touch sensors, CircuitPython does not have a published library for the the exact accelerometer used on the badge. This repository includes support for the accelerometer as part of the explorer full hardware library. More about the explorer library at the end of this workshop.
See the Explorer API for Accelerometer for the code.
Note: An accelerometer does not provide position or direction. It only shows the relative influence of gravity on the 3 axis of X, Y, and Z. Acceleration in the Z axis indicates if the accelerometer (and by extension the badge) is facing up or facing down. Acceleration in the X and Y axis indicate tilting.
Here is the example:
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 | ''' setup the Neopixel LEDs ''' NEOPIXEL_COUNT: int = 9 leds = neopixel.NeoPixel(board.NEOPIXEL, NEOPIXEL_COUNT, brightness=0.15, auto_write=True) RED: int = 0xFF0000 GREEN: int = 0x00FF00 BLUE: int = 0x0000FF gyro = expAccelerometer() counter = 0 try: while True: gyro.update() if gyro.x < 0: leds[0] = RED else: leds[0] = GREEN if gyro.y < 0: leds[1] = RED else: leds[1] = GREEN if gyro.z < 0: leds[2] = RED else: leds[2] = GREEN if gyro.interrupt: action = ("SHAKE" if gyro.shake else ("MOTION" if gyro.orientation else "")) print(counter, action) counter += 1 time.sleep(0.1) finally: leds.fill(0x000000) |
Each time through the while True: loop, we give the accelerometer library an opportunity to update it's data. The first three Neopixels are used to show the three axis as either red or green, depending on if the corresponding X, Y, or Z is positive or negative.
There is lots more the expAccelerometer library can do. Full detains of the explorer library are at the end of this workshop.
Not Available
Sound
CircuitPython supports sound through two popular mechanisms:
- PWM (Pulse Width Modulation)
- I2S (Inter-IC Sound)
While I2S is well suited for sound, in many cases, PWM can be used with decent results. PWM requires no special hardware and only uses a single GPIO pin.
The badge implements PWM directly with a small speaker and a mono amplifier.
The I2S pins are available via an unpopulated header on the back of the badge. (see the photo at the start of this page or refer to the pin map on the back of the badge for details)
CircuitPython supports a few different methods of generating sound, including:
- simple tones (using a square wave - on/off - pulse)
- wave forms (sine, square, triangle, sawtooth, and combinations)
- wave tables (loaded from files)
- sound files (loaded from files)
Note: The badge has a mono amplifier and a small speaker. The amplifier is off by default. To use the speaker, the amplifier is controlled with the board.SPEAKER_EN pin. This pin is a digital GPIO pin and is controlled similar to how we controlled the LED in the first example.
Simple Tone Generation
To generate a simple tone, we create a square wave using PWM. CircuitPython provides a library which handles creating a PWM signal. We will import the pwmio library to interact with the board.SPEAKER pin.
Here is an example:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | import board import time import digitalio import pwmio sound_enable = digitalio.DigitalInOut(board.SPEAKER_EN) sound_enable.direction = digitalio.Direction.OUTPUT sound_enable.value = False # we start with the amplifier OFF sound_pwm = None sound_enable.value = True def tone_on(frequency:float=440.0): global sound_enable, sound_pwm ''' produce the given frequency (float) as a PWM signal ''' print(f"Tone On ({frequency:f})") frequency = int(frequency) if sound_pwm is None: sound_pwm = pwmio.PWMOut(board.SPEAKER, frequency=frequency, variable_frequency=True) sound_pwm.duty_cycle = (65535 // 2) #0x8000 sound_enable.value = True else: sound_pwm.frequency = frequency def tone_off(): global sound_enable, sound_pwm ''' stop producing sound ''' if sound_pwm is not None: sound_pwm.deinit() sound_pwm = None sound_enable.value = False def tone(frequency:float=440.0, duration:int=1.0): ''' produce the given sound for the given amount of time ''' tone_on(frequency) time.sleep(duration) tone_off() music_scale:list = [523.25, 587.33, 659.33, 698.46, 783.99, 880.00, 987.77, 1046.50, 1174.66] # play each note without stopping between notes for note in music_scale: tone_on(note) time.sleep(0.25) tone_off() counter:int = 0 try: while True: tone(music_scale[counter % len(music_scale)], 0.125) counter += 1 finally: # any cleanup code goes here pass |
This code uses functions to improve readability and for code reuse. An even better option would be to make this a class to contain the globals and the functions.
The functions provide the ability to start and stop a tone. It also provides the ability to play a tone for a given duration. Repeated calls to the tone_on() function will immediately replace the current the tone.
Exercise: Modify the code to play a simple melody. A list of notes and durations can be used to play a simple tune. Each note and duration can be a tuple or a list.
Polyphonic Synth
What if we want to have more than one tone at a time or have more complex sounds? CircuitPython provides several options - in increasing levels of complexity - for generating polyphonic sound.
The first method uses the synthio library for polyphonic sounds - creating waveforms - sine, square, triangle, or sawtooth.
Here is just one example of synthio using a sawtooth waveform:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | import board import time import digitalio import audiopwmio import synthio import ulab.numpy as np # provides lots of numeric data processing sound_enable = digitalio.DigitalInOut(board.SPEAKER_EN) sound_enable.direction = digitalio.Direction.OUTPUT sound_enable.value = False # we start with the amplifier OFF # create a basic synth SAMPLE_SIZE = 1024 SAMPLE_VOLUME = 32767 # largest positive number in a 16bit integer wave = np.linspace(SAMPLE_VOLUME, -SAMPLE_VOLUME, num=SAMPLE_SIZE, dtype=np.int16) synth = synthio.Synthesizer(sample_rate=22050) audio = audiopwmio.PWMAudioOut(board.SPEAKER) audio.play(synth) # because we can have multiple notes at once, we need to track them # some data to track notes and control the amplifier notes = {} # dict used to track notes note_count = 0 def tone_on(frequency:float=440.0): global wave, synth, sound_enable, notes, note_count ''' add a new note (frequency) to the polyphonic output ''' # add to active notes if not note_count: sound_enable.value = True synth_note = synthio.Note(frequency, waveform=wave) # save the note so we can match it up when we stop playing it notes[frequency] = synth_note synth.press(synth_note) note_count += 1 def tone_off(frequency:float=440.0): global wave, synth, sound_enable, notes, note_count ''' remove an active note (frequency) from the polyphonic output ''' synth_note = notes.get(frequency, None) if synth_note is not None: synth.release(synth_note) notes.pop(frequency) note_count -= 1 if not note_count: sound_enable.value = False def tone_off_all(): global wave, synth, sound_enable, notes, note_count ''' remove all existing notes from the polyphonic output ''' for note in notes: synth_note = notes.get(note, None) synth.release(synth_note) notes.pop(note) note_count = 0 sound_enable.value = False music_scale:list = [523.25, 587.33, 659.33, 698.46, 783.99, 880.00, 987.77, 1046.50, 1174.66] # play each note - building quite a cacophony for note in music_scale: tone_on(note) time.sleep(0.25) tone_off_all() counter:int = 0 try: while True: # stop prior chord tone_off_all() # play a chord for i in range(0, 8, 2): index = (counter + i) % len(music_scale) tone_on(music_scale[index]) time.sleep(0.125) time.sleep(0.25) counter += 1 finally: # any cleanup code goes here pass |
There is a lot of moving parts in this example. We create a waveform using the numpy library. (numpy is a popular library for working with arrays and matrices) We create a Synthio object with a sampling rate and connect it to board.SPEAKER.
Within our note_on() function, we create a note with a frequency and the waveform.
This code differs from the simple tone example is several ways. First, to support polyphonic - aka multiple simultaneous sounds - we need to keep track of all of the notes that are active. The code uses a dictionary called notes.
From the earlier workshop we know a dictionary entry consist of a key and a value. The key for our notes is the frequency of the sound and the value is the generated Synthio waveform note.
When we want to stop playing a note, the code locates the entry in the dictionary using the frequency and is then able to release the generated note. The last step is to remove the entry from the dictionary.
Note: There is a more elaborate demonstration of the synthio library in the code named badge_12_midi.py.
Tip: If you are interested in synthesizers, consider using an I2S board for a line-out sound level with richer audio.
Sound Files
In addition to tones and waveforms, the CircuitPython sound services support playing sound files. Sound files can be messages, snippets of songs, sound effects, animal noises, or even ... drum sounds!
Here is an example which maps drum sounds to the touch pads. The on-board amplifier is not very loud.
This is an instance where the SPEAKER pin and mono amplifier is not as good as using I2S and an external amplifier.
The previously mentioned midi example includes the code to use I2S output. 😉
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | import board import time import digitalio import audiopwmio import audiobusio import audiocore import audiomixer import neopixel import touchio USE_I2S_AUDIO = False ''' setup the Neopixel LEDs ''' NEOPIXEL_COUNT: int = 9 leds = neopixel.NeoPixel(board.NEOPIXEL, NEOPIXEL_COUNT, brightness=0.15, auto_write=True) # lets quickly cycle all the neopixels through a range of colors colors: list = [0x0000FF, 0x00FF00, 0x00FFFF, 0xFF0000, 0xFF00FF, 0xFFFF00, 0xFFFFFF, 0x000000] COLOR_COUNT: int = 8 color_index: int = 0 ''' setup the capacitive touch sensors ''' TOUCH_PINS: list = [board.TOUCH1, board.TOUCH2, board.TOUCH3, board.TOUCH4, board.TOUCH5, board.TOUCH6, board.TOUCH7, board.TOUCH8, board.TOUCH9] TOUCH_COUNT:int = 9 touch_sensors = [None for i in range(TOUCH_COUNT)] for i in range(TOUCH_COUNT): touch_sensors[i] = touchio.TouchIn(TOUCH_PINS[i]) # the minimum sensitivity (aka threshold) is set when the touch sensor is created print(f"initialized touch {i:d} with threshold {touch_sensors[i].threshold:d}") ''' seetup and load sound WAV files ''' PAD_NAMES = ['kick', 'snare', 'hatclosed', 'hatopen', 'clap', 'tom'] sounds = [None] * TOUCH_COUNT # create a TOUCH_COUNT sized list of None values active = [False] * TOUCH_COUNT # keeps track of which pad notes are active mixer = audiomixer.Mixer(voice_count=TOUCH_COUNT, sample_rate=22050, channel_count=1, bits_per_sample=16, samples_signed=True, buffer_size=4096) if USE_I2S_AUDIO: audio = audiobusio.I2SOut(board.I2S_BCK, board.I2S_LRCK, board.I2S_DATA) sound_enable = None for i in range(TOUCH_COUNT): mixer.voice[i].level = 0.15 else: audio = audiopwmio.PWMAudioOut(board.SPEAKER) sound_enable = digitalio.DigitalInOut(board.SPEAKER_EN) sound_enable.direction = digitalio.Direction.OUTPUT sound_enable.value = True # make sure the amplifier is ON audio.play(mixer) def load_sounds(folder: str): ''' folder is a string and represents the full path to a set of files within the folder should be a series of numbered '.wav' files 00kick, 01snare, 02hatclosed, 03hatopen, 04clap, 05tom ''' for i in range(TOUCH_COUNT): try: fname = f"{folder}/{i:02d}{PAD_NAMES[i]}.wav" sounds[i] = audiocore.WaveFile(open(fname,"rb")) except Exception as e: print(f"Error while assigning wave audio #{i:d}:", e) sounds[i] = None def play_sound(num: int, pressed: bool): num = num % TOUCH_COUNT # safety to prevent values out of range voice = mixer.voice[num] # get mixer voice if pressed: voice.play(sounds[num],loop=False) else: # released pass # not doing this for samples # load one of the sets of sound files load_sounds("/sounds/studio") # studio quilcom tr919 try: while True: for i in range(TOUCH_COUNT): if touch_sensors[i].value: if not active[i]: active[i] = True leds[i] = 0x00FF00 if sounds[i]: print(f"touch #{i:d} has a value of {touch_sensors[i].raw_value:d} and plays {PAD_NAMES[i]}") play_sound(i, True) else: if active[i]: active[i] = False leds[i] = 0x000000 if sounds[i]: play_sound(i, False) time.sleep(0.01) finally: if sound_enable: sound_enable.value = False audio.deinit() leds.fill(0x000000) |
We are not going to get into a review of the code. Take a look at it and take a look at some of the related CircuitPython tutorials.
IR Receiver and Emitter
The badge has an IR receiver and an IR emitter. - Reproduce the signals from an existing IR remote - Record and playback the signals from a remote - Create custom signals to control other devices
The basics of an IR signal are: - the wavelength of the IR light (the badge uses 940nm) - the modulation of the signal (the badge uses 38kHz) - pulses and gaps
The length of the pulsed and gaps form the data packet:
- Each message starts with a header
- The header has a very long pulse and a less long gap
- The data of the message is sent as binary where
- a 'zero' has a short pulse and a short gap
- a 'one' has a short pulse and a longer gap
- The message ends with a stop bit
- typically equal to a 'zero'
CircuitPython provides a library which handles all of the complexity. We import the pulseio library to process the IR signal into pulses. We import the adafruit_irremote library to convert between data and pulses.
Here is an example:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | import board # provides access to all of the board IO pins and hardware import time # convenient for timing tasks and for 'sleep' delays import random import digitalio import pulseio # used for the IR receiver import adafruit_irremote import neopixel ''' setup the LED - used as in indicator for IR receive ''' indicator = digitalio.DigitalInOut(board.LED) indicator.direction = digitalio.Direction.OUTPUT indicator.value = False ''' setup the Neopixel LEDs ''' NEOPIXEL_COUNT: int = 9 leds = neopixel.NeoPixel(board.NEOPIXEL, NEOPIXEL_COUNT, brightness=0.15, auto_write=True) # lets quickly cycle all the neopixels through a range of colors colors: list = [0x000000, 0xFF0000, 0x00FF00, 0xFFFF00, 0x0000FF, 0xFF00FF, 0x00FFFF, 0xFFFFFF] COLOR_COUNT: int = 8 ''' IR Data Format: The header has a very long pulse and a long gap. Each bit of the binary data starts with a marker pulse and then a long or short gap. Most formats include a stop bit at the end which is the same as a 'zero' bit. ''' IR_HEADER = [9000, 4500] IR_START = 560 IR_SHORT = 560 IR_LONG = 1670 IR_ZERO = [IR_START, IR_SHORT] IR_ONE = [IR_START, IR_LONG] ir_rx = pulseio.PulseIn(board.GP1, maxlen=200, idle_state=True) ir_tx = pulseio.PulseOut(board.GP0, frequency=38000, duty_cycle=2**15) decoder = adafruit_irremote.GenericDecode() encoder = adafruit_irremote.GenericTransmit(header=IR_HEADER, one=IR_ONE, zero=IR_ZERO, trail=255, debug=False) MESSAGE_SIZE: int = 4 message = [2, 3, 0, 255] # cmd, color, duration, delay (we are only interested in the color) def ir_received(ir) -> list: pulses = decoder.read_pulses(ir) count = len(pulses) if count < MESSAGE_SIZE: return None try: code = list(decoder.decode_bits(pulses)) print("Received:", code) indicator.value = True time.sleep(0.5) indicator.value = False if len(code) != MESSAGE_SIZE: print("Bad code: ", code) code = None except Exception as e: print("Failed to decode:", e, "raw:", count, pulses) code = None for i in range(5): indicator.value = True time.sleep(0.05) indicator.value = False time.sleep(0.05) return code def ir_transmit(ir, value): if type(value) is not list: value = [value] print("sending: ", value) encoder.transmit(ir, value) timer = time.time() + int((5 * random.random())) try: while True: if timer < time.time(): timer = time.time() + int((5 * random.random())) ir_rx.pause() message[1] = random.randint(0, 7) ir_transmit(ir_tx, message) ir_rx.clear() ir_rx.resume() if len(ir_rx) > 0: code = ir_received(ir_rx) # valid code is a list of 3 values: [cmd, brightness, color] # brightness is a value 0..9 # color is a value 0..7 # cmd is not used if code: color = code[1] % COLOR_COUNT leds.fill(colors[color]) time.sleep(0.05) finally: leds.fill(0x000000) |
This example sends an IR message which includes a color. It also listens for messages which contain a color. When it receives a valid message, it changes the neopixels to the received color. The board.LED is used to indicate if we received good data.
Exercise: Popular IR remotes use well documented values for the length of the pulses for the header and data. By listening for pulses and storing the results, it is possible to 'record' an existing IR remote and then play back the data.
ePaper Display
The badge has a Monochrome (Black White) ePaper display with a resolution of 200x200 pixels.
The badge provides the fully configured display devices as board.DISPLAY.
ePaper displays are very slow to refresh. You won't want to refresh it very often. It is great to display a text message - such as your handle/nickname or a cipher - or an image.
CircuitPython treats all displays the same and has common code that works on all of them.
It is possible to place simple text on the display. More complicated output uses layout mechanisms called groups and tiles. Groups are rendered one after another after another. If groups overlay, later groups in the list will render on-top-of earlier groups. Groups contain one or more tiles.
TBH, the whole process is pretty gorpy for ePaper displays which tend to be updated once, so it's nice the explorer library does most of the heavy lifting.
Here is an example displaying a text message in a range of font sizes:
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | import time import board import digitalio import displayio import terminalio # only used if we use the simple built-in font (and as a fallback if our bitmap font is missing) display_enable = digitalio.DigitalInOut(board.DISPLAY_EN) display_enable.direction = digitalio.Direction.OUTPUT display_enable.value = True # turn on the display power from adafruit_bitmap_font import bitmap_font from adafruit_display_text import label WIDTH:int = 200 HEIGHT:int = 200 BLACK:int = 0x000000 WHITE:int = 0xFFFFFF RED:int = 0xFF0000 display = board.DISPLAY # start building our display groups and tiles display.root_group = displayio.Group() # an empty group to start # start with a red background background_bitmap = displayio.Bitmap(WIDTH, HEIGHT, 1) palette = displayio.Palette(1) palette[0] = WHITE background_tile = displayio.TileGrid(background_bitmap, pixel_shader=palette) display.root_group.append(background_tile) # add test message is various font sizes #BASE_NAME = "sedgwick" vid = board.VID() # get the type of board (which tells us the type of display) if (vid == 2) or (vid == 4): BASE_NAME = "neuropolx" else: BASE_NAME = "graffiti" font_sizes = [4, 6, 9, 12, 18] msg = "Fix The Planet" offset = 0 for size in font_sizes: fullname = f"/fonts/{BASE_NAME}{size:d}.bdf" try: font = bitmap_font.load_font(fullname) font_height = font.ascent + font.descent box = font.get_bounding_box() box_height = box[1] - box[3] # use the larger of font_height and box_height if font_height < box_height: font_height = box_height scale = 1 except: print(f"Warning: unable to locate font '{fullname}'") print(" will fall back to terminal font") font = terminalio.FONT font_width, font_height = font.get_bounding_box() scale = int(size / 2) font_height *= scale print(msg, "at Y =", offset) text = label.Label(font, text=msg, color=RED, scale=scale, anchor_point=(0.0,0.0), anchored_position=(0, offset)) display.root_group.append(text) offset += font_height if offset > HEIGHT: break # render everything on the display wait = display.time_to_refresh print (f"Refresh will be delayed by {wait:4.1f}.") time.sleep(wait) try: display.refresh() print("ePaper display updated") except Exception as e: print("Error: unable to update display -", e) counter: int = 0 reported: bool = False print("Seconds: ", end="") try: while True: print(counter, end=" ") if not reported and not display.busy: print("\nThe ePaper display has finished repainting.") reported = True time.sleep(1.0) counter += 1 finally: print("Disabling the ePaper display.") display_enable.value = False |
The bitmap_font library supports bdf and pcf fonts. The badge includes the graffiti font in 6 sizes.
When you run this code, pay attention to when the "display updated" message appears and when the display actually finishes refreshing. It's not quick!
Tip: If you want to quickly change the text on the ePaper display, use the demo_text.py code.
Preventing ePaper Updates
The code related to display_enable.value is a bit of trickery.
CircuitPython considers any attached display to be available to display error messages.
Given the ePaper display on the badge is intended for the user to customize the look of their badge, having CircuitPython erase it and put up error messages, may not be desirable. To prevent this, the ePaper display power is controlled by a GPIO pin. By default, the pin is enabled.
If you wish to prevent the display from being changed - outside of your code - then configure the board.DISPLAY_EN pin using digitalio as OUTPUT and set the value to False. This turns the power to the display off and prevents CircuitPython from updating the display.
The Big Demo
The Explorer Badge had a demo for everything. It is the original code which ran when you first received your badge.
Here is that code:
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | import time import board vid = board.VID() OPTION = 0 # 0 = Badge Hardware Demo, 1 = Magic 8-Ball, 2 = Screen Message, 3 = Image, 4 = Simple CircuitPython Example if OPTION == 3: from demo_image import run_demo run_demo("image/raider.bmp") # NOTE: the above function `run_demo()` never returns so any following code never runs if OPTION == 2: from demo_text import run_demo run_demo("YOUR\nNAME\nHERE") # NOTE: the above function `run_demo()` never returns so any following code never runs if OPTION == 1: if (vid == 2) or (vid == 4): # this type of board can support the magic 8-ball demo from demo_ball import run_demo run_demo() # NOTE: the above function `run_demo()` never returns so any following code never runs else: print("This board does not support the Magic 8-Ball demo") OPTION = 0 if OPTION == 0: from demo_badge import run_demo run_demo() # NOTE: the above function `run_demo()` never returns so any following code never runs print("Hello World!") counter = 0 while True: # do repetitive stuff here time.sleep(1.0) counter += 1 print(counter) |
Explorer Library
The Explorer Badge has lots of hardware features and CircuitPython has lots of libraries to support both the hardware on the badge and hardware which can be added to the badge via the STEMMA-QT connect.
The explorer library provides basic support for the on-board hardware. It is not comprehensive. The library is provided as source material - so feel free to copy it and make changes!
If you find a bug or add something you'd like to share, consider opening an issue in the GitLab repository or even creating a pull-request!
The explorer library is fully ... well ... ummm ... mostly documented. The documentation is maintained in the repository and published here (see the side bar).
Exercises
Here are a few things to try:
- Customize the text on the ePaper display.
- Draw a cryptograph or create a bitmap and display it on the ePaper display.
- Locate one of the wave-table synth examples online and modify it to work on the badge.
- Combine the sound capability and touch sensors to create a simple drum synth.
- If you're into synthesizers, its possible to use MIDI over USB to the badge!
- Use the CircuitPython HID support and the badge the touch sensors to make a macropad.
Conclusion
If you are new to Python, this workshop has introduces a lot of new content. The best way to get comfortable with all of this material is to keep writing code (and ask questions).
Here are a few useful things we did not cover: - popular libraries - extending classes - web services - server, client, and listeners
We already mentioned the tutorials and examples on W3Schools.
You can also get some great tips and ideas using one of the on-line chat AI tools. Understand that the chat AI tools make mistakes so any code they generate may not work. That said, they do make it easy to generate examples.