Stairs project

Ok, so like I said, I thought this was an interesting problem, so I decided to go ahead and create a single-threaded state machine that does what you set out to do.

These are the requirements I started with (from your original post) :

  • Two stairwells (two lighting zones)
  • Each lighting zone has two PIR sensors, presumably one at the top of the stairs and one at the bottom
  • When a PIR is triggered, the associated lighting zone should ramp up, stay on for a while, and then ramp down

I then added a few “user experience” requirements:

  • Multiple people tripping a PIR in the same zone should simply extend the on time. That is, the on time counts down from the last PIR event seen, without triggering more ramp-up/ramp-down animations
  • And because we want the ramp-up/ramp-down to be smooth, use animation techniques to guarantee equal frame times between PWM steps. This is another thing that threading makes near impossible due to non-deterministic execution timing.
  • Events in different zones can overlap with each other without impacting each others’ performance
  • If a PIR event occurs while the lights are dimming (falling), then they simply reverse direction and rise back to an ‘On’ state.

Caveat: Not tested with real MOSFETS or a scope on the outputs - I just made sure I was calling the PWM functions with the right numbers. You will definitely need to change pin definitions for the G30 as I used a different TinyCLR board. I verified that the off/rise/on/fall states work as expected in the debugger.

The main function:

namespace ResponsiveLighting
{
    class Program
    {
        static void Main()
        {
            var lightingManager = new LightingManager();
            lightingManager.Run();
        }
    }
}

The bit doing the work…

using System;
using System.Diagnostics;
using System.Threading;
using GHIElectronics.TinyCLR.Devices.Gpio;
using GHIElectronics.TinyCLR.Devices.Pwm;
using GHIElectronics.TinyCLR.Pins;

namespace ResponsiveLighting
{
    public enum LightingStates
    {
        Off = 0,
        Rising = 1,
        On = 2,
        Falling = 3
    }

    public class LightingZone
    {
        private const int MillisecondsPerFrame = (1000 / LightingManager.FrameDuration);
        private const int RiseAndFallDuration = 3 * MillisecondsPerFrame;
        private const int LightsOnDuration = 60 * MillisecondsPerFrame;

        public LightingStates CurrentState { get; set; } = LightingStates.Off;
        public int Counter { get; set; }

        private PwmChannel pwmOutput;

        public LightingZone(string controllerName, int pin)
        {
            var controller = PwmController.FromName(controllerName);
            this.pwmOutput = controller.OpenChannel(pin);
        }

        public void MotionDetected()
        {

            switch (this.CurrentState)
            {
                case LightingStates.Off:
                    // start lighting up this zone
                    this.CurrentState = LightingStates.Rising;
                    this.Counter = RiseAndFallDuration;
                    break;

                case LightingStates.Falling:
                    // The lights were in the process of turning off
                    // Reverse the direction by setting the state to 'rising';
                    // start with whatever the current lighting level is;
                    // and set the duration to be whatever part of 'falling' we have already done.
                    // The effect is to simply reverse falling into a briefer rising state.
                    this.CurrentState = LightingStates.Rising;
                    this.Counter = RiseAndFallDuration - Counter;
                    break;

                case LightingStates.On:
                    // We're already on, so just extend the "on" time
                    this.Counter = LightsOnDuration;
                    break;

                default:
                case LightingStates.Rising:
                    // We've already been triggered to turn on - nothing to do
                    break;

            }
        }

        public void Step()
        {
            if (--this.Counter <= 0)
            {
                this.Counter = 0;
                // Counter has expired = move to the next state
                switch (this.CurrentState)
                {
                    case LightingStates.Rising:
                        this.CurrentState = LightingStates.On;
                        this.Counter = LightsOnDuration;
                        this.pwmOutput.Start();
                        break;

                    case LightingStates.On:
                        this.CurrentState = LightingStates.Falling;
                        this.Counter = RiseAndFallDuration;
                        break;

                    case LightingStates.Falling:
                        this.CurrentState = LightingStates.Off;
                        break;

                    default:
                    case LightingStates.Off:
                        break;
                }
            }

            SetOutput();
        }

        private void SetOutput()
        {
            double percentage;
            switch (this.CurrentState)
            {
                case LightingStates.Off:
                    this.pwmOutput.SetActiveDutyCyclePercentage(0.0);
                    this.pwmOutput.Stop();
                    break;

                case LightingStates.Rising:
                    percentage = (RiseAndFallDuration - this.Counter) / (double)RiseAndFallDuration;
                    this.pwmOutput.SetActiveDutyCyclePercentage(percentage);
                    break;

                case LightingStates.Falling:
                    percentage = (RiseAndFallDuration - (RiseAndFallDuration - this.Counter)) / (double)RiseAndFallDuration;
                    this.pwmOutput.SetActiveDutyCyclePercentage(percentage);
                    break;

                case LightingStates.On:
                    this.pwmOutput.SetActiveDutyCyclePercentage(1.0);
                    break;
            }
        }
    }

    public class LightingManager
    {
        public const int FrameDuration = 20;

        private const int NumberOfLightingZones = 2;
        // How many mS in each animation frame? A value of 20 means 50 frames per second
        private readonly GpioController gpio = GpioController.GetDefault();
        private LightingZone[] zones = new LightingZone[NumberOfLightingZones];

        private GpioPin[][] pirSensors = new GpioPin[NumberOfLightingZones][];

        public LightingManager()
        {
            this.InitializeHardware();
        }

        public void Run()
        {
            var frameTime = DateTime.UtcNow;
            while (true)
            {
                // Update each lighting zone
                foreach (var zone in this.zones)
                {
                    if (zone.Counter > 0)
                    {
                        zone.Step();
                    }
                }

                // Why not just use sleep?  Because we wnat each trip around the loop
                //  to last exactly the same amount of time regardless of how
                //  much code was run in that loop.  This avoids jitter in the animation.
                var sleepTime = FrameDuration - (DateTime.UtcNow - frameTime).TotalMilliseconds;
                // Sleep for whatever part of our frame duration we haven't already eaten up
                if (sleepTime > 0) // why? because if you are debugging, sleepTime will go negative
                {
                    Thread.Sleep((int)sleepTime);
                }
                frameTime = DateTime.UtcNow;
            }
        }

        private void InitializeHardware()
        {
            // The sensors in the array pirSensors[0] all trigger the first stairwell's lighting
            this.zones[0] = new LightingZone(SC20260.PwmChannel.Controller3.Id, SC20260.PwmChannel.Controller3.PC6);
            this.pirSensors[0] = new GpioPin[2];
            this.pirSensors[0][0] = this.InitializePirSensor(SC20100.GpioPin.PE4);
            this.pirSensors[0][1] = this.InitializePirSensor(SC20100.GpioPin.PE6);

            // And now the second stairwell in pirSensors[1]
            this.zones[1] = new LightingZone(SC20260.PwmChannel.Controller3.Id, SC20260.PwmChannel.Controller3.PC7);
            this.pirSensors[1] = new GpioPin[2];
            this.pirSensors[1][0] = this.InitializePirSensor(SC20100.GpioPin.PC2);
            this.pirSensors[1][1] = this.InitializePirSensor(SC20100.GpioPin.PC3);
        }

        private GpioPin InitializePirSensor(int pin)
        {
            var result = gpio.OpenPin(pin);
            result.SetDriveMode(GpioPinDriveMode.InputPullUp);
            result.ValueChanged += this.PirSensor_ValueChanged;

            return result;
        }

        private void PirSensor_ValueChanged(GpioPin sender, GpioPinValueChangedEventArgs e)
        {
            for (int zone = 0; zone < NumberOfLightingZones; ++zone)
            {
                foreach (var pin in pirSensors[zone])
                {
                    if (pin == sender)
                    {
                        Debug.WriteLine($"I see motion in zone {zone}");
                        this.zones[zone].MotionDetected();
                        return;
                    }
                }
            }
        }

    }
}
4 Likes