Python & CircuitPython Workshops

Hack the Badge

Front of Hardware Badge Back of Hardware Badge

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:

  1. Customize the text on the ePaper display.
  2. Draw a cryptograph or create a bitmap and display it on the ePaper display.
  3. Locate one of the wave-table synth examples online and modify it to work on the badge.
  4. Combine the sound capability and touch sensors to create a simple drum synth.
  5. If you're into synthesizers, its possible to use MIDI over USB to the badge!
  6. 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.