diff --git a/CodeCasa.sln b/CodeCasa.sln
index 571957a..00c7ba1 100644
--- a/CodeCasa.sln
+++ b/CodeCasa.sln
@@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.AutomationPipeline
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Lights.NetDaemon", "src\CodeCasa.Lights.NetDaemon\CodeCasa.Lights.NetDaemon.csproj", "{FA32051C-0DFF-4FDE-9D9D-60F4ABA4D1A8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.AutomationPipelines.Lights", "src\CodeCasa.AutomationPipelines.Lights\CodeCasa.AutomationPipelines.Lights.csproj", "{A66370F1-3944-4EAA-82EF-6A321BF273D1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.AutomationPipelines.Lights.NetDaemon", "src\CodeCasa.AutomationPipelines.Lights.NetDaemon\CodeCasa.AutomationPipelines.Lights.NetDaemon.csproj", "{039D5B9D-F313-4DC5-9A12-FF2A1FDEB15C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -89,6 +93,14 @@ Global
{FA32051C-0DFF-4FDE-9D9D-60F4ABA4D1A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FA32051C-0DFF-4FDE-9D9D-60F4ABA4D1A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FA32051C-0DFF-4FDE-9D9D-60F4ABA4D1A8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A66370F1-3944-4EAA-82EF-6A321BF273D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A66370F1-3944-4EAA-82EF-6A321BF273D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A66370F1-3944-4EAA-82EF-6A321BF273D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A66370F1-3944-4EAA-82EF-6A321BF273D1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {039D5B9D-F313-4DC5-9A12-FF2A1FDEB15C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {039D5B9D-F313-4DC5-9A12-FF2A1FDEB15C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {039D5B9D-F313-4DC5-9A12-FF2A1FDEB15C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {039D5B9D-F313-4DC5-9A12-FF2A1FDEB15C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/CodeCasa.Abstractions/CodeCasa.Abstractions.csproj b/src/CodeCasa.Abstractions/CodeCasa.Abstractions.csproj
index 08e387b..e3a53a6 100644
--- a/src/CodeCasa.Abstractions/CodeCasa.Abstractions.csproj
+++ b/src/CodeCasa.Abstractions/CodeCasa.Abstractions.csproj
@@ -32,9 +32,5 @@
\
-
-
-
-
diff --git a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/CodeCasa.AutomationPipelines.Lights.NetDaemon.csproj b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/CodeCasa.AutomationPipelines.Lights.NetDaemon.csproj
new file mode 100644
index 0000000..573ba12
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/CodeCasa.AutomationPipelines.Lights.NetDaemon.csproj
@@ -0,0 +1,41 @@
+
+
+
+ net10.0
+ enable
+ enable
+ CodeCasa.AutomationPipelines.Lights.NetDaemon
+ Jasper Lammers
+ DevJasper
+ NetDaemon integration for light automation pipelines, enabling advanced reactive light control through CodeCasa.AutomationPipelines and CodeCasa.Lights abstractions.
+ https://github.com/DevJasperNL/CodeCasa
+ NetDaemon;Light Control;Lights;Home Automation;Home Assistant;HA Integration;Smart Home;Automation;Pipeline;Pipelines;Node;Nodes;Reactive;Rx;Reactive Extensions;C#;.NET;DotNet;Extension Methods
+ LICENSE
+ README.md
+ CodeCasa.AutomationPipelines.Lights.NetDaemon
+ True
+ ccnd_icon.png
+ https://github.com/DevJasperNL/CodeCasa/releases
+
+
+
+
+ True
+ \
+
+
+ True
+ \
+
+
+ True
+ \
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightPipelineFactoryExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightPipelineFactoryExtensions.cs
new file mode 100644
index 0000000..8c0240f
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightPipelineFactoryExtensions.cs
@@ -0,0 +1,25 @@
+using CodeCasa.AutomationPipelines.Lights.Pipeline;
+using CodeCasa.Lights.NetDaemon.Extensions;
+using NetDaemon.HassModel.Entities;
+
+namespace CodeCasa.AutomationPipelines.Lights.NetDaemon.Extensions
+{
+ ///
+ /// Extension methods for to work with NetDaemon light entities.
+ ///
+ public static class LightPipelineFactoryExtensions
+ {
+ ///
+ /// Sets up a light pipeline for a NetDaemon light entity.
+ ///
+ /// The light pipeline factory.
+ /// The NetDaemon light entity to set up the pipeline for.
+ /// An action to configure the pipeline behavior.
+ /// An async disposable representing the created pipeline(s) that can be disposed to clean up resources.
+ public static IAsyncDisposable SetupLightPipeline(this LightPipelineFactory lightPipelineFactory, ILightEntityCore lightEntity,
+ Action pipelineBuilder)
+ {
+ return lightPipelineFactory.SetupLightPipeline(lightEntity.AsLight(), pipelineBuilder);
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/CodeCasa.AutomationPipelines.Lights.csproj b/src/CodeCasa.AutomationPipelines.Lights/CodeCasa.AutomationPipelines.Lights.csproj
new file mode 100644
index 0000000..eb95380
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/CodeCasa.AutomationPipelines.Lights.csproj
@@ -0,0 +1,42 @@
+
+
+
+ net10.0
+ enable
+ enable
+ CodeCasa.AutomationPipelines.Lights
+ Jasper Lammers
+ DevJasper
+ Light automation pipeline extensions for CodeCasa.AutomationPipelines, providing composable and reactive light control logic.
+ https://github.com/DevJasperNL/CodeCasa
+ Home Automation;Home Assistant;HA Integration;Smart Home;Automation;Lights;Light;Pipeline;Pipelines;Node;Nodes;Reactive;Rx;Reactive Extensions;C#;.NET;DotNet;Extension Methods
+ LICENSE
+ README.md
+ CodeCasa.AutomationPipelines.Lights
+ True
+ cc_icon.png
+ https://github.com/DevJasperNL/CodeCasa/releases
+
+
+
+
+ True
+ \
+
+
+ True
+ \
+
+
+ True
+ \
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeCasa.AutomationPipelines.Lights/CompositeAsyncDisposable.cs b/src/CodeCasa.AutomationPipelines.Lights/CompositeAsyncDisposable.cs
new file mode 100644
index 0000000..058f62b
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/CompositeAsyncDisposable.cs
@@ -0,0 +1,60 @@
+namespace CodeCasa.AutomationPipelines.Lights
+{
+ ///
+ /// A composite disposable that manages both synchronous and asynchronous disposable resources.
+ ///
+ public sealed class CompositeAsyncDisposable : IAsyncDisposable
+ {
+ private readonly List _asyncDisposables = new();
+ private readonly List _disposables = new();
+ private bool _disposed;
+
+ ///
+ /// Adds an asynchronous disposable resource to be disposed when this composite is disposed.
+ ///
+ /// The asynchronous disposable resource to add.
+ public void Add(IAsyncDisposable asyncDisposable)
+ {
+ _asyncDisposables.Add(asyncDisposable);
+ }
+
+ ///
+ /// Adds a synchronous disposable resource to be disposed when this composite is disposed.
+ ///
+ /// The synchronous disposable resource to add.
+ public void Add(IDisposable disposable)
+ {
+ _disposables.Add(disposable);
+ }
+
+ ///
+ /// Adds a range of synchronous disposable resources to be disposed when this composite is disposed.
+ ///
+ /// The collection of synchronous disposable resources to add.
+ public void AddRange(IEnumerable disposables)
+ {
+ _disposables.AddRange(disposables);
+ }
+
+ ///
+ /// Disposes all managed asynchronous and synchronous resources.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+
+ foreach (var d in _asyncDisposables)
+ {
+ await d.DisposeAsync();
+ }
+ foreach (var d in _disposables)
+ {
+ d.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/CompositeHelper.cs b/src/CodeCasa.AutomationPipelines.Lights/CompositeHelper.cs
new file mode 100644
index 0000000..dcc56af
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/CompositeHelper.cs
@@ -0,0 +1,60 @@
+using CodeCasa.Lights;
+using CodeCasa.Lights.Extensions;
+
+namespace CodeCasa.AutomationPipelines.Lights
+{
+ internal static class CompositeHelper
+ {
+ public static string[] ValidateLightsSupported(IEnumerable lightIds, IEnumerable supportedLightIds)
+ {
+ var supportedLightIdsArray = supportedLightIds.ToArray();
+ var lightIdsArray = lightIds.ToArray();
+ if (!lightIdsArray.Any())
+ {
+ throw new ArgumentException("At least one id should be provided.", nameof(lightIdsArray));
+ }
+
+ var unsupportedLightIds = lightIdsArray
+ .Where(id => !supportedLightIdsArray.Contains(id))
+ .ToArray();
+
+ if (unsupportedLightIds.Any())
+ {
+ throw new InvalidOperationException(
+ $"The following lights are not supported: {string.Join(", ", unsupportedLightIds)}.");
+ }
+
+ return lightIdsArray.ToArray();
+ }
+
+ public static void ValidateLightSupported(IEnumerable lights, string supportedLightId)
+ {
+ var lightsArray = lights.ToArray();
+ if (!lightsArray.Any())
+ {
+ throw new ArgumentException("At least one id should be provided.", nameof(lightsArray));
+ }
+
+ var unsupportedLightIds = lightsArray
+ .Where(id => id != supportedLightId)
+ .ToArray();
+
+ if (unsupportedLightIds.Any())
+ {
+ throw new InvalidOperationException(
+ $"The following lights are not supported: {string.Join(", ", unsupportedLightIds)}.");
+ }
+ }
+
+ public static string[] ResolveGroupsAndValidateLightsSupported(IEnumerable lights, IEnumerable supportedLightIds)
+ {
+ return ValidateLightsSupported(lights.SelectMany(le => le.Flatten()).Select(l => l.Id).Distinct(), supportedLightIds);
+ }
+
+
+ public static void ResolveGroupsAndValidateLightSupported(IEnumerable lights, string supportedLightId)
+ {
+ ValidateLightSupported(lights.SelectMany(le => le.Flatten()).Select(l => l.Id).Distinct(), supportedLightId);
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Context/ILightPipelineContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Context/ILightPipelineContext.cs
new file mode 100644
index 0000000..14f0e94
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Context/ILightPipelineContext.cs
@@ -0,0 +1,19 @@
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Context;
+
+///
+/// Represents the context for a light pipeline, providing access to the service provider and the light being controlled.
+///
+public interface ILightPipelineContext
+{
+ ///
+ /// Gets the service provider instance used to resolve dependencies in the pipeline.
+ ///
+ IServiceProvider ServiceProvider { get; }
+
+ ///
+ /// Gets the light being controlled by the pipeline.
+ ///
+ ILight Light { get; }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Context/LightPipelineContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Context/LightPipelineContext.cs
new file mode 100644
index 0000000..d725d59
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Context/LightPipelineContext.cs
@@ -0,0 +1,24 @@
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Context;
+
+///
+public class LightPipelineContext : ILightPipelineContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The service provider instance used to resolve dependencies in the pipeline.
+ /// The light being controlled by the pipeline.
+ internal LightPipelineContext(IServiceProvider serviceProvider, ILight light)
+ {
+ ServiceProvider = serviceProvider;
+ Light = light;
+ }
+
+ ///
+ public IServiceProvider ServiceProvider { get; }
+
+ ///
+ public ILight Light { get; }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Context/LightPipelineContextProvider.cs b/src/CodeCasa.AutomationPipelines.Lights/Context/LightPipelineContextProvider.cs
new file mode 100644
index 0000000..fd2e978
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Context/LightPipelineContextProvider.cs
@@ -0,0 +1,22 @@
+namespace CodeCasa.AutomationPipelines.Lights.Context
+{
+ internal class LightPipelineContextProvider
+ {
+ private ILightPipelineContext? _lightPipelineContext;
+
+ public ILightPipelineContext GetLightPipelineContext()
+ {
+ return _lightPipelineContext ?? throw new InvalidOperationException("Current context not set.");
+ }
+
+ public void SetLightPipelineContext(ILightPipelineContext context)
+ {
+ _lightPipelineContext = context;
+ }
+
+ public void ResetLight()
+ {
+ _lightPipelineContext = null;
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Cycle/CompositeLightTransitionCycleConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/Cycle/CompositeLightTransitionCycleConfigurator.cs
new file mode 100644
index 0000000..df9b5e9
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Cycle/CompositeLightTransitionCycleConfigurator.cs
@@ -0,0 +1,129 @@
+using System.Reactive.Concurrency;
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.AutomationPipelines.Lights.Extensions;
+using CodeCasa.AutomationPipelines.Lights.Nodes;
+using CodeCasa.Lights;
+using CodeCasa.Lights.Extensions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeCasa.AutomationPipelines.Lights.Cycle;
+
+internal class CompositeLightTransitionCycleConfigurator(
+ Dictionary activeConfigurators,
+ Dictionary inactiveConfigurators)
+ : ILightTransitionCycleConfigurator
+{
+ public ILightTransitionCycleConfigurator AddOff()
+ {
+ var matchesNodeState = () => activeConfigurators.Values.All(c => c.Light.IsOff());
+ activeConfigurators.Values.ForEach(c => c.Add(_ => matchesNodeState()));
+ inactiveConfigurators.Values.ForEach(c => c.AddPassThrough(_ => matchesNodeState()));
+ return this;
+ }
+
+ public ILightTransitionCycleConfigurator AddOn()
+ {
+ return Add(LightTransition.On());
+ }
+
+ public ILightTransitionCycleConfigurator Add(LightParameters lightParameters, IEqualityComparer? comparer = null)
+ {
+ return Add(lightParameters.AsTransition(), comparer);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightParametersFactory, Func matchesNodeState)
+ {
+ return Add(c => lightParametersFactory(c)?.AsTransition(), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightParametersFactory, Func matchesNodeState)
+ {
+ return Add((c, t) => lightParametersFactory(c, t)?.AsTransition(), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(LightTransition lightTransition, IEqualityComparer? comparer = null)
+ {
+ comparer ??= EqualityComparer.Default;
+ return Add(
+ _ => lightTransition,
+ _ => activeConfigurators.Values.All(c => comparer.Equals(
+ c.Light.GetParameters(),
+ lightTransition.LightParameters)));
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightTransitionFactory, Func matchesNodeState)
+ {
+ return Add(c => new StaticLightTransitionNode(lightTransitionFactory(c), c.ServiceProvider.GetRequiredService()), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightTransitionFactory, Func matchesNodeState)
+ {
+ return Add(c => new FactoryNode(t => lightTransitionFactory(c, t)), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func matchesNodeState) where TNode : IPipelineNode
+ {
+ activeConfigurators.Values.ForEach(c => c.Add(matchesNodeState));
+ inactiveConfigurators.Values.ForEach(c => c.AddPassThrough(matchesNodeState));
+ return this;
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func> nodeFactory, Func matchesNodeState)
+ {
+ activeConfigurators.Values.ForEach(c => c.Add(nodeFactory, matchesNodeState));
+ inactiveConfigurators.Values.ForEach(c => c.AddPassThrough(matchesNodeState));
+ return this;
+ }
+
+ public ILightTransitionCycleConfigurator AddPassThrough(Func matchesNodeState)
+ {
+ activeConfigurators.Values.ForEach(c => c.AddPassThrough(matchesNodeState));
+ inactiveConfigurators.Values.ForEach(c => c.AddPassThrough(matchesNodeState));
+ return this;
+ }
+
+ public ILightTransitionCycleConfigurator ForLight(string lightId, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None) => ForLights([lightId], configure, excludedLightBehaviour);
+
+ public ILightTransitionCycleConfigurator ForLight(ILight light, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None) => ForLights([light], configure, excludedLightBehaviour);
+
+ public ILightTransitionCycleConfigurator ForLights(IEnumerable lightIds,
+ Action configure,
+ ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None)
+ {
+ var lightIdArray =
+ CompositeHelper.ValidateLightsSupported(lightIds, activeConfigurators.Keys);
+
+ if (lightIdArray.Length == activeConfigurators.Count)
+ {
+ configure(this);
+ return this;
+ }
+
+ if (excludedLightBehaviour == ExcludedLightBehaviours.None)
+ {
+ if (lightIdArray.Length == 1)
+ {
+ configure(activeConfigurators[lightIdArray.First()]);
+ return this;
+ }
+
+ configure(new CompositeLightTransitionCycleConfigurator(
+ activeConfigurators.Where(kvp => lightIdArray.Contains(kvp.Key))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value), []));
+ return this;
+ }
+
+ configure(new CompositeLightTransitionCycleConfigurator(
+ activeConfigurators.Where(kvp => lightIdArray.Contains(kvp.Key))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
+ activeConfigurators.Where(kvp => !lightIdArray.Contains(kvp.Key))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)));
+ return this;
+ }
+
+ public ILightTransitionCycleConfigurator ForLights(IEnumerable lightEntities, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None)
+ {
+ var lightIds = CompositeHelper.ResolveGroupsAndValidateLightsSupported(lightEntities, activeConfigurators.Keys);
+ return ForLights(lightIds, configure, excludedLightBehaviour);
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Cycle/ILightTransitionCycleConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/Cycle/ILightTransitionCycleConfigurator.cs
new file mode 100644
index 0000000..17aa318
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Cycle/ILightTransitionCycleConfigurator.cs
@@ -0,0 +1,142 @@
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Cycle
+{
+ ///
+ /// Configurator for state-based cycle behavior. Cycles advance based on the current light state:
+ /// if the light matches a state in the cycle, it advances to the next state.
+ /// If the current state is not recognized, the cycle starts from the beginning.
+ ///
+ public interface ILightTransitionCycleConfigurator
+ {
+ ///
+ /// Adds an "off" state to the cycle.
+ ///
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator AddOff();
+
+ ///
+ /// Adds an "on" state to the cycle.
+ ///
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator AddOn();
+
+ ///
+ /// Adds light parameters to the cycle. The cycle will advance to these parameters when the current state matches the previous entry in the cycle.
+ ///
+ /// The light parameters to add to the cycle.
+ /// An optional equality comparer for determining if light parameters match. If null, the default equality comparison is used.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(LightParameters lightParameters, IEqualityComparer? comparer = null);
+
+ ///
+ /// Adds light parameters created by a factory to the cycle, with a custom state matching function.
+ /// The function determines if the current light state matches this cycle entry.
+ ///
+ /// A factory function that creates light parameters based on the pipeline context.
+ /// A function that determines if the current state matches this cycle entry.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(Func lightParametersFactory, Func matchesNodeState);
+
+ ///
+ /// Adds light parameters created by a factory to the cycle, with a custom state matching function.
+ /// The factory receives both the pipeline context and the current light transition.
+ /// The function determines if the current light state matches this cycle entry.
+ ///
+ /// A factory function that creates light parameters based on the pipeline context and current transition.
+ /// A function that determines if the current state matches this cycle entry.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(Func lightParametersFactory, Func matchesNodeState);
+
+ ///
+ /// Adds a light transition to the cycle. The cycle will advance to this transition when the current state matches the previous entry in the cycle.
+ ///
+ /// The light transition to add to the cycle.
+ /// An optional equality comparer for determining if light parameters match. If null, the default equality comparison is used.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(LightTransition lightTransition, IEqualityComparer? comparer = null);
+
+ ///
+ /// Adds a light transition created by a factory to the cycle, with a custom state matching function.
+ /// The function determines if the current light state matches this cycle entry.
+ ///
+ /// A factory function that creates a light transition based on the pipeline context.
+ /// A function that determines if the current state matches this cycle entry.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(Func lightTransitionFactory, Func matchesNodeState);
+
+ ///
+ /// Adds a light transition created by a factory to the cycle, with a custom state matching function.
+ /// The factory receives both the pipeline context and the current light transition.
+ /// The function determines if the current light state matches this cycle entry.
+ ///
+ /// A factory function that creates a light transition based on the pipeline context and current transition.
+ /// A function that determines if the current state matches this cycle entry.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(Func lightTransitionFactory, Func matchesNodeState);
+
+ ///
+ /// Adds a pipeline node of type to the cycle, with a custom state matching function.
+ /// The node is resolved from the service provider.
+ /// The function determines if the current light state matches this cycle entry.
+ ///
+ /// The type of the pipeline node to add to the cycle.
+ /// A function that determines if the current state matches this cycle entry.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(Func matchesNodeState) where TNode : IPipelineNode;
+
+ ///
+ /// Adds a pipeline node created by a factory to the cycle, with a custom state matching function.
+ /// The function determines if the current light state matches this cycle entry.
+ ///
+ /// A factory function that creates a pipeline node based on the pipeline context.
+ /// A function that determines if the current state matches this cycle entry.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator Add(Func> nodeFactory, Func matchesNodeState);
+
+ ///
+ /// Adds a pass-through state to the cycle that maintains the current light state.
+ /// The function determines if the current light state matches this cycle entry.
+ ///
+ /// A function that determines if the current state matches this cycle entry.
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator AddPassThrough(Func matchesNodeState);
+
+ ///
+ /// Creates a scoped cycle configuration for a specific light identified by its entity ID.
+ ///
+ /// The entity ID of the light to configure.
+ /// An action to configure the cycle for this specific light.
+ /// Specifies the behavior for lights not included in this scoped configuration. Defaults to .
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator ForLight(string lightId, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None);
+
+ ///
+ /// Creates a scoped cycle configuration for a specific light.
+ ///
+ /// The light to configure.
+ /// An action to configure the cycle for this specific light.
+ /// Specifies the behavior for lights not included in this scoped configuration. Defaults to .
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator ForLight(ILight light, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None);
+
+ ///
+ /// Creates a scoped cycle configuration for multiple light entities identified by their entity IDs.
+ ///
+ /// The entity IDs of the lights to configure.
+ /// An action to configure the cycle for these lights.
+ /// Specifies the behavior for lights not included in this scoped configuration. Defaults to .
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator ForLights(IEnumerable lightIds, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None);
+
+ ///
+ /// Creates a scoped cycle configuration for multiple light entities.
+ ///
+ /// The light entities to configure.
+ /// An action to configure the cycle for these lights.
+ /// Specifies the behavior for lights not included in this scoped configuration. Defaults to .
+ /// The configurator instance for method chaining.
+ ILightTransitionCycleConfigurator ForLights(IEnumerable lightEntities, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None);
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Cycle/LightTransitionCycleConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/Cycle/LightTransitionCycleConfigurator.cs
new file mode 100644
index 0000000..8350ca1
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Cycle/LightTransitionCycleConfigurator.cs
@@ -0,0 +1,98 @@
+using System.Reactive.Concurrency;
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.AutomationPipelines.Lights.Extensions;
+using CodeCasa.AutomationPipelines.Lights.Nodes;
+using CodeCasa.Lights;
+using CodeCasa.Lights.Extensions;
+
+namespace CodeCasa.AutomationPipelines.Lights.Cycle;
+
+internal class LightTransitionCycleConfigurator(ILight light, IScheduler scheduler) : ILightTransitionCycleConfigurator
+{
+ public ILight Light { get; } = light;
+
+ internal List<(Func> nodeFactory, Func matchesNodeState)> CycleNodeFactories
+ {
+ get;
+ } = [];
+
+ public ILightTransitionCycleConfigurator AddOff()
+ {
+ return Add(_ => Light.IsOff());
+ }
+
+ public ILightTransitionCycleConfigurator AddOn()
+ {
+ return Add(LightTransition.On());
+ }
+
+ public ILightTransitionCycleConfigurator Add(LightParameters lightParameters, IEqualityComparer? comparer = null)
+ {
+ return Add(lightParameters.AsTransition(), comparer);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightParametersFactory, Func matchesNodeState)
+ {
+ return Add(c => lightParametersFactory(c)?.AsTransition(), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightParametersFactory, Func matchesNodeState)
+ {
+ return Add((c, t) => lightParametersFactory(c, t)?.AsTransition(), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(LightTransition lightTransition, IEqualityComparer? comparer = null)
+ {
+ comparer ??= EqualityComparer.Default;
+ return Add(new StaticLightTransitionNode(lightTransition, scheduler), _ => comparer.Equals(
+ Light.GetParameters(),
+ lightTransition.LightParameters));
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightTransitionFactory, Func matchesNodeState)
+ {
+ return Add(c => new StaticLightTransitionNode(lightTransitionFactory(c), scheduler), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func lightTransitionFactory, Func matchesNodeState)
+ {
+ return Add(c => new FactoryNode(t => lightTransitionFactory(c, t)), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func matchesNodeState) where TNode : IPipelineNode
+ {
+ return Add(c => c.ServiceProvider.CreateInstanceWithinContext(c), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(IPipelineNode node, Func matchesNodeState)
+ {
+ return Add(_ => node, matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator Add(Func> nodeFactory, Func matchesNodeState)
+ {
+ CycleNodeFactories.Add((nodeFactory, matchesNodeState));
+ return this;
+ }
+
+ public ILightTransitionCycleConfigurator AddPassThrough(Func matchesNodeState)
+ {
+ return Add(new PassThroughNode(), matchesNodeState);
+ }
+
+ public ILightTransitionCycleConfigurator ForLight(string lightId, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None) => ForLights([lightId], configure, excludedLightBehaviour);
+
+ public ILightTransitionCycleConfigurator ForLight(ILight light, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None) => ForLights([light], configure, excludedLightBehaviour);
+
+ public ILightTransitionCycleConfigurator ForLights(IEnumerable lightIds, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None)
+ {
+ CompositeHelper.ValidateLightSupported(lightIds, Light.Id);
+ return this;
+ }
+
+ public ILightTransitionCycleConfigurator ForLights(IEnumerable lightEntities, Action configure, ExcludedLightBehaviours excludedLightBehaviour = ExcludedLightBehaviours.None)
+ {
+ CompositeHelper.ResolveGroupsAndValidateLightSupported(lightEntities, Light.Id);
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/DimmerOptions.cs b/src/CodeCasa.AutomationPipelines.Lights/DimmerOptions.cs
new file mode 100644
index 0000000..977319f
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/DimmerOptions.cs
@@ -0,0 +1,43 @@
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights;
+
+///
+/// Configuration options for dimmer behavior, including brightness levels, step sizes, and timing.
+///
+public record DimmerOptions
+{
+ ///
+ /// Gets or sets the minimum brightness level. Defaults to 2.
+ ///
+ public int MinBrightness { get; set; } = 2;
+
+ ///
+ /// Gets or sets the brightness step size for each dimming increment. Defaults to 51.
+ ///
+ public int BrightnessStep { get; set; } = 51;
+
+ ///
+ /// Gets or sets the time delay between each brightness step. Defaults to 500 milliseconds.
+ ///
+ public TimeSpan TimeBetweenSteps { get; set; } = TimeSpan.FromMilliseconds(500);
+
+ ///
+ /// Gets or sets the collection of light IDs that define the order for dimming operations.
+ ///
+ public IEnumerable? DimOrderLightEntities { get; set; }
+
+ ///
+ /// Sets the light order for dimming operations based on the provided collection of light entities.
+ ///
+ /// The light entities that define the dimming order.
+ public void SetLightOrder(IEnumerable lightEntities) =>
+ DimOrderLightEntities = lightEntities.Select(l => l.Id).ToArray();
+
+ ///
+ /// Sets the light order for dimming operations based on the provided light entities.
+ ///
+ /// The light entities that define the dimming order.
+ public void SetLightOrder(params ILight[] lightEntities) =>
+ DimOrderLightEntities = lightEntities.Select(l => l.Id).ToArray();
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/ExcludedLightBehaviours.cs b/src/CodeCasa.AutomationPipelines.Lights/ExcludedLightBehaviours.cs
new file mode 100644
index 0000000..a2436e3
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/ExcludedLightBehaviours.cs
@@ -0,0 +1,17 @@
+namespace CodeCasa.AutomationPipelines.Lights;
+
+///
+/// Specifies the behavior for lights that are excluded from specific pipeline operations.
+///
+public enum ExcludedLightBehaviours
+{
+ ///
+ /// No behavior will be defined for excluded lights.
+ /// Either add behavior specifically, or they will be out of sync when toggling or cycling through behaviors.
+ ///
+ None,
+ ///
+ /// A simple pass-through node is added for excluded lights to ensure they stay in sync when toggling or cycling through behaviors.
+ ///
+ PassThrough
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/DimmerOptionsExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/DimmerOptionsExtensions.cs
new file mode 100644
index 0000000..c7ad5a7
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/DimmerOptionsExtensions.cs
@@ -0,0 +1,45 @@
+namespace CodeCasa.AutomationPipelines.Lights.Extensions
+{
+ internal static class DimmerOptionsExtensions
+ {
+ public static void ValidateSingleLight(this DimmerOptions dimmerOptions, string lightId)
+ {
+ var dimOrderLightEntitiesArray = dimmerOptions.DimOrderLightEntities?.ToArray();
+ if (dimOrderLightEntitiesArray != null && dimOrderLightEntitiesArray.Any())
+ {
+ var extraEntities = dimOrderLightEntitiesArray.Where(l => l != lightId).ToArray();
+ if (extraEntities.Any())
+ {
+ throw new InvalidOperationException(
+ $"Builder only supports entity {lightId}. Please remove extra entities {string.Join(", ", extraEntities)}.");
+ }
+ }
+ }
+
+ public static OrderedDictionary ValidateAndOrderMultipleLightTypes(this DimmerOptions dimmerOptions, Dictionary typesByLightIds)
+ {
+ var dimOrderLightEntitiesArray = dimmerOptions.DimOrderLightEntities?.ToArray();
+ if (dimOrderLightEntitiesArray != null && dimOrderLightEntitiesArray.Any())
+ {
+ var missingEntities = typesByLightIds.Keys.Except(dimOrderLightEntitiesArray).ToArray();
+ if (missingEntities.Any())
+ {
+ throw new InvalidOperationException(
+ $"When providing dim order, all entities should be provided. The following entities are missing: {string.Join(", ", missingEntities)}. Make sure to provide low level entities.");
+ }
+
+ var extraEntities = dimOrderLightEntitiesArray.Except(typesByLightIds.Keys).ToArray();
+ if (extraEntities.Any())
+ {
+ throw new InvalidOperationException(
+ $"Pipeline does not contain the following entities: {string.Join(", ", extraEntities)}. Make sure to provide low level entities.");
+ }
+
+ return new OrderedDictionary(dimOrderLightEntitiesArray
+ .Select(e => new KeyValuePair(e, typesByLightIds[e])));
+ }
+
+ return new OrderedDictionary(typesByLightIds);
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/EnumerableExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/EnumerableExtensions.cs
new file mode 100644
index 0000000..e4ead0a
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/EnumerableExtensions.cs
@@ -0,0 +1,13 @@
+namespace CodeCasa.AutomationPipelines.Lights.Extensions
+{
+ internal static class EnumerableExtensions
+ {
+ public static void ForEach(this IEnumerable source, Action action)
+ {
+ foreach (var item in source)
+ {
+ action(item);
+ }
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs
new file mode 100644
index 0000000..7fc455e
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs
@@ -0,0 +1,47 @@
+using System.Reactive;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.AutomationPipelines.Lights.Nodes;
+using CodeCasa.Lights;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeCasa.AutomationPipelines.Lights.Extensions
+{
+ ///
+ /// Provides extension methods for instances.
+ /// These extensions enable adding timeout behavior to light transition nodes.
+ ///
+ 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.
+ ///
+ /// 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.
+ public static IPipelineNode TurnOffAfter(this IPipelineNode node,
+ TimeSpan timeSpan, IScheduler 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.
+ ///
+ /// 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)
+ {
+ return new ResettableTimeoutNode(node, timeSpan, resetTimerObservable, scheduler);
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ObservableExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ObservableExtensions.cs
new file mode 100644
index 0000000..d745840
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ObservableExtensions.cs
@@ -0,0 +1,75 @@
+using System.Reactive;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+
+namespace CodeCasa.AutomationPipelines.Lights.Extensions
+{
+ internal static class ObservableExtensions
+ {
+ public static IObservable ToPulsesWhenTrue(this IObservable source, TimeSpan timeBetweenPulses, IScheduler scheduler)
+ {
+ return source
+ .Select(b =>
+ b
+ ? Observable.Timer(TimeSpan.Zero, timeBetweenPulses, scheduler).Select(_ => Unit.Default)
+ : Observable.Empty())
+ .Switch();
+ }
+
+ public static IObservable ToCycleObservable(
+ this IObservable triggerObservable,
+ IEnumerable<(Func valueFactory, Func valueIsActiveFunc)> cycleValues)
+ {
+ var cycleValuesList = cycleValues.ToList();
+ return triggerObservable.Select(_ =>
+ {
+ var index = cycleValuesList.FindIndex(n => n.valueIsActiveFunc()) + 1;
+ if (index >= cycleValuesList.Count)
+ {
+ index = 0;
+ }
+
+ return cycleValuesList[index].valueFactory();
+ });
+ }
+
+ public static IObservable ToToggleObservable(
+ this IObservable triggerObservable,
+ Func offCondition,
+ Func offValueFactory,
+ IEnumerable> valueFactories,
+ TimeSpan timeout,
+ bool? includeOff)
+ {
+ var valueFactoryArray = valueFactories.ToArray();
+ var includeOffBool = includeOff ?? valueFactoryArray.Length <= 1;
+ if (!includeOffBool && valueFactoryArray.Length <= 1)
+ {
+ throw new InvalidOperationException("When only supplying one factory, off should be included.");
+ }
+ DateTime? previousLastChanged = null;
+ var index = 0;
+ var maxIndexValue = includeOffBool ? valueFactoryArray.Length : valueFactoryArray.Length - 1;
+ return triggerObservable
+ .Select(_ =>
+ {
+ var utcNow = DateTime.UtcNow;
+ var consecutive = previousLastChanged != null && utcNow - previousLastChanged < timeout;
+ previousLastChanged = utcNow;
+
+ if (!consecutive)
+ {
+ index = 0;
+ if (offCondition())
+ {
+ return offValueFactory();
+ }
+ }
+
+ var value = index >= valueFactoryArray.Length ? offValueFactory() : valueFactoryArray[index]();
+ index = index < maxIndexValue ? index + 1 : 0;
+ return value;
+ });
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/SchedulerExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/SchedulerExtensions.cs
new file mode 100644
index 0000000..2659045
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/SchedulerExtensions.cs
@@ -0,0 +1,65 @@
+
+using System.Reactive.Concurrency;
+using CodeCasa.Lights;
+using CodeCasa.Lights.Extensions;
+
+namespace CodeCasa.AutomationPipelines.Lights.Extensions
+{
+ internal static class SchedulerExtensions
+ {
+ ///
+ /// Schedules a smooth light transition between two states,
+ /// optionally interpolating progress if the transition is already in progress.
+ ///
+ public static IDisposable? ScheduleInterpolatedLightTransition(
+ this IScheduler scheduler,
+ LightParameters? sourceLightParameters,
+ LightParameters? destinationLightParameters,
+ DateTime? startOfTransition,
+ DateTime? endOfTransition,
+ Action transitionAction,
+ int defaultTransitionTimeMs = 500)
+ {
+ if (destinationLightParameters == null)
+ {
+ transitionAction(null);
+ return null;
+ }
+
+ if (endOfTransition == null)
+ {
+ // If there is no end of transition specified, we simply go to the destination state immediately.
+ transitionAction(destinationLightParameters.AsTransition());
+ return null;
+ }
+
+ var utcNow = DateTime.UtcNow;
+ // Note: this can be negative.
+ var timeToEndOfInputTransition = endOfTransition.Value - utcNow;
+ // For any transition under half a second we simply don't provide a transition. Lights will just smoothly go to the corresponding state.
+ if (timeToEndOfInputTransition <= TimeSpan.FromMilliseconds(defaultTransitionTimeMs))
+ {
+ transitionAction(destinationLightParameters.AsTransition());
+ return null;
+ }
+
+ if (sourceLightParameters == null || startOfTransition == null)
+ {
+ // We don't know the original parameters or the start of this transition, so we simply transition from where we are towards the new.
+ transitionAction(destinationLightParameters.AsTransition(timeToEndOfInputTransition));
+ return null;
+ }
+
+ var total = (endOfTransition.Value - startOfTransition.Value).TotalSeconds;
+ var elapsed = (utcNow - startOfTransition.Value).TotalSeconds;
+ var progress = elapsed / total;
+
+ var initialState = sourceLightParameters.Interpolate(destinationLightParameters, progress);
+ // First (re)set the lights to where they would have been.
+ transitionAction(initialState.AsTransition());
+ // After the lights have been reset (default transition takes half a second), continue the rest of the transition.
+ return scheduler.Schedule(TimeSpan.FromMilliseconds(defaultTransitionTimeMs),
+ () => transitionAction(destinationLightParameters.AsTransition(timeToEndOfInputTransition - TimeSpan.FromMilliseconds(defaultTransitionTimeMs))));
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceCollectionExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..50b27d8
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,27 @@
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.AutomationPipelines.Lights.Pipeline;
+using CodeCasa.AutomationPipelines.Lights.ReactiveNode;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeCasa.AutomationPipelines.Lights.Extensions;
+
+///
+/// Extension methods for registering light pipeline services in the dependency injection container.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers all required services for light pipelines in the service collection.
+ ///
+ /// The service collection to register services in.
+ /// The service collection for method chaining.
+ public static IServiceCollection AddLightPipelines(this IServiceCollection serviceCollection)
+ {
+ return serviceCollection
+ .AddTransient()
+ .AddTransient()
+ .AddSingleton()
+ .AddTransient(serviceProvider =>
+ serviceProvider.GetRequiredService().GetLightPipelineContext());
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs
new file mode 100644
index 0000000..f069930
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs
@@ -0,0 +1,26 @@
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.Lights;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeCasa.AutomationPipelines.Lights.Extensions;
+
+internal static class ServiceProviderExtensions
+{
+ public static T
+ CreateInstanceWithinContext(this IServiceProvider serviceProvider, ILight light) =>
+ serviceProvider.CreateInstanceWithinContext(new LightPipelineContext(serviceProvider, light));
+
+ public static T
+ CreateInstanceWithinContext(this IServiceProvider serviceProvider, ILightPipelineContext context) =>
+ (T)serviceProvider.CreateInstanceWithinContext(typeof(T), context);
+
+ public static object CreateInstanceWithinContext(this IServiceProvider serviceProvider, Type instanceType,
+ ILightPipelineContext context)
+ {
+ var contextProvider = serviceProvider.GetRequiredService();
+ contextProvider.SetLightPipelineContext(context);
+ var instance = ActivatorUtilities.CreateInstance(serviceProvider, instanceType);
+ contextProvider.ResetLight();
+ return instance;
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/CompositeDimmer.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/CompositeDimmer.cs
new file mode 100644
index 0000000..d009ed0
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/CompositeDimmer.cs
@@ -0,0 +1,40 @@
+using System.Reactive.Linq;
+using CodeCasa.Abstractions;
+
+
+namespace CodeCasa.AutomationPipelines.Lights.Nodes
+{
+ ///
+ /// A composite dimmer that combines multiple dimmers and emits true when any of them are actively dimming or brightening.
+ ///
+ public class CompositeDimmer : IDimmer
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The collection of dimmers to combine. Must contain at least one dimmer.
+ /// Thrown when the dimmers collection is null or empty.
+ public CompositeDimmer(IEnumerable dimmers)
+ {
+ dimmers = dimmers.ToArray();
+ if (dimmers == null || !dimmers.Any())
+ throw new ArgumentException("At least one dimmer must be provided.", nameof(dimmers));
+
+ Dimming = dimmers
+ .Select(d => d.Dimming)
+ .CombineLatest(x => x.Any())
+ .DistinctUntilChanged();
+
+ Brightening = dimmers
+ .Select(d => d.Brightening)
+ .CombineLatest(x => x.Any())
+ .DistinctUntilChanged();
+ }
+
+ ///
+ public IObservable Dimming { get; }
+
+ ///
+ public IObservable Brightening { get; }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/DimHelper.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/DimHelper.cs
new file mode 100644
index 0000000..4e60596
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/DimHelper.cs
@@ -0,0 +1,119 @@
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Nodes;
+
+internal class DimHelper(
+ ILight subject,
+ IEnumerable lightsInDimOrder,
+ int minBrightness,
+ int brightnessStep)
+{
+ private readonly ILight[] _lightsInDimOrder = lightsInDimOrder.ToArray();
+
+ public LightTransition? DimStep()
+ {
+ var lightToTurnOff = ShouldTurnOffToDim();
+ if (lightToTurnOff?.Id == subject.Id)
+ {
+ return LightTransition.Off();
+ }
+ if (lightToTurnOff != null)
+ {
+ // Another node should act. We shouldn't do anything.
+ return null;
+ }
+
+ var parameters = subject.GetParameters();
+ var oldBrightness = parameters.Brightness ?? 0;
+ if (oldBrightness == 0)
+ {
+ return null;
+ }
+ var newBrightness = Math.Max(minBrightness, oldBrightness - brightnessStep);
+ if (newBrightness == oldBrightness)
+ {
+ return null;
+ }
+
+ return (parameters with { Brightness = newBrightness }).AsTransition();
+ }
+
+ public LightTransition? BrightenStep()
+ {
+ var lightToTurnOn = ShouldTurnOnToBrighten();
+ if (lightToTurnOn?.Id == subject.Id)
+ {
+ return (subject.GetParameters() with { Brightness = minBrightness }).AsTransition();
+ }
+ if (lightToTurnOn != null)
+ {
+ // Another node should act. We shouldn't do anything.
+ return null;
+ }
+
+ var parameters = subject.GetParameters();
+ var oldBrightness = parameters.Brightness ?? 0;
+ if (oldBrightness == 0)
+ {
+ return null;
+ }
+ var newBrightness = Math.Min(255, oldBrightness + brightnessStep);
+ if (newBrightness == oldBrightness)
+ {
+ return null;
+ }
+
+ return (parameters with { Brightness = newBrightness }).AsTransition();
+ }
+
+ private ILight? ShouldTurnOffToDim()
+ {
+ // todo: filter on availability
+ ILight? lightToTurnOff = null;
+ foreach (var light in _lightsInDimOrder)
+ {
+ var brightness = light.GetParameters().Brightness ?? 0;
+ if (brightness == 0)
+ {
+ continue;
+ }
+ if (brightness > minBrightness)
+ {
+ // If any light is brighter than MinBrightness, we don't turn anything off, we need to dim.
+ return null;
+ }
+
+ lightToTurnOff ??= light;
+ }
+
+ return lightToTurnOff;
+ }
+
+ private ILight? ShouldTurnOnToBrighten()
+ {
+ Console.WriteLine($"What node for {subject.Id} sees:");
+ foreach (var light in _lightsInDimOrder.Reverse())
+ {
+ Console.WriteLine($"{light.Id}: {light.GetParameters()}");
+ }
+
+ // This method is a bit more specific: it will only return true if lights are turned on in the correct order. If not, we want to keep the lights that are off, off.
+ ILight? lightToTurnOn = null;
+ foreach (var light in _lightsInDimOrder.Reverse())
+ {
+ var brightness = light.GetParameters().Brightness ?? 0;
+ if (brightness >= minBrightness) // On
+ {
+ if (lightToTurnOn != null || brightness > minBrightness)
+ {
+ return null;
+ }
+ continue;
+ }
+
+ lightToTurnOn ??= light;
+ }
+
+ return lightToTurnOn;
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/FactoryNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/FactoryNode.cs
new file mode 100644
index 0000000..267d579
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/FactoryNode.cs
@@ -0,0 +1,11 @@
+namespace CodeCasa.AutomationPipelines.Lights.Nodes;
+
+internal class FactoryNode(Func lightTransitionFactory)
+ : PipelineNode
+{
+ ///
+ protected override void InputReceived(TState? input)
+ {
+ Output = lightTransitionFactory(input);
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/LightTransitionNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/LightTransitionNode.cs
new file mode 100644
index 0000000..f4179fc
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/LightTransitionNode.cs
@@ -0,0 +1,169 @@
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using CodeCasa.AutomationPipelines.Lights.Extensions;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Nodes
+{
+ ///
+ /// Base class for pipeline nodes that work with light transitions, extending IPipelineNode functionality.
+ /// Provides features for managing light transition states, scheduling, and pass-through behavior.
+ ///
+ public abstract class LightTransitionNode(IScheduler scheduler) : IPipelineNode
+ {
+ private readonly Subject _newOutputSubject = new();
+ private LightTransition? _input;
+ private LightParameters? _inputLightDestinationParameters;
+ private DateTime? _inputStartOfTransition;
+ private DateTime? _inputEndOfTransition;
+ private LightTransition? _output;
+ private bool _passThroughNextInput;
+ private bool _passThrough;
+ private IDisposable? _scheduledAction;
+
+ ///
+ /// Gets the source light parameters from the previous input, useful for interpolating transitions.
+ ///
+ protected LightParameters? InputLightSourceParameters { get; private set; }
+
+ ///
+ public IObservable OnNewOutput => _newOutputSubject.AsObservable();
+
+ ///
+ public LightTransition? Input
+ {
+ get => _input;
+ set
+ {
+ _scheduledAction?.Dispose(); // Always cancel scheduled actions when the input changes.
+ // We save additional information on the light transition that we can later use to continue the transition if it would be interrupted.
+ InputLightSourceParameters = _inputLightDestinationParameters;
+ _input = value;
+ _inputLightDestinationParameters = value?.LightParameters;
+ var transitionTime = value?.TransitionTime;
+ _inputStartOfTransition = DateTime.UtcNow;
+ _inputEndOfTransition = transitionTime == null ? null : _inputStartOfTransition + transitionTime;
+
+ if (_passThroughNextInput)
+ {
+ PassThrough = true;
+ return;
+ }
+ if (PassThrough)
+ {
+ SetOutputInternal(_input);
+ return;
+ }
+ InputReceived(_input);
+ }
+ }
+
+ ///
+ /// Called when the input is received. Override this method to implement custom input handling logic.
+ ///
+ /// The light transition input that was received.
+ protected virtual void InputReceived(LightTransition? input)
+ {
+ // Ignore input by default.
+ }
+
+ ///
+ /// Enables pass-through mode for the node, causing it to pass the input directly to the output without processing.
+ ///
+ protected void PassInputThrough()
+ {
+ PassThrough = true;
+ }
+
+ ///
+ /// Gets or sets the output state of the node.
+ /// Setting this value will trigger output processing and disable pass-through mode.
+ ///
+ public LightTransition? Output
+ {
+ get => _output;
+ protected set
+ {
+ _scheduledAction?.Dispose(); // Always cancel scheduled actions when the output is changed directly.
+ PassThrough = false;
+
+ SetOutputInternal(value);
+ }
+ }
+
+ ///
+ /// Schedules an interpolated light transition that will animate from source to desired parameters using the input's transition time.
+ ///
+ /// The source light parameters to transition from.
+ /// The desired light parameters to transition to.
+ protected void ScheduleInterpolatedLightTransitionUsingInputTransitionTime(LightParameters? sourceLightParameters, LightParameters? desiredLightParameters)
+ {
+ PassThrough = false;
+ _scheduledAction = scheduler.ScheduleInterpolatedLightTransition(sourceLightParameters,
+ desiredLightParameters, _inputStartOfTransition, _inputEndOfTransition, SetOutputInternal);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the node should pass its input directly to the output.
+ /// When true, the node does not call InputReceived and instead passes the input through unchanged.
+ ///
+ public bool PassThrough
+ {
+ get => _passThrough;
+ set
+ {
+ // Always reset _passThroughNextInput when PassThrough is explicitly called.
+ _passThroughNextInput = false;
+
+ if (_passThrough == value)
+ {
+ return;
+ }
+
+ _scheduledAction?.Dispose(); // Always cancel scheduled actions when the pass through value changes.
+
+ _passThrough = value;
+ if (_passThrough)
+ {
+ _scheduledAction = scheduler.ScheduleInterpolatedLightTransition(InputLightSourceParameters,
+ _inputLightDestinationParameters, _inputStartOfTransition, _inputEndOfTransition, SetOutputInternal);
+ }
+ }
+ }
+
+ ///
+ /// Changes the output state of the node and enables pass-through mode after the next input is received.
+ /// This is useful for nodes that should influence pipeline behavior once, such as light switches or motion sensors.
+ ///
+ /// The output light transition to set.
+ protected void ChangeOutputAndTurnOnPassThroughOnNextInput(LightTransition? output)
+ {
+ Output = output;
+ TurnOnPassThroughOnNextInput();
+ }
+
+ ///
+ /// Keeps the current output but enables pass-through mode after receiving the next input.
+ /// This is useful for nodes that should influence pipeline behavior once, such as light switches or motion sensors.
+ ///
+ protected void TurnOnPassThroughOnNextInput()
+ {
+ if (PassThrough)
+ {
+ return;
+ }
+
+ _passThroughNextInput = true;
+ }
+
+ private void SetOutputInternal(LightTransition? output)
+ {
+ _output = output;
+ _newOutputSubject.OnNext(output);
+ }
+
+ ///
+ public override string ToString() => GetType().Name;
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/PassThroughNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/PassThroughNode.cs
new file mode 100644
index 0000000..370220a
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/PassThroughNode.cs
@@ -0,0 +1,9 @@
+namespace CodeCasa.AutomationPipelines.Lights.Nodes;
+
+internal class PassThroughNode : PipelineNode
+{
+ public PassThroughNode()
+ {
+ PassThrough = true;
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs
new file mode 100644
index 0000000..a9da7a4
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs
@@ -0,0 +1,29 @@
+using System.Reactive;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Nodes
+{
+ internal class ResettableTimeoutNode : LightTransitionNode{
+ public ResettableTimeoutNode(IPipelineNode childNode, TimeSpan turnOffTime,
+ IObservable refreshObservable, IScheduler scheduler) : base(scheduler)
+ {
+ var serializedChild = childNode.OnNewOutput.Prepend(childNode.Output).ObserveOn(scheduler);
+
+ var serializedTurnOff =
+ refreshObservable.Select(_ => Unit.Default)
+ .Prepend(Unit.Default)
+ .Throttle(turnOffTime, scheduler)
+ .Take(1)
+ .ObserveOn(scheduler);
+
+ serializedChild
+ .TakeUntil(serializedTurnOff)
+ .Subscribe(output => { Output = output; });
+
+ serializedTurnOff
+ .Subscribe(_ => { ChangeOutputAndTurnOnPassThroughOnNextInput(LightTransition.Off()); });
+ }
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/ScopedNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ScopedNode.cs
new file mode 100644
index 0000000..b85eb2f
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ScopedNode.cs
@@ -0,0 +1,26 @@
+using CodeCasa.AutomationPipelines.Lights.Utils;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeCasa.AutomationPipelines.Lights.Nodes
+{
+ internal class ScopedNode(IServiceScope serviceScope, IPipelineNode innerNode)
+ : IPipelineNode, IAsyncDisposable
+ {
+ public async ValueTask DisposeAsync()
+ {
+ await serviceScope.DisposeOrDisposeAsync();
+ await innerNode.DisposeOrDisposeAsync();
+ }
+
+ public TState? Input
+ {
+ get => innerNode.Input;
+ set => innerNode.Input = value;
+ }
+
+ public TState? Output => innerNode.Output;
+ public IObservable OnNewOutput => innerNode.OnNewOutput;
+
+ public override string? ToString() => $"{innerNode} (scoped)";
+ }
+}
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/StaticLightTransitionNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/StaticLightTransitionNode.cs
new file mode 100644
index 0000000..bde921d
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/StaticLightTransitionNode.cs
@@ -0,0 +1,12 @@
+using System.Reactive.Concurrency;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Nodes;
+
+internal class StaticLightTransitionNode : LightTransitionNode
+{
+ public StaticLightTransitionNode(LightTransition? output, IScheduler scheduler) : base(scheduler)
+ {
+ Output = output;
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/TurnOffThenPassThroughNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/TurnOffThenPassThroughNode.cs
new file mode 100644
index 0000000..3ad407e
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/TurnOffThenPassThroughNode.cs
@@ -0,0 +1,18 @@
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Nodes;
+
+internal class TurnOffThenPassThroughNode : PipelineNode
+{
+ public TurnOffThenPassThroughNode()
+ {
+ // Note: we cannot simply call ChangeOutputAndTurnOnPassThroughOnNextInput here, as the input will immediately be set when this node is added to the timeline.
+ Output = LightTransition.Off();
+ }
+
+ ///
+ protected override void InputReceived(LightTransition? input)
+ {
+ TurnOnPassThroughOnNextInput();
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Reactive.Cycle.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Reactive.Cycle.cs
new file mode 100644
index 0000000..8646086
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Reactive.Cycle.cs
@@ -0,0 +1,34 @@
+using CodeCasa.AutomationPipelines.Lights.Cycle;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Pipeline;
+
+internal partial class CompositeLightTransitionPipelineConfigurator
+{
+ public ILightTransitionPipelineConfigurator AddCycle(IObservable triggerObservable, IEnumerable lightParameters)
+ {
+ return AddReactiveNode(c => c.AddCycle(triggerObservable, lightParameters));
+ }
+
+ public ILightTransitionPipelineConfigurator AddCycle(IObservable triggerObservable,
+ params LightParameters[] lightParameters)
+ {
+ return AddReactiveNode(c => c.AddCycle(triggerObservable, lightParameters));
+ }
+
+ public ILightTransitionPipelineConfigurator AddCycle(IObservable triggerObservable, IEnumerable lightTransitions)
+ {
+ return AddReactiveNode(c => c.AddCycle(triggerObservable, lightTransitions));
+ }
+
+ public ILightTransitionPipelineConfigurator AddCycle(IObservable triggerObservable,
+ params LightTransition[] lightTransitions)
+ {
+ return AddReactiveNode(c => c.AddCycle(triggerObservable, lightTransitions));
+ }
+
+ public ILightTransitionPipelineConfigurator AddCycle(IObservable triggerObservable, Action configure)
+ {
+ return AddReactiveNode(c => c.AddCycle(triggerObservable, configure));
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Reactive.Toggle.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Reactive.Toggle.cs
new file mode 100644
index 0000000..ecdf339
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Reactive.Toggle.cs
@@ -0,0 +1,57 @@
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.AutomationPipelines.Lights.Toggle;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Pipeline;
+
+internal partial class CompositeLightTransitionPipelineConfigurator
+{
+ ///
+ public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable,
+ IEnumerable lightParameters)
+ {
+ return AddReactiveNode(c => c.AddToggle(triggerObservable, lightParameters));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable,
+ params LightParameters[] lightParameters)
+ {
+ return AddReactiveNode(c => c.AddToggle(triggerObservable, lightParameters));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable,
+ IEnumerable lightTransitions)
+ {
+ return AddReactiveNode(c => c.AddToggle(triggerObservable, lightTransitions));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable,
+ params LightTransition[] lightTransitions)
+ {
+ return AddReactiveNode(c => c.AddToggle(triggerObservable, lightTransitions));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable,
+ IEnumerable>> nodeFactories)
+ {
+ return AddReactiveNode(c => c.AddToggle(triggerObservable, nodeFactories));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable,
+ params Func>[] nodeFactories)
+ {
+ return AddReactiveNode(c => c.AddToggle(triggerObservable, nodeFactories));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable,
+ Action configure)
+ {
+ return AddReactiveNode(c => c.AddToggle(triggerObservable, configure));
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Switch.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Switch.cs
new file mode 100644
index 0000000..5bbdb00
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Switch.cs
@@ -0,0 +1,158 @@
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.AutomationPipelines.Lights.Extensions;
+using CodeCasa.AutomationPipelines.Lights.ReactiveNode;
+using Microsoft.Extensions.DependencyInjection;
+
+
+using System.Reactive.Linq;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Pipeline;
+
+internal partial class CompositeLightTransitionPipelineConfigurator
+{
+ ///
+ public ILightTransitionPipelineConfigurator Switch(LightParameters trueLightParameters,
+ LightParameters falseLightParameters) where TObservable : IObservable
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(trueLightParameters, falseLightParameters));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(IObservable observable, LightParameters trueLightParameters,
+ LightParameters falseLightParameters)
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(observable, trueLightParameters, falseLightParameters));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(Func trueLightParametersFactory,
+ Func falseLightParametersFactory) where TObservable : IObservable
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(trueLightParametersFactory, falseLightParametersFactory));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(IObservable observable, Func trueLightParametersFactory,
+ Func falseLightParametersFactory)
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(observable, trueLightParametersFactory, falseLightParametersFactory));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(LightTransition trueLightTransition,
+ LightTransition falseLightTransition) where TObservable : IObservable
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(trueLightTransition, falseLightTransition));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(IObservable observable, LightTransition trueLightTransition,
+ LightTransition falseLightTransition)
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(observable, trueLightTransition, falseLightTransition));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(Func trueLightTransitionFactory,
+ Func falseLightTransitionFactory) where TObservable : IObservable
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(trueLightTransitionFactory, falseLightTransitionFactory));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(IObservable observable, Func trueLightTransitionFactory,
+ Func falseLightTransitionFactory)
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(observable, trueLightTransitionFactory, falseLightTransitionFactory));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(Func> trueNodeFactory, Func> falseNodeFactory) where TObservable : IObservable
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(trueNodeFactory, falseNodeFactory));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(IObservable observable, Func> trueNodeFactory,
+ Func> falseNodeFactory)
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(observable, trueNodeFactory, falseNodeFactory));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch() where TObservable : IObservable where TTrueNode : IPipelineNode where TFalseNode : IPipelineNode
+ {
+ NodeContainers.Values.ForEach(b => b.Switch());
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator Switch(IObservable observable) where TTrueNode : IPipelineNode where TFalseNode : IPipelineNode
+ {
+ NodeContainers.Values.ForEach(b => b.Switch(observable));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddReactiveNodeSwitch(Action trueConfigure,
+ Action falseConfigure) where TObservable : IObservable
+ {
+ /*
+ * For this implementation we can either instantiate the TObservable for each container and pass configure to them individual, breaking composite dimming behavior.
+ * Or we can create a single TObservable without light context.
+ * I decided to go with the latter to preserve composite dimming behavior.
+ */
+ var observable = ActivatorUtilities.CreateInstance(serviceProvider);
+ return AddReactiveNodeSwitch(observable, trueConfigure, falseConfigure);
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddReactiveNodeSwitch(IObservable observable, Action trueConfigure,
+ Action falseConfigure)
+ {
+ // Note: we use CompositeLightTransitionPipelineConfigurator.AddReactiveNode so configure is also applied on the composite context.
+ return AddReactiveNode(c => c
+ .On(observable.Where(x => x), trueConfigure)
+ .On(observable.Where(x => !x), falseConfigure));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddPipelineSwitch(Action trueConfigure, Action falseConfigure) where TObservable : IObservable
+ {
+ var observable = ActivatorUtilities.CreateInstance(serviceProvider);
+ return AddPipelineSwitch(observable, trueConfigure, falseConfigure);
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator AddPipelineSwitch(IObservable observable, Action trueConfigure,
+ Action falseConfigure)
+ {
+ // Note: we use CompositeLightTransitionPipelineConfigurator.AddReactiveNode so configure is also applied on the composite context.
+ return AddReactiveNode(c => c
+ .On(observable.Where(x => x), trueConfigure)
+ .On(observable.Where(x => x), falseConfigure));
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator TurnOnOff() where TObservable : IObservable
+ {
+ return Switch(LightTransition.On(), LightTransition.Off());
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator TurnOnOff(IObservable observable)
+ {
+ return Switch(observable, LightTransition.On(), LightTransition.Off());
+ }
+}
\ No newline at end of file
diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.When.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.When.cs
new file mode 100644
index 0000000..320c1a6
--- /dev/null
+++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.When.cs
@@ -0,0 +1,172 @@
+using CodeCasa.AutomationPipelines.Lights.Context;
+using CodeCasa.AutomationPipelines.Lights.Extensions;
+using CodeCasa.AutomationPipelines.Lights.ReactiveNode;
+using Microsoft.Extensions.DependencyInjection;
+
+
+using System.Reactive.Linq;
+using CodeCasa.Lights;
+
+namespace CodeCasa.AutomationPipelines.Lights.Pipeline;
+
+internal partial class CompositeLightTransitionPipelineConfigurator
+{
+ ///
+ public ILightTransitionPipelineConfigurator When(LightParameters lightParameters)
+ where TObservable : IObservable
+ {
+ NodeContainers.Values.ForEach(b => b.When(lightParameters));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator When(IObservable observable,
+ LightParameters lightParameters)
+ {
+ NodeContainers.Values.ForEach(b => b.When(observable, lightParameters));
+ return this;
+ }
+
+ ///
+ public ILightTransitionPipelineConfigurator When(
+ Func