Lighting the stairs

 

Original Software

The basics

The basic goal of the software running on the Trinket M0 is to detect when one of the PIR sensors detects motion and then turn on the LED strip. Of course, just turning on the LEDs is a little boring, especially when we have individually addressable RGB pixels, a light level sensor, two PIRs, and a mode switch. So what might we do to make the lighting more interesting?

Since there is a PIR at the top and bottom of the stairs, we know which end of the stairs was entered. One simiple animation is to incrementally turn on the pixels from one entered end of the stairs to the exit end of the stairs. So if the top PIR trips, light the strip from the top descending to the bottom. Or vice versa. And with a notion of the light level in the stairwell, we could choose an appropriate color and/or or brightness. For example, if it stairwell was dark, a medium brightness RED might be appropriate (better for night vision). White might be a better choice when the stairwell is moderately bright. And if the stairwell is fairly well lit, perhaps the LEDs are not required at all.

By using the mode switch, we could choose to have lighting patterns that are just kind of fun. A standard rainbow swirl, fade-in/fade-out of a random color, etc. Whatever we could imagine.

Arduino vs CircuitPython

Adafruit has a major effort in progress to make CircuitPython a good way to quickly create projects. I've tried it out with some of their AdaBox projects and it works quite well. After I had a prototype of the code working in Arduino, I thought I'd try it in CircuitPython. I translated the code I had and tried to load it onto the Trinket M0. It wouldn't load. As best I could tell, my code and the libraries I wanted to include were exceeding the available memory. As of the writing of this guide, version 4 of CircuitPython is imminent. So maybe I'll try it again when that's available. But for now, the project is Arduino based. The current code uses about 10% of the available space on the Trinket M0.

Update on CircuitPython

The CircuitPython implementation was revisited using version 4rc2. As described above, there were problems attempting to build and run the code on a Tinket M0. To avoid this problem, a Feather M4 Express was used while writing an object-oriented version of the code in CircuitPython. After all of functionality of the original Arduino based code was working, functionality was removed until the code fit on the Trinket M0.

The cut down version does work on the Trinket M0. But just barely. It was necessary to get rid of all extraneous files on the M0 to store the .py files. On the execution side, all but one animation was removed. Other few other items were simplified, and these changes allowed the code to fit into Trinket M0 memory at run time.

The CircuitPython implementations are now posted on GitHub.

An object-oriented version of the Arudino code is in developement as well.

Writing the software

I like to have insight into what the code I write is actually doing (in constrast to what I thought I wrote). When the USB cable is attached, printing text to the console is very helpful. But eventually, that tether is gone and I might still want some insight. Fortunately, this project has lots of indicator LEDs available. There's the red LED and the single DotStar on the Trinket M0. There are also about 150 LEDs on the LED strip. I appropriated the first and last LED of the strip to use as an indicator for when the top or bottom PIR is indicating motion. The DotStar is used to indicate what state the software is in, and the red LED is used to indicate that the code is operating (blinks on and off).

DotStar Color Description
Red Top PIR went high. Waiting for bottom PIR to trigger.
Green Bottom PIR went high. Waiting for top PIR to trigger.
Blue Top, then bottom PIRs have triggered. Waiting for PIRs low or timeout.
Pale blue Top PIR went HIGH. Timed out waiting for bottom PIR to go HIGH.
Magenta Bottom PIR went HIGH, then top PIR went HIGH. Waiting for PIRs low or timeout.
Yellow Bottom PIR went HIGH. Timed out waiting for top PIR to go HIGH.
Black Idle state (returns to this color after 30 seconds)

DotStar color indicators

While working with the prototype, it became clear that the PIR sensors act sort of like an analog device. That is, they don't behave the same every time. In particular, the time period that the PIR asserts motion can vary quite a bit. Yes, there are adjustments on the devices, but even with adjustments, the variation remains. I left debugging code in the code base that reports how long the PIR was in a HIGH state.

In initial testing, it also became clear that sometimes the PIR will trigger when someone walks by with no intention of using the stairs. So one PIR will trigger and the second one never will. Code was added that basically times out this situation. The timeout can be adjusted. The #define of timeBetweenPIRTriggers controls this timeout.

LED animations

Originally I used the standard technique of using delay() between steps in the LED strip animations. This works for simple animations, but makes it difficult to detect changes in the PIR output. At least detecting the changes in a main loop. And, as I wanted to have longer running animations, delay() was no longer an option.

Now all LED animations are broken into at least 2 parts. The setup for the animation, and the continuation of the animation until it completes. You'll find things like colorWipe(uint32_t theColor, bool reverse) paired with wipeColorContinue(). A wipeColorActive() also exists. More complex animations, like twinkle(bool turnOn) and are broken into 5 or 6 functions.

An object oriented approach would make for a cleaner implementation. But I didn't get around to do that.

startup()

The startup code waits a bit to let the Serial.begin() get set. Then it initializes the LED strip, the DotStar and the GPIO pins for the two PIRs and the mode switch. The next step is to wait until both PIRs are reporting a LOW state. When first powered up, the PIRs can report HIGH for some period of time. Waiting here simplifies the code in the main loop. If debugPattern == true, then waiting for the PIRs to be stabilized (both LOW) is skipped. I used this to debug animations on the desktop with no PIRs attached to the Trinket M0.

loop()

The basic function of the main loop is to watch for changes in the PIRs and then, under the right conditions, run an animation on the LED strip. Here is a state diagram of the basic main loop.

Basic main loop state diagram

Of course there is a bit more going on when the code transitions from one state to another. For one thing, the DotStar is set to a specific color to indicate the current state as described above.

PIR handling

As noted before, the PIR response to motion is not the same each time. During testing, a PIR could stay in the HIGH state for anything from 2 to 15 seconds, and infrequently, longer than that. (Without changing the PIR adjustments.) Parts of the main loop try to wait until both PIRs have returned to the LOW state before transitioning to the next state. If a PIR decides to stay HIGH for a while, the code can behave in unintended ways. The functions readTopPIR(bool rawRead=false) and readBottomPIR(bool rawRead=false) were created to help deal with this behavior. If the PIR stays in the HIGH state for more that 15 seconds, the function will report that it is now in the LOW state.

Miscellany

The functions doFadeOr(bool turnOn, uint32_t colorToUse, bool topToBottom) and displayLights(bool turnOn, bool topToBottom=false) are essentially wrapper functions used by the main loop to hide the decision of which animation to use given the current conditions. displayLights checks the state of the mode switch and either calls doFadeOr() or uses the colorWipe or zipLine functions based on light levels.

The function getLightLevel() is used to get the current reading from the photoresistor. However, if the LEDs are ON, the reading will not reflect the ambient light level. If the LEDs are ON, then getLightLevel() returns the last reading from when the LEDs were OFF. The function also tracks the minimum and maximum light level readings. Once a day, these readings are used to re-normalize light levels used in displayLights() and then are reset to start the process again. Not ideal, but better than nothing as the values from the photoresistor can vary quite a bit.

Current animations

Function name Description
fadeToColor() Fills the strip with a single color and then fades in to full brightness. Fades out when specified color is offColor
colorWipe() Fills the strip from one end to the other with a single color. Can start at top or bottom.
twinkle() Fills the strip by randomly selecting a pixel and color until the entire strip is on. Then randomly selects LEDs to turn off and back on. When called with turnOn == false, the code randomly turns off LEDs until all LEDs are off.
zipLine() Moves a single pixel of a specified color from top to bottom and back again until stopped. Can start at top or bottom.
zipLineInverse() Lights the entire strip in a specified color then moves a single black pixel from top to bottom and back again until stopped. Can start at the top or bottom.
colorSwirl() Fills the strip with a rainbow of color. Animates (swirls) the colors until stopped.
marquee() Does a marquee style animation for a specified color. Animates until stopped. Can start at the top or bottom.
setAllPixelsTo() Helper function like .fill() except that it avoids the LEDs used a PIR indicators
randomColor() Helper function that returns a pseudo random color from the colors table. Will not return the same color on sequential calls.
chooseColor() Helper function that returns a color based on the mode switch and/or current light level
blinkActive() Helper function that blinks the on board red LED. Can alternatively blink the on board DotStar.
setIndicator() Helper function that sets the DotStar to a color, but also records the time that the color was set. indicatorContinue() will reset the color to BLACK after 30 seconds.

Source code

The source code for the project is available on here on github. It's not particularly pretty code as I wrote it quickly and was more focused on getting the project working than writing nice, modular code. One of these days I may go back and refactor the code, and build and object-oriented version.