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): # 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 #------------------------------------------------------------------------------- # 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)
Text editor powered by tinymce.