diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs
index 040e9fd..86a67c8 100644
--- a/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs
@@ -13,33 +13,31 @@ namespace CodeCasa.AutomationPipelines.Lights.Extensions
public static class LightTransitionNodeExtensions
{
///
- /// Creates a timeout node that automatically turns off the light after the specified time span.
- /// The timeout is not reset by any external events.
+ /// Wraps the pipeline node to automatically transition to an 'Off' state after a specified duration of inactivity.
///
- /// The pipeline node to wrap with timeout behavior.
- /// The duration after which the light will turn off.
- /// The scheduler to use for timing operations.
- /// A new pipeline node that wraps the original node with timeout behavior.
+ /// The source pipeline node.
+ /// The duration to wait before turning off.
+ /// The scheduler used for timing operations.
+ /// A new pipeline node that times out to 'Off'.
public static IPipelineNode TurnOffAfter(this IPipelineNode node,
TimeSpan timeSpan, IScheduler scheduler)
{
- return new ResettableTimeoutNode(node, timeSpan, Observable.Empty(), scheduler);
+ return new ResettableTimeoutNode(node, timeSpan, Observable.Empty(), scheduler);
}
///
- /// Creates a timeout node that automatically turns off the light after the specified time span.
- /// The timeout can be reset when the observable emits a value.
+ /// Wraps the pipeline node to automatically transition to an 'Off' state after a specified duration of inactivity,
+ /// with an optional observable to persist the current state and bypass the timeout.
///
- /// The type of values emitted by the reset timer observable.
- /// The pipeline node to wrap with timeout behavior.
- /// The duration after which the light will turn off.
- /// An observable that resets the timeout timer when it emits a value.
- /// The scheduler to use for timing operations.
- /// A new pipeline node that wraps the original node with resettable timeout behavior.
- public static IPipelineNode TurnOffAfter(this IPipelineNode node,
- TimeSpan timeSpan, IObservable resetTimerObservable, IScheduler scheduler)
+ /// The source pipeline node.
+ /// The duration to wait before turning off.
+ /// An observable that, when true, prevents the timeout from triggering.
+ /// The scheduler used for timing operations.
+ /// A new pipeline node that times out to 'Off' unless persistence is active.
+ public static IPipelineNode TurnOffAfter(this IPipelineNode node,
+ TimeSpan timeSpan, IObservable persistObservable, IScheduler scheduler)
{
- return new ResettableTimeoutNode(node, timeSpan, resetTimerObservable, scheduler);
+ return new ResettableTimeoutNode(node, timeSpan, persistObservable, scheduler);
}
}
}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs
index 3eba3fb..5b3aafd 100644
--- a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs
@@ -26,13 +26,13 @@ internal static IServiceScope CreateLightContextScope(this IServiceProvi
}
///
- /// Creates a pipeline node that applies the specified light parameters and automatically turns off the light after a specified duration.
- /// The timeout is not reset by any external events.
+ /// Creates a pipeline node that applies the specified light parameters and automatically transitions
+ /// to an 'Off' state after a specified duration of inactivity.
///
- /// The service provider.
+ /// The service provider used to resolve the .
/// The light parameters to apply as a transition.
- /// The duration after which the light should turn off.
- /// A pipeline node that applies the light parameters and handles the turn-off behavior.
+ /// The duration to wait before turning off.
+ /// A pipeline node that applies the light parameters and manages the turn-off timeout.
public static IPipelineNode CreateAutoOffLightNode(this IServiceProvider serviceProvider,
LightParameters lightParameters,
TimeSpan timeSpan)
@@ -43,21 +43,20 @@ public static IPipelineNode CreateAutoOffLightNode(this IServic
}
///
- /// Creates a pipeline node that applies the specified light parameters and automatically turns off the light after a specified duration.
- /// The timeout can be reset when the observable emits a value.
+ /// Creates a pipeline node that applies the specified light parameters and automatically transitions
+ /// to an 'Off' state after a specified duration of inactivity.
///
- /// The type of elements emitted by the reset timer observable.
- /// The service provider.
+ /// The service provider used to resolve the .
/// The light parameters to apply as a transition.
- /// The duration after which the light should turn off.
- /// An observable that resets the turn-off timer when it emits.
- /// A pipeline node that applies the light parameters and handles the turn-off behavior.
- public static IPipelineNode CreateAutoOffLightNode(this IServiceProvider serviceProvider,
+ /// The duration to wait before turning off.
+ /// An observable that, when true, prevents the timeout from triggering.
+ /// A pipeline node that applies the light parameters and manages the turn-off timeout.
+ public static IPipelineNode CreateAutoOffLightNode(this IServiceProvider serviceProvider,
LightParameters lightParameters,
- TimeSpan timeSpan, IObservable resetTimerObservable)
+ TimeSpan timeSpan, IObservable persistObservable)
{
var scheduler = serviceProvider.GetRequiredService();
var innerNode = new StaticLightTransitionNode(lightParameters.AsTransition(), scheduler);
- return innerNode.TurnOffAfter(timeSpan, resetTimerObservable, scheduler);
+ return innerNode.TurnOffAfter(timeSpan, persistObservable, scheduler);
}
}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs
index a9da7a4..41e6d0f 100644
--- a/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs
@@ -1,29 +1,58 @@
-using System.Reactive;
+using CodeCasa.Lights;
using System.Reactive.Concurrency;
+using System.Reactive.Disposables;
+using System.Reactive.Disposables.Fluent;
using System.Reactive.Linq;
-using CodeCasa.Lights;
namespace CodeCasa.AutomationPipelines.Lights.Nodes
{
- internal class ResettableTimeoutNode : LightTransitionNode{
+ internal class ResettableTimeoutNode : LightTransitionNode, IDisposable
+ {
+ private readonly CompositeDisposable _disposables = new();
+ private IDisposable? _timerSubscription;
+ private bool _isPersisting;
+
public ResettableTimeoutNode(IPipelineNode childNode, TimeSpan turnOffTime,
- IObservable refreshObservable, IScheduler scheduler) : base(scheduler)
+ IObservable persistObservable, IScheduler scheduler) : base(scheduler)
{
- var serializedChild = childNode.OnNewOutput.Prepend(childNode.Output).ObserveOn(scheduler);
+ childNode.OnNewOutput
+ .Prepend(childNode.Output)
+ .ObserveOn(scheduler)
+ .Subscribe(output =>
+ {
+ Output = output;
+ RestartTimer();
+ }).DisposeWith(_disposables);
- var serializedTurnOff =
- refreshObservable.Select(_ => Unit.Default)
- .Prepend(Unit.Default)
- .Throttle(turnOffTime, scheduler)
- .Take(1)
- .ObserveOn(scheduler);
+ persistObservable
+ .ObserveOn(scheduler)
+ .DistinctUntilChanged()
+ .Subscribe(persist =>
+ {
+ _isPersisting = persist;
+ if (persist)
+ {
+ _timerSubscription?.Dispose();
+ }
+ else
+ {
+ RestartTimer();
+ }
+ }).DisposeWith(_disposables);
- serializedChild
- .TakeUntil(serializedTurnOff)
- .Subscribe(output => { Output = output; });
+ void RestartTimer()
+ {
+ if (_isPersisting)
+ {
+ return;
+ }
- serializedTurnOff
- .Subscribe(_ => { ChangeOutputAndTurnOnPassThroughOnNextInput(LightTransition.Off()); });
+ _timerSubscription?.Dispose();
+ _timerSubscription = Observable.Timer(turnOffTime, scheduler)
+ .Subscribe(_ => ChangeOutputAndTurnOnPassThroughOnNextInput(LightTransition.Off()));
+ }
}
+
+ public void Dispose() => _disposables.Dispose();
}
}
diff --git a/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj b/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj
index 6f4ddfb..4c8480b 100644
--- a/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj
+++ b/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj
@@ -8,6 +8,7 @@
+
diff --git a/tests/CodeCasa.AutomationPipelines.Lights.Tests/ResettableTimeoutNodeTests.cs b/tests/CodeCasa.AutomationPipelines.Lights.Tests/ResettableTimeoutNodeTests.cs
new file mode 100644
index 0000000..b801283
--- /dev/null
+++ b/tests/CodeCasa.AutomationPipelines.Lights.Tests/ResettableTimeoutNodeTests.cs
@@ -0,0 +1,306 @@
+using CodeCasa.AutomationPipelines.Lights.Nodes;
+using Microsoft.Reactive.Testing;
+using System.Reactive.Subjects;
+using CodeCasa.Lights;
+using Moq;
+
+namespace CodeCasa.AutomationPipelines.Lights.Tests
+{
+ [TestClass]
+ public sealed class ResettableTimeoutNodeTests
+ {
+ private TestScheduler _scheduler = null!;
+ private Mock> _childNodeMock = null!;
+ private Subject _childOutputSubject = null!;
+ private Subject _persistSubject = null!;
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(5);
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _scheduler = new TestScheduler();
+ _childNodeMock = new Mock>();
+ _childOutputSubject = new Subject();
+ _persistSubject = new Subject();
+
+ _childNodeMock.Setup(x => x.OnNewOutput).Returns(_childOutputSubject);
+ _childNodeMock.Setup(x => x.Output).Returns((LightTransition?)null);
+ }
+
+ [TestMethod]
+ public void Constructor_InitializesWithChildNodeOutput()
+ {
+ // Arrange
+ var expectedTransition = LightTransition.On();
+ _childNodeMock.Setup(x => x.Output).Returns(expectedTransition);
+
+ // Act
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _scheduler.AdvanceBy(1);
+
+ // Assert
+ Assert.AreEqual(expectedTransition, node.Output);
+ }
+
+ [TestMethod]
+ public void TimeoutElapsed_TurnsOffLight()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Act
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks + 1);
+
+ // Assert
+ Assert.IsNotNull(node.Output);
+ Assert.AreEqual(0, node.Output.LightParameters.Brightness);
+ }
+
+ [TestMethod]
+ public void ChildOutputChange_RestartsTimer()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Advance halfway through the timeout
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2);
+
+ // Act - trigger another output change to restart the timer
+ var newTransition = new LightTransition { LightParameters = new LightParameters { Brightness = 50 } };
+ _childOutputSubject.OnNext(newTransition);
+ _scheduler.AdvanceBy(1);
+
+ // Advance past the original timeout but not past the new one
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2);
+
+ // Assert - light should still be on
+ Assert.AreEqual(newTransition, node.Output);
+ }
+
+ [TestMethod]
+ public void ChildOutputChange_RestartsTimer_EventuallyTurnsOff()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Advance halfway through the timeout
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2);
+
+ // Restart timer
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Act - advance past the full timeout from the restart
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks);
+
+ // Assert
+ Assert.IsNotNull(node.Output);
+ Assert.AreEqual(0, node.Output.LightParameters.Brightness);
+ }
+
+ [TestMethod]
+ public void PersistTrue_StopsTimer()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Act - enable persist mode
+ _persistSubject.OnNext(true);
+ _scheduler.AdvanceBy(1);
+
+ // Advance past the timeout
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks * 2);
+
+ // Assert - light should still be on because persist is enabled
+ Assert.AreNotEqual(0, node.Output?.LightParameters.Brightness);
+ }
+
+ [TestMethod]
+ public void PersistFalse_RestartsTimer()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Enable persist
+ _persistSubject.OnNext(true);
+ _scheduler.AdvanceBy(1);
+
+ // Advance past original timeout
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks * 2);
+
+ // Act - disable persist
+ _persistSubject.OnNext(false);
+ _scheduler.AdvanceBy(1);
+
+ // Advance past the new timeout
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks);
+
+ // Assert - light should be off now
+ Assert.AreEqual(0, node.Output?.LightParameters.Brightness);
+ }
+
+ [TestMethod]
+ public void PersistTrue_ChildOutputChange_DoesNotRestartTimer()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ // Enable persist first
+ _persistSubject.OnNext(true);
+ _scheduler.AdvanceBy(1);
+
+ // Act - trigger child output change
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Advance past timeout
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks * 2);
+
+ // Assert - light should still be on (no timer running)
+ Assert.AreNotEqual(0, node.Output?.LightParameters.Brightness);
+ }
+
+ [TestMethod]
+ public void PersistDuplicateValues_IgnoredDueToDistinctUntilChanged()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Advance halfway
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2);
+
+ // Act - send duplicate persist values (should be ignored)
+ _persistSubject.OnNext(false);
+ _scheduler.AdvanceBy(1);
+ _persistSubject.OnNext(false);
+ _scheduler.AdvanceBy(1);
+
+ // Advance remaining time from original start
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2);
+
+ // Assert - light should still be on because duplicate false doesn't restart timer
+ Assert.AreNotEqual(0, node.Output?.LightParameters.Brightness);
+ }
+
+ [TestMethod]
+ public void Dispose_CleansUpSubscriptions()
+ {
+ // Arrange
+ var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+
+ // Act
+ node.Dispose();
+
+ // Assert - no exceptions should occur when subjects complete after disposal
+ _childOutputSubject.OnCompleted();
+ _persistSubject.OnCompleted();
+ }
+
+ [TestMethod]
+ public void MultipleChildOutputChanges_OnlyLastTimerIsActive()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ // Act - rapid fire multiple output changes
+ _childOutputSubject.OnNext(LightTransition.On());
+ _scheduler.AdvanceBy(1);
+ _childOutputSubject.OnNext(new LightTransition { LightParameters = new LightParameters { Brightness = 50 } });
+ _scheduler.AdvanceBy(1);
+ _childOutputSubject.OnNext(new LightTransition { LightParameters = new LightParameters { Brightness = 100 } });
+ _scheduler.AdvanceBy(1);
+
+ // Advance to just before timeout
+ _scheduler.AdvanceBy(DefaultTimeout.Ticks - 10);
+
+ // Assert - light should still be on
+ Assert.AreEqual(100, node.Output?.LightParameters.Brightness);
+
+ // Advance past timeout
+ _scheduler.AdvanceBy(20);
+
+ // Assert - light should be off
+ Assert.AreEqual(0, node.Output?.LightParameters.Brightness);
+ }
+
+ [TestMethod]
+ public void NullChildOutput_HandledCorrectly()
+ {
+ // Arrange
+ using var node = new ResettableTimeoutNode(
+ _childNodeMock.Object,
+ DefaultTimeout,
+ _persistSubject,
+ _scheduler);
+
+ // Act
+ _childOutputSubject.OnNext(null);
+ _scheduler.AdvanceBy(1);
+
+ // Assert
+ Assert.IsNull(node.Output);
+ }
+ }
+}