We'll be using CircuitPython for this project. Are you new to using CircuitPython? No worries, there is a full getting started guide here.
Adafruit suggests using the Mu editor to edit your code and have an interactive REPL in CircuitPython. You can learn about Mu and its installation in this tutorial.
Start by loading the HalloWing build of CircuitPython (at least 4.0) onto the HalloWing. See this part of the above guide for instructions if you aren't familiar. Be sure this is done before snapping the case together, as the reset signal is not accessible from outside.
Setting up
As usual, after the required imports, the code starts by setting up the various bits of hardware in use.
The PIR is just a digital input.
pir = digitalio.DigitalInOut(board.SENSE) pir.direction = digitalio.Direction.INPUT
Touch pads are also quite straightforward.
touch_1 = touchio.TouchIn(board.TOUCH1) touch_4 = touchio.TouchIn(board.TOUCH4)
As is audio.
audio = audioio.AudioOut(board.SPEAKER)
The display is a bit more work.
backlight = digitalio.DigitalInOut(board.TFT_BACKLIGHT) backlight.direction = digitalio.Direction.OUTPUT backlight.value = False splash = displayio.Group() board.DISPLAY.root_group = splash
Neopixels are configured as usual. Note the use of EXTERNAL_NEOPIXEL so that the pixels attached to the connector will be used, and not the one on the HalloWing board. Once configured they're all turned off.
pixels = neopixel.NeoPixel(board.EXTERNAL_NEOPIXEL, 24, brightness=.2) pixels.fill((0, 0, 0)) pixels.show()
Playing Sound Files
The Hallowing has a builtin amplifier with a speaker connector and volume control. Additionally it has 8 megabytes of SPI flash rather than the 2 that the M0 Express boards have. That provides plenty of room for sound and image files.
To play a wav file, the file is opened and used to create a WaveFile object. Then the audio object created above is used to play it. In this simple version of the code, it waits while the file plays. The file is then closed before returning. The downside of this is that nothing else happens while the sounds plays. Making this more asynchronous is an area to explore.
def play_wave(filename):
wave_file = open(filename, "rb")
wave = audiocore.WaveFile(wave_file)
audio.play(wave)
while audio.playing:
pass
wave_file.close()
Displaying Images
Displaying an image is somewhat similar: open the file, use it to create an OnDiskBitmap object, which is then used to create a Sprite object, that is then loaded into the display system.
Note that the display's backlight is turned off before loading the image and wait_for_frame is used to loop until the image has finished displaying, at which time the backlight is turned on again.
To keep things simpler the backlight is driven by a digital output rather than a PWM output. The result is that we can only turn it on or off, but not fade it.
def show_image(filename):
# CircuitPython 6 & 7 compatible
image_file = open(filename, "rb")
odb = displayio.OnDiskBitmap(image_file)
face = displayio.TileGrid(
odb,
pixel_shader=getattr(odb, 'pixel_shader', displayio.ColorConverter())
)
# # CircuitPython 7+ compatible
# odb = displayio.OnDiskBitmap(filename)
# face = displayio.TileGrid(odb, pixel_shader=odb.pixel_shader)
backlight.value = False
splash.append(face)
board.DISPLAY.refresh(target_frames_per_second=60)
backlight.value = True
def random_colour():
return (randrange(255), randrange(255), randrange(255))
In the ATTRACT state (more on that later) the pixels are randomly twinkled. Rather than clearing the entire ring each time, lit pixels are tracked and individually turned off. While this code updates all 6 twinkling pixels at a time, by keeping track of individual pixels it could change them one at a time. The code is hardcoded to work with 6 pixels. It could easily be made more flexible by using the size of the index list instead.
twinkle_indicies = [0, 0, 0, 0, 0, 0]
def twinkle():
for p in range(6):
pixels[twinkle_indicies[p]] = (0, 0, 0)
twinkle_indicies[p] = randrange(len(pixels))
pixels[twinkle_indicies[p]] = random_colour()
pixels.show()
Finally, there is a function to fill the ring with a random color.
def solid():
pixels.fill(random_colour())
pixels.show()
The operation of the toy is governed by a finite state machine.
Wikipedia defines a finite state machine as:
It is an abstract machine that can be in exactly one of a finite number of states at any given time. The FSM can change from one state to another in response to some external inputs; the change from one state to another is called a transition. An FSM is defined by a list of its states, its initial state, and the conditions for each transition.
This machine has 6 states and its transitions are driven by 4 events.
ARMED_STATE = 0 ATTRACT_STATE = 1 PLAY_STATE = 2 TOUCHED_STATE = 3 MOVED_STATE = 4 ROCKED_STATE = 5 TIMEOUT_EVENT = 0 MOVE_EVENT = 1 TOUCH_EVENT = 2 ROCK_EVENT = 3
Here's the state diagram, the notes what happens when each state is entered, and the transitions between states, annotated with the events that triggers each one. No event means it's followed unconditionally as soon as possible (sometimes that's after a sounds plays or an image is displayed).
The ARMED state is where the toy is sitting dormant, watching for activity. When motion is detected, the ATTRACT state is transitioned to. If accelerometer activity is detected, the ROCKED state is transitioned to. Note that this isn't currently implemented and is an area for extension.
In the ATTRACT state the toy attempts to get the cat's attention. Currently this is done by twinkling the pixels. A likely extension is to play a compelling sounds as well but would require asynchronous sound playing as mentioned earlier. If the touch pads are touched, the machine moves to the TOUCHED state. If it is rocked the ROCKED state is transitioned to. Finally, if the timeout expires, the machine goes back to the ARMED state.
In the TOUCHED state, the pixels turn red and a random sound file is played. When the sound finishes, it transitions to the PLAY state.
The ROCKED state hasn't been implemented yet and transitions immediately to the PLAY state.
The PLAY state is the core state when the toy is active. While in it, something interesting can be displayed and the pixels continually show random colors. Touch, motion, and accelerometer events cause a transition to the appropriate state. If a timeout occurs the ARMED state is transitioned to.
Finally, the MOVED state currently does nothing other than transition immediately back to the PLAY state. It's there primarily as a hook for later extension.
The timeout was mentioned several times above. Moving out of the ARMED state sets a timeout to one minute. If no activity occurs in that time, the ARMED state is returned to (from the ATTRACT or PLAY states). Any activity causes the timeout to be reset to a minute. The result is that as long as there is activity the toy stays active. When the cat becomes bored and wanders off, the timeout will expire and the toy will go back to sleep.
States are implemented by two functions: enter_state and handle_event. The enter_state function is called with the state being entered and is responsible for doing whatever is appropriate for each state. The handle_event function implements the transitions. Depending on the event and the current state, the appropriate new state is entered.
def enter_state(state):
global current_state, timeout_time, update_time
current_state = state
if state == ARMED_STATE:
timeout_time = 0
update_time = 0
backlight.value = False
pixels.fill((0,0,0))
pixels.show()
elif state == ATTRACT_STATE:
splash.pop()
show_image(images[1]) # here kitty
reset_timeout()
set_update(0.1, twinkle)
elif state == PLAY_STATE:
splash.pop()
show_image(images[2]) # good kitty
set_update(2.0, solid)
elif state == TOUCHED_STATE:
reset_timeout()
update_time = 0
pixels.fill((128, 0, 0))
pixels.show()
play_wave(sounds[randrange(len(sounds))])
enter_state(PLAY_STATE)
elif state == MOVED_STATE:
enter_state(PLAY_STATE)
elif state == ROCKED_STATE:
reset_timeout()
enter_state(PLAY_STATE)
def handle_event(event):
if event == TIMEOUT_EVENT:
enter_state(ARMED_STATE)
elif event == MOVE_EVENT:
if current_state == ARMED_STATE:
enter_state(ATTRACT_STATE)
elif current_state == PLAY_STATE:
enter_state(MOVED_STATE)
elif event == TOUCH_EVENT:
if current_state in [ARMED_STATE, ATTRACT_STATE, PLAY_STATE]:
enter_state(TOUCHED_STATE)
elif event == ROCK_EVENT:
if current_state in [ARMED_STATE, ATTRACT_STATE, PLAY_STATE]:
enter_state(ROCKED_STATE)
They should be fairly understandable. There is one thing of note: entering the ATTRACT and PLAY states call the function set_update with an interval value and one of the NeoPixel functions described earlier. In the main loop, the function is called at the interval specified. This is what allows the dynamic behavior within a state.
The main loop is fairly simple: if it's time to call the update function it does so, then the four events are generated (by calling handle_event) as appropriate depending on sensor activity. It uses some sensor processing functions to detect the leading edge of sensor activity (i.e. there was no motion last time it checked and now there is).
was_moving = False
def started_moving():
global was_moving
started = False
moving_now = pir.value
if moving_now:
started = not was_moving
was_moving = moving_now
return started
was_touching = False
def started_touching():
global was_touching
started = False
touching_now = touch_1.value or touch_4.value
if touching_now:
started = not was_touching
was_touching = touching_now
return started
def started_rocking():
return False
#-------------------------------------------------------------------------------
# Image and sound filenames
images = ["please_standby.bmp", "here_kitty.bmp", "good_kitty.bmp"]
sounds = ["Cat_Meow_2.wav", "Cat_Meowing.wav", "kitten3.wav", "kitten4.wav"]
#-------------------------------------------------------------------------------
# Get started and loop, looking for and handling events
show_image(images[0]) # waiting display
time.sleep(3)
arm_time = 0
armed = True
enter_state(ARMED_STATE)
while True:
now = time.monotonic()
if update_time > 0 and now > update_time:
update_time += update_interval
update_function()
if timeout_time > 0 and now > timeout_time:
handle_event(TIMEOUT_EVENT)
elif started_moving():
handle_event(MOVE_EVENT)
elif started_touching():
handle_event(TOUCH_EVENT)
elif started_rocking():
handle_event(ROCK_EVENT)
And that's it. It's simple but could be the jumping off point to something more complex if memory was larger, possibly using a Feather M4 Express and the PropMakerWing.
The images and sounds are are available, zipped, at the link below as well as in the GitHub repository.
Here's the full code.py file.
# SPDX-FileCopyrightText: 2018 Dave Astels for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
HalloWing Interactive Cat Toy
Adafruit invests time and resources providing this open source code.
Please support Adafruit and open source hardware by purchasing
products from Adafruit!
Written by Dave Astels for Adafruit Industries
Copyright (c) 2018 Adafruit Industries
Licensed under the MIT license.
All text above must be included in any redistribution.
"""
# pylint: disable=global-statement
import time
from random import randrange
import board
import displayio
import digitalio
import touchio
import audioio
import audiocore
import neopixel
# import adafruit_lis3dh
#-------------------------------------------------------------------------------
# Setup hardware
pir = digitalio.DigitalInOut(board.SENSE)
pir.direction = digitalio.Direction.INPUT
touch_1 = touchio.TouchIn(board.TOUCH1)
touch_4 = touchio.TouchIn(board.TOUCH4)
audio = audioio.AudioOut(board.SPEAKER)
backlight = digitalio.DigitalInOut(board.TFT_BACKLIGHT)
backlight.direction = digitalio.Direction.OUTPUT
backlight.value = False
splash = displayio.Group()
board.DISPLAY.root_group = splash
# setup neopixel ring
pixels = neopixel.NeoPixel(board.EXTERNAL_NEOPIXEL, 24, brightness=.2)
pixels.fill((0, 0, 0))
pixels.show()
# setup accelerometer
# i2c = board.I2C() # uses board.SCL and board.SDA
# i2c = board.STEMMA_I2C() # For using the built-in STEMMA QT connector on a microcontroller
# lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c)
#-------------------------------------------------------------------------------
# Play a wav file
def play_wave(filename):
wave_file = open(filename, "rb")
wave = audiocore.WaveFile(wave_file)
audio.play(wave)
while audio.playing:
pass
wave_file.close()
#-------------------------------------------------------------------------------
# Display an image on the HalloWing TFT screen
def show_image(filename):
odb = displayio.OnDiskBitmap(filename)
face = displayio.TileGrid(odb, pixel_shader=odb.pixel_shader)
backlight.value = False
splash.append(face)
board.DISPLAY.refresh(target_frames_per_second=60)
backlight.value = True
#-------------------------------------------------------------------------------
# Neopixel routines
def random_colour():
return (randrange(255), randrange(255), randrange(255))
# Set 6 random pixels to random colours.
# Keep track of which are lit so they can be turned off next time
twinkle_indices = [0 , 0, 0, 0, 0, 0]
def twinkle():
for p in range(6):
pixels[twinkle_indices[p]] = (0, 0, 0)
twinkle_indices[p] = randrange(len(pixels))
pixels[twinkle_indices[p]] = random_colour()
pixels.show()
# Fill the ring with a random colour
def solid():
pixels.fill(random_colour())
pixels.show()
#-------------------------------------------------------------------------------
# The state machine
ARMED_STATE = 0
ATTRACT_STATE = 1
PLAY_STATE = 2
TOUCHED_STATE = 3
MOVED_STATE = 4
ROCKED_STATE = 5
TIMEOUT_EVENT = 0
MOVE_EVENT = 1
TOUCH_EVENT = 2
ROCK_EVENT = 3
TIMEOUT = 60
timeout_time = 0
current_state = -1
update_function = None
update_time = 0
update_interval = 0
def reset_timeout():
global timeout_time
timeout_time = time.monotonic() + TIMEOUT
def set_update(interval, func):
global update_interval, update_time, update_function
update_function = func
update_interval = interval
update_time = time.monotonic() + interval
def enter_state(state):
global current_state, timeout_time, update_time
# print("Entering state {0}".format(state))
current_state = state
if state == ARMED_STATE:
timeout_time = 0
update_time = 0
backlight.value = False
pixels.fill((0,0,0))
pixels.show()
elif state == ATTRACT_STATE:
splash.pop()
show_image(images[1]) # here kitty
reset_timeout()
set_update(0.1, twinkle)
elif state == PLAY_STATE:
splash.pop()
show_image(images[2]) # good kitty
set_update(2.0, solid)
elif state == TOUCHED_STATE:
reset_timeout()
update_time = 0
pixels.fill((128, 0, 0))
pixels.show()
play_wave(sounds[randrange(len(sounds))])
enter_state(PLAY_STATE)
elif state == MOVED_STATE:
enter_state(PLAY_STATE)
elif state == ROCKED_STATE:
reset_timeout()
enter_state(PLAY_STATE)
def handle_event(event):
# print("Handling event {0}".format(event))
if event == TIMEOUT_EVENT:
enter_state(ARMED_STATE)
elif event == MOVE_EVENT:
if current_state == ARMED_STATE:
enter_state(ATTRACT_STATE)
elif current_state == PLAY_STATE:
enter_state(MOVED_STATE)
elif event == TOUCH_EVENT:
if current_state in [ARMED_STATE, ATTRACT_STATE, PLAY_STATE]:
enter_state(TOUCHED_STATE)
elif event == ROCK_EVENT:
if current_state in [ARMED_STATE, ATTRACT_STATE, PLAY_STATE]:
enter_state(ROCKED_STATE)
#-------------------------------------------------------------------------------
# Check for event triggers
was_moving = False
def started_moving():
global was_moving
started = False
moving_now = pir.value
if moving_now:
started = not was_moving
was_moving = moving_now
return started
was_touching = False
def started_touching():
global was_touching
started = False
touching_now = touch_1.value or touch_4.value
if touching_now:
started = not was_touching
was_touching = touching_now
return started
def started_rocking():
return False
#-------------------------------------------------------------------------------
# Image and sound filenames
images = ["please_standby.bmp", "here_kitty.bmp", "good_kitty.bmp"]
sounds = ["Cat_Meow_2.wav", "Cat_Meowing.wav", "kitten3.wav", "kitten4.wav"]
#-------------------------------------------------------------------------------
# Get started and loop, looking for and handling events
show_image(images[0]) # waiting display
time.sleep(3)
arm_time = 0
armed = True
enter_state(ARMED_STATE)
while True:
now = time.monotonic()
if update_time > 0 and now > update_time:
update_time += update_interval
update_function()
if timeout_time > 0 and now > timeout_time:
handle_event(TIMEOUT_EVENT)
elif started_moving():
handle_event(MOVE_EVENT)
elif started_touching():
handle_event(TOUCH_EVENT)
elif started_rocking():
handle_event(ROCK_EVENT)
Page last edited January 22, 2025
Text editor powered by tinymce.