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;
}
}
}
}
}
}