sensors_IMG_3001.jpg
What new devilry is this?

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.show(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

NeoPixel Effects

Random colors are used a few times so there is a function to generate one.

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 State Machine

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.show(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)

This guide was first published on Oct 23, 2018. It was last updated on Oct 23, 2018.

This page (Code) was last updated on Mar 28, 2023.

Text editor powered by tinymce.