From 2a2e37491e460859b61342e9900a3edba6a38ed4 Mon Sep 17 00:00:00 2001 From: Jasper Date: Mon, 20 Jul 2026 13:18:02 +0200 Subject: [PATCH 1/7] Initial (thread unsafe) implementation of grouping mechanism --- .../Nodes/GroupNode.cs | 32 ++++ .../Nodes/IGroupNodeContext.cs | 144 ++++++++++++++++++ ...htTransitionPipelineConfigurator.Groups.cs | 21 +++ .../Pipeline/LightPipelineFactory.cs | 18 ++- ...htTransitionPipelineConfigurator.Groups.cs | 19 +++ .../LightTransitionPipelineConfigurator.cs | 4 +- 6 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs create mode 100644 src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs create mode 100644 src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs create mode 100644 src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Groups.cs diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs new file mode 100644 index 0000000..d416008 --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs @@ -0,0 +1,32 @@ +using CodeCasa.Lights; + +namespace CodeCasa.AutomationPipelines.Lights.Nodes; + +internal class GroupNode : PipelineNode +{ + private readonly IGroupNodeContext _groupNodeContext; + private readonly ILight _light; + + public GroupNode(ILight light, IGroupNodeContext groupNodeContext) + { + _light = light; + _groupNodeContext = groupNodeContext; + Name = "Group Node"; + } + + /// + protected override void InputReceived(LightTransition? input) + { + if (input != null) + { + _groupNodeContext.Process(_light, input); + } + } + + /// + public override async ValueTask DisposeAsync() + { + _groupNodeContext.Unregister(_light); + await base.DisposeAsync(); + } +} \ No newline at end of file diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs new file mode 100644 index 0000000..094bf42 --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs @@ -0,0 +1,144 @@ +using System.Reactive.Concurrency; +using CodeCasa.Lights; + +namespace CodeCasa.AutomationPipelines.Lights.Nodes +{ + public class GroupNodeContext : IGroupNodeContext + { + private readonly IScheduler _scheduler; + private readonly List _groups = new(); + + public GroupNodeContext(IScheduler scheduler) + { + _scheduler = scheduler; + } + + public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer equalityComparer) + { + var existingGroup = _groups.FirstOrDefault(g => g.LightGroup == lightGroup); + if (existingGroup == null) + { + existingGroup = new GroupInfo(lightGroup, light, equalityComparer, groupDuration, _scheduler); + _groups.Add(existingGroup); + } + else + { + existingGroup.AddMember(light); + } + } + + public void Process(ILight light, LightTransition transition) + { + var inputInfo = new InputInfo(DateTime.UtcNow, light, transition); + foreach (var group in _groups) + { + group.Process(inputInfo); + } + } + + public void Unregister(ILight light) + { + foreach (var group in _groups.ToArray()) + { + if (group.RemoveMember(light)) + { + _groups.Remove(group); + } + } + } + } + + public class InputInfo(DateTime timestamp, ILight light, LightTransition lightTransition) + { + public LightTransition Transition { get; } = lightTransition; + public ILight Light { get; } = light; + public DateTime Timestamp { get; } = timestamp; + public bool HasExecuted { get; private set; } + public void Execute() + { + if (HasExecuted) + { + return; + } + Light.ApplyTransition(Transition); + HasExecuted = true; + } + } + + public class GroupInfo + { + private readonly IEqualityComparer _equalityComparer; + public ILight LightGroup { get; } + private readonly List _groupMembers; + private readonly Dictionary _groupInputs = new(); + private readonly TimeSpan _groupDuration; + private readonly IScheduler _scheduler; + + public GroupInfo(ILight lightGroup, ILight firstGroupMember, IEqualityComparer equalityComparer, TimeSpan groupDuration, IScheduler scheduler) + { + LightGroup = lightGroup; + _groupMembers = new List { firstGroupMember }; + _equalityComparer = equalityComparer; + _scheduler = scheduler; + _groupDuration = groupDuration; + } + + public void AddMember(ILight member) + { + _groupMembers.Add(member); + } + + public bool RemoveMember(ILight member) + { + _groupMembers.Remove(member); + return _groupMembers.Any(); + } + + public IDisposable? Process(InputInfo inputInfo) + { + if (!_groupMembers.Contains(inputInfo.Light)) + { + return null; + } + + var utcNow = inputInfo.Timestamp; + foreach (var info in _groupInputs.Values.ToArray()) + { + if (info.HasExecuted) + { + // This can occur if the light is in multiple groups at once. + _groupInputs.Remove(info.Light); + continue; + } + if (info.Timestamp + _groupDuration < utcNow) + { + // We waited long enough for this light to be part of the group, but it never received a transition that matched the other lights in the group. + info.Execute(); + _groupInputs.Remove(info.Light); + } + } + if (_groupInputs.Where(kvp => kvp.Key != inputInfo.Light).All(kvp => _equalityComparer.Equals(kvp.Value.Transition, inputInfo.Transition))) + { + _groupInputs.Clear(); + LightGroup.ApplyTransition(inputInfo.Transition); + return null; + } + + if (_groupInputs.TryGetValue(inputInfo.Light, out var existingInput)) + { + existingInput.Execute(); + } + _groupInputs[inputInfo.Light] = inputInfo; + return _scheduler.Schedule(_groupDuration, () => + { + inputInfo.Execute(); + }); + } + } + + internal interface IGroupNodeContext + { + void Process(ILight light, LightTransition input); + void Unregister(ILight light); + } +} diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs new file mode 100644 index 0000000..5f5cbac --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs @@ -0,0 +1,21 @@ +using CodeCasa.Lights; + +namespace CodeCasa.AutomationPipelines.Lights.Pipeline; + +public partial interface ILightTransitionPipelineConfigurator +{ + /// + /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within 10 milliseconds. + /// + /// The light group entity. + /// The configurator instance for method chaining. + ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup); + + /// + /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within the specified time span. + /// + /// The light group entity. + /// The time span for the transition. + /// The configurator instance for method chaining. + ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan); +} \ No newline at end of file diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs index 1f72cb7..bf8d17a 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs @@ -117,14 +117,28 @@ internal Dictionary> CreateLightPipelines kvp.Key, kvp => { var conf = kvp.Value; + var nodes = conf.Nodes.ToList(); + + if (conf.LightGroups.Any()) + { + var groupNode = new GroupNode(conf.Light, groupContext); + foreach (var lightGroup in conf.LightGroups) + { + groupContext.Register(conf.Light, lightGroup.Key, lightGroup.Value, null); + } + nodes.Add(groupNode); + } + IPipeline pipeline = new Pipeline( LightTransition.Off(), - conf.Nodes, + nodes, conf.Light.ApplyTransition, - conf.EqualityComparer) + conf.DistinctEqualityComparer) { Name = conf.Name }; diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Groups.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Groups.cs new file mode 100644 index 0000000..929285d --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Groups.cs @@ -0,0 +1,19 @@ +using CodeCasa.Lights; + +namespace CodeCasa.AutomationPipelines.Lights.Pipeline; + +internal partial class LightTransitionPipelineConfigurator : IPipelineHierarchyContext +{ + public Dictionary LightGroups = new(); + + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup) + { + return UseLightGroup(lightGroup, TimeSpan.FromMilliseconds(10)); + } + + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan) + { + LightGroups[lightGroup] = timeSpan; + return this; + } +} diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.cs index 70cbb4c..14f8b1a 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.cs @@ -19,7 +19,7 @@ internal partial class LightTransitionPipelineConfigurator internal string? Name { get; set; } = "Pipeline"; internal TLight Light { get; } - internal IEqualityComparer? EqualityComparer { get; private set; } + internal IEqualityComparer? DistinctEqualityComparer { get; private set; } public LightTransitionPipelineConfigurator(IServiceProvider serviceProvider, TLight light) { @@ -50,7 +50,7 @@ public ILightTransitionPipelineConfigurator WithDistinctOutput() /// public ILightTransitionPipelineConfigurator WithDistinctOutput(IEqualityComparer equalityComparer) { - EqualityComparer = equalityComparer; + DistinctEqualityComparer = equalityComparer; return this; } From 7ecbd464497c91a648933c12c8e408be765a3617 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 23 Jul 2026 14:22:49 +0200 Subject: [PATCH 2/7] Added methods for compose implementation and handling thread safety. --- ...ionPipelineConfiguratorExtensions.Scene.cs | 2 - ...eactiveNodeConfiguratorExtensions.Scene.cs | 2 - .../Nodes/IGroupNodeContext.cs | 153 +++++++++++++----- ...htTransitionPipelineConfigurator.Groups.cs | 19 +++ .../Pipeline/LightPipelineFactory.cs | 2 +- 5 files changed, 130 insertions(+), 48 deletions(-) create mode 100644 src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs diff --git a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs index b4a5c61..f071f64 100644 --- a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs +++ b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs @@ -1,6 +1,4 @@ -using CodeCasa.AutomationPipelines.Lights.Cycle; using CodeCasa.AutomationPipelines.Lights.Pipeline; -using CodeCasa.AutomationPipelines.Lights.Toggle; using CodeCasa.Lights.NetDaemon; using CodeCasa.Lights.NetDaemon.Scenes; using NetDaemon.HassModel.Entities; diff --git a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs index c828cf9..b52f403 100644 --- a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs +++ b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs @@ -1,6 +1,4 @@ -using CodeCasa.AutomationPipelines.Lights.Cycle; using CodeCasa.AutomationPipelines.Lights.ReactiveNode; -using CodeCasa.AutomationPipelines.Lights.Toggle; using CodeCasa.Lights.NetDaemon; using CodeCasa.Lights.NetDaemon.Scenes; using NetDaemon.HassModel.Entities; diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs index 094bf42..3356619 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs @@ -7,42 +7,54 @@ public class GroupNodeContext : IGroupNodeContext { private readonly IScheduler _scheduler; private readonly List _groups = new(); + private readonly object _lock = new(); public GroupNodeContext(IScheduler scheduler) { _scheduler = scheduler; } - public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer equalityComparer) + public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer? equalityComparer) { - var existingGroup = _groups.FirstOrDefault(g => g.LightGroup == lightGroup); - if (existingGroup == null) + lock (_lock) { - existingGroup = new GroupInfo(lightGroup, light, equalityComparer, groupDuration, _scheduler); - _groups.Add(existingGroup); - } - else - { - existingGroup.AddMember(light); + var existingGroup = _groups.FirstOrDefault(g => g.LightGroup == lightGroup); + if (existingGroup == null) + { + existingGroup = new GroupInfo(lightGroup, light, equalityComparer ?? EqualityComparer.Default, groupDuration, _scheduler); + _groups.Add(existingGroup); + } + else + { + existingGroup.AddMember(light); + } } } public void Process(ILight light, LightTransition transition) { var inputInfo = new InputInfo(DateTime.UtcNow, light, transition); - foreach (var group in _groups) + lock (_lock) { - group.Process(inputInfo); + foreach (var group in _groups) + { + group.Process(inputInfo); + } } + } public void Unregister(ILight light) { - foreach (var group in _groups.ToArray()) + lock (_lock) { - if (group.RemoveMember(light)) + foreach (var group in _groups.ToArray()) { - _groups.Remove(group); + if (group.RemoveMember(light)) + { + _groups.Remove(group); + group.Dispose(); + } } } } @@ -65,14 +77,16 @@ public void Execute() } } - public class GroupInfo + public class GroupInfo : IDisposable { private readonly IEqualityComparer _equalityComparer; public ILight LightGroup { get; } private readonly List _groupMembers; private readonly Dictionary _groupInputs = new(); + private readonly Dictionary _scheduledWork = new(); private readonly TimeSpan _groupDuration; private readonly IScheduler _scheduler; + private readonly object _lock = new(); public GroupInfo(ILight lightGroup, ILight firstGroupMember, IEqualityComparer equalityComparer, TimeSpan groupDuration, IScheduler scheduler) { @@ -85,54 +99,107 @@ public GroupInfo(ILight lightGroup, ILight firstGroupMember, IEqualityComparer kvp.Key != inputInfo.Light).All(kvp => _equalityComparer.Equals(kvp.Value.Transition, inputInfo.Transition))) { - // We waited long enough for this light to be part of the group, but it never received a transition that matched the other lights in the group. - info.Execute(); - _groupInputs.Remove(info.Light); + _groupInputs.Clear(); + CleanupAllScheduledWork(); + LightGroup.ApplyTransition(inputInfo.Transition); + return; } + + if (_groupInputs.TryGetValue(inputInfo.Light, out var existingInput)) + { + existingInput.Execute(); + CleanupScheduledWork(inputInfo.Light); + } + + _groupInputs[inputInfo.Light] = inputInfo; + var scheduledWork = _scheduler.Schedule(_groupDuration, () => + { + lock (_lock) + { + inputInfo.Execute(); + _scheduledWork.Remove(inputInfo.Light); + } + }); + _scheduledWork[inputInfo.Light] = scheduledWork; } - if (_groupInputs.Where(kvp => kvp.Key != inputInfo.Light).All(kvp => _equalityComparer.Equals(kvp.Value.Transition, inputInfo.Transition))) + } + + private void CleanupScheduledWork(ILight light) + { + if (_scheduledWork.TryGetValue(light, out var disposable)) { - _groupInputs.Clear(); - LightGroup.ApplyTransition(inputInfo.Transition); - return null; + disposable.Dispose(); + _scheduledWork.Remove(light); } + } - if (_groupInputs.TryGetValue(inputInfo.Light, out var existingInput)) + private void CleanupAllScheduledWork() + { + foreach (var disposable in _scheduledWork.Values) { - existingInput.Execute(); + disposable.Dispose(); } - _groupInputs[inputInfo.Light] = inputInfo; - return _scheduler.Schedule(_groupDuration, () => + _scheduledWork.Clear(); + } + + public void Dispose() + { + lock (_lock) { - inputInfo.Execute(); - }); + CleanupAllScheduledWork(); + } } } diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs new file mode 100644 index 0000000..ec20780 --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs @@ -0,0 +1,19 @@ +using CodeCasa.AutomationPipelines.Lights.Extensions; +using CodeCasa.Lights; + +namespace CodeCasa.AutomationPipelines.Lights.Pipeline; + +internal partial class CompositeLightTransitionPipelineConfigurator : IPipelineHierarchyContext +{ + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup) + { + NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup)); + return this; + } + + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan) + { + NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup, timeSpan)); + return this; + } +} diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs index bf8d17a..bb2e7a0 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs @@ -117,7 +117,7 @@ internal Dictionary> CreateLightPipelines()); return configurators.ToDictionary(kvp => kvp.Key, kvp => { From b3028c5e1e44b1b85a4222ae18b0e4edc6f0c186 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 23 Jul 2026 14:33:25 +0200 Subject: [PATCH 3/7] Refactoring and bugfixing. --- .../Nodes/IGroupNodeContext.cs | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs index 3356619..4de54e7 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs @@ -7,7 +7,7 @@ public class GroupNodeContext : IGroupNodeContext { private readonly IScheduler _scheduler; private readonly List _groups = new(); - private readonly object _lock = new(); + private readonly Lock _lock = new(); public GroupNodeContext(IScheduler scheduler) { @@ -86,7 +86,7 @@ public class GroupInfo : IDisposable private readonly Dictionary _scheduledWork = new(); private readonly TimeSpan _groupDuration; private readonly IScheduler _scheduler; - private readonly object _lock = new(); + private readonly Lock _lock = new(); public GroupInfo(ILight lightGroup, ILight firstGroupMember, IEqualityComparer equalityComparer, TimeSpan groupDuration, IScheduler scheduler) { @@ -111,12 +111,7 @@ public bool RemoveMember(ILight member) { _groupMembers.Remove(member); _groupInputs.Remove(member); - - if (_scheduledWork.TryGetValue(member, out var disposable)) - { - disposable.Dispose(); - _scheduledWork.Remove(member); - } + CleanupScheduledWork(member); return !_groupMembers.Any(); } @@ -131,44 +126,39 @@ public void Process(InputInfo inputInfo) return; } - var utcNow = inputInfo.Timestamp; - foreach (var info in _groupInputs.Values.ToArray()) + // Clean up expired or executed inputs + CleanupExpiredInputs(inputInfo.Timestamp); + + // If there's an existing input for this light, execute it first + if (_groupInputs.TryGetValue(inputInfo.Light, out var existingInput)) { - if (info.HasExecuted) - { - // This can occur if the light is in multiple groups at once. - _groupInputs.Remove(info.Light); - CleanupScheduledWork(info.Light); - continue; - } - if (info.Timestamp + _groupDuration < utcNow) - { - // We waited long enough for this light to be part of the group, but it never received a transition that matched the other lights in the group. - info.Execute(); - _groupInputs.Remove(info.Light); - CleanupScheduledWork(info.Light); - } + existingInput.Execute(); + CleanupScheduledWork(inputInfo.Light); } - if (_groupInputs.Where(kvp => kvp.Key != inputInfo.Light).All(kvp => _equalityComparer.Equals(kvp.Value.Transition, inputInfo.Transition))) + + // Add the new input + _groupInputs[inputInfo.Light] = inputInfo; + + // Check if all group members now have matching transitions + if (AllMembersHaveMatchingTransitions(inputInfo.Transition)) { + // All members are in sync - apply to the group instead _groupInputs.Clear(); CleanupAllScheduledWork(); LightGroup.ApplyTransition(inputInfo.Transition); return; } - if (_groupInputs.TryGetValue(inputInfo.Light, out var existingInput)) - { - existingInput.Execute(); - CleanupScheduledWork(inputInfo.Light); - } - - _groupInputs[inputInfo.Light] = inputInfo; + // Schedule this input for individual execution if no group consensus is reached var scheduledWork = _scheduler.Schedule(_groupDuration, () => { lock (_lock) { - inputInfo.Execute(); + if (_groupInputs.TryGetValue(inputInfo.Light, out var info) && !info.HasExecuted) + { + info.Execute(); + _groupInputs.Remove(inputInfo.Light); + } _scheduledWork.Remove(inputInfo.Light); } }); @@ -176,6 +166,40 @@ public void Process(InputInfo inputInfo) } } + private void CleanupExpiredInputs(DateTime currentTime) + { + foreach (var kvp in _groupInputs.ToArray()) + { + var info = kvp.Value; + if (info.HasExecuted) + { + // This can occur if the light is in multiple groups at once. + _groupInputs.Remove(info.Light); + CleanupScheduledWork(info.Light); + } + else if (info.Timestamp + _groupDuration < currentTime) + { + // We waited long enough for this light to be part of the group, + // but it never received a transition that matched the other lights in the group. + info.Execute(); + _groupInputs.Remove(info.Light); + CleanupScheduledWork(info.Light); + } + } + } + + private bool AllMembersHaveMatchingTransitions(LightTransition transition) + { + // We need inputs from ALL group members + if (_groupInputs.Count != _groupMembers.Count) + { + return false; + } + + // All inputs must have matching transitions + return _groupInputs.Values.All(info => _equalityComparer.Equals(info.Transition, transition)); + } + private void CleanupScheduledWork(ILight light) { if (_scheduledWork.TryGetValue(light, out var disposable)) From ed59b4882ceb5c2d87d7991e7fada163a71b67a2 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 23 Jul 2026 18:24:45 +0200 Subject: [PATCH 4/7] Cleanup classes. --- .../Nodes/GroupNode.cs | 4 +- .../Nodes/GroupNodeContext.cs | 223 +++++++++++++++++ .../Nodes/IGroupNodeContext.cs | 235 ------------------ 3 files changed, 225 insertions(+), 237 deletions(-) create mode 100644 src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs delete mode 100644 src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs index d416008..89c59e3 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs @@ -4,10 +4,10 @@ namespace CodeCasa.AutomationPipelines.Lights.Nodes; internal class GroupNode : PipelineNode { - private readonly IGroupNodeContext _groupNodeContext; + private readonly GroupNodeContext _groupNodeContext; private readonly ILight _light; - public GroupNode(ILight light, IGroupNodeContext groupNodeContext) + public GroupNode(ILight light, GroupNodeContext groupNodeContext) { _light = light; _groupNodeContext = groupNodeContext; diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs new file mode 100644 index 0000000..ea12ab6 --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs @@ -0,0 +1,223 @@ +using System.Reactive.Concurrency; +using CodeCasa.Lights; + +namespace CodeCasa.AutomationPipelines.Lights.Nodes +{ + internal class GroupNodeContext(IScheduler scheduler) + { + private readonly List _groups = new(); + private readonly Lock _lock = new(); + + public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer? equalityComparer) + { + lock (_lock) + { + var existingGroup = _groups.FirstOrDefault(g => g.LightGroup == lightGroup); + if (existingGroup == null) + { + existingGroup = new GroupInfo(lightGroup, light, equalityComparer ?? EqualityComparer.Default, groupDuration, scheduler); + _groups.Add(existingGroup); + } + else + { + existingGroup.AddMember(light); + } + } + } + + public void Process(ILight light, LightTransition transition) + { + var inputInfo = new InputInfo(DateTime.UtcNow, light, transition); + lock (_lock) + { + foreach (var group in _groups) + { + group.Process(inputInfo); + } + } + + } + + public void Unregister(ILight light) + { + lock (_lock) + { + foreach (var group in _groups.ToArray()) + { + if (group.RemoveMember(light)) + { + _groups.Remove(group); + group.Dispose(); + } + } + } + } + + internal class InputInfo(DateTime timestamp, ILight light, LightTransition lightTransition) + { + public LightTransition Transition { get; } = lightTransition; + public ILight Light { get; } = light; + public DateTime Timestamp { get; } = timestamp; + public bool HasExecuted { get; private set; } + public void Execute() + { + if (HasExecuted) + { + return; + } + Light.ApplyTransition(Transition); + HasExecuted = true; + } + } + + internal class GroupInfo : IDisposable + { + private readonly IEqualityComparer _equalityComparer; + public ILight LightGroup { get; } + private readonly List _groupMembers; + private readonly Dictionary _groupInputs = new(); + private readonly Dictionary _scheduledWork = new(); + private readonly TimeSpan _groupDuration; + private readonly IScheduler _scheduler; + private readonly Lock _lock = new(); + + public GroupInfo(ILight lightGroup, ILight firstGroupMember, IEqualityComparer equalityComparer, TimeSpan groupDuration, IScheduler scheduler) + { + LightGroup = lightGroup; + _groupMembers = new List { firstGroupMember }; + _equalityComparer = equalityComparer; + _scheduler = scheduler; + _groupDuration = groupDuration; + } + + public void AddMember(ILight member) + { + lock (_lock) + { + _groupMembers.Add(member); + } + } + + public bool RemoveMember(ILight member) + { + lock (_lock) + { + _groupMembers.Remove(member); + _groupInputs.Remove(member); + CleanupScheduledWork(member); + + return !_groupMembers.Any(); + } + } + + public void Process(InputInfo inputInfo) + { + lock (_lock) + { + if (!_groupMembers.Contains(inputInfo.Light)) + { + return; + } + + // Clean up expired or executed inputs + CleanupExpiredInputs(inputInfo.Timestamp); + + // If there's an existing input for this light, execute it first + if (_groupInputs.TryGetValue(inputInfo.Light, out var existingInput)) + { + existingInput.Execute(); + CleanupScheduledWork(inputInfo.Light); + } + + // Add the new input + _groupInputs[inputInfo.Light] = inputInfo; + + // Check if all group members now have matching transitions + if (AllMembersHaveMatchingTransitions(inputInfo.Transition)) + { + // All members are in sync - apply to the group instead + _groupInputs.Clear(); + CleanupAllScheduledWork(); + LightGroup.ApplyTransition(inputInfo.Transition); + return; + } + + // Schedule this input for individual execution if no group consensus is reached + var scheduledWork = _scheduler.Schedule(_groupDuration, () => + { + lock (_lock) + { + if (_groupInputs.TryGetValue(inputInfo.Light, out var info) && !info.HasExecuted) + { + info.Execute(); + _groupInputs.Remove(inputInfo.Light); + } + _scheduledWork.Remove(inputInfo.Light); + } + }); + _scheduledWork[inputInfo.Light] = scheduledWork; + } + } + + private void CleanupExpiredInputs(DateTime currentTime) + { + foreach (var kvp in _groupInputs.ToArray()) + { + var info = kvp.Value; + if (info.HasExecuted) + { + // This can occur if the light is in multiple groups at once. + _groupInputs.Remove(info.Light); + CleanupScheduledWork(info.Light); + } + else if (info.Timestamp + _groupDuration < currentTime) + { + // We waited long enough for this light to be part of the group, + // but it never received a transition that matched the other lights in the group. + info.Execute(); + _groupInputs.Remove(info.Light); + CleanupScheduledWork(info.Light); + } + } + } + + private bool AllMembersHaveMatchingTransitions(LightTransition transition) + { + // We need inputs from ALL group members + if (_groupInputs.Count != _groupMembers.Count) + { + return false; + } + + // All inputs must have matching transitions + return _groupInputs.Values.All(info => _equalityComparer.Equals(info.Transition, transition)); + } + + private void CleanupScheduledWork(ILight light) + { + if (_scheduledWork.TryGetValue(light, out var disposable)) + { + disposable.Dispose(); + _scheduledWork.Remove(light); + } + } + + private void CleanupAllScheduledWork() + { + foreach (var disposable in _scheduledWork.Values) + { + disposable.Dispose(); + } + _scheduledWork.Clear(); + } + + public void Dispose() + { + lock (_lock) + { + CleanupAllScheduledWork(); + } + } + } + } +} diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs deleted file mode 100644 index 4de54e7..0000000 --- a/src/CodeCasa.AutomationPipelines.Lights/Nodes/IGroupNodeContext.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System.Reactive.Concurrency; -using CodeCasa.Lights; - -namespace CodeCasa.AutomationPipelines.Lights.Nodes -{ - public class GroupNodeContext : IGroupNodeContext - { - private readonly IScheduler _scheduler; - private readonly List _groups = new(); - private readonly Lock _lock = new(); - - public GroupNodeContext(IScheduler scheduler) - { - _scheduler = scheduler; - } - - public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer? equalityComparer) - { - lock (_lock) - { - var existingGroup = _groups.FirstOrDefault(g => g.LightGroup == lightGroup); - if (existingGroup == null) - { - existingGroup = new GroupInfo(lightGroup, light, equalityComparer ?? EqualityComparer.Default, groupDuration, _scheduler); - _groups.Add(existingGroup); - } - else - { - existingGroup.AddMember(light); - } - } - } - - public void Process(ILight light, LightTransition transition) - { - var inputInfo = new InputInfo(DateTime.UtcNow, light, transition); - lock (_lock) - { - foreach (var group in _groups) - { - group.Process(inputInfo); - } - } - - } - - public void Unregister(ILight light) - { - lock (_lock) - { - foreach (var group in _groups.ToArray()) - { - if (group.RemoveMember(light)) - { - _groups.Remove(group); - group.Dispose(); - } - } - } - } - } - - public class InputInfo(DateTime timestamp, ILight light, LightTransition lightTransition) - { - public LightTransition Transition { get; } = lightTransition; - public ILight Light { get; } = light; - public DateTime Timestamp { get; } = timestamp; - public bool HasExecuted { get; private set; } - public void Execute() - { - if (HasExecuted) - { - return; - } - Light.ApplyTransition(Transition); - HasExecuted = true; - } - } - - public class GroupInfo : IDisposable - { - private readonly IEqualityComparer _equalityComparer; - public ILight LightGroup { get; } - private readonly List _groupMembers; - private readonly Dictionary _groupInputs = new(); - private readonly Dictionary _scheduledWork = new(); - private readonly TimeSpan _groupDuration; - private readonly IScheduler _scheduler; - private readonly Lock _lock = new(); - - public GroupInfo(ILight lightGroup, ILight firstGroupMember, IEqualityComparer equalityComparer, TimeSpan groupDuration, IScheduler scheduler) - { - LightGroup = lightGroup; - _groupMembers = new List { firstGroupMember }; - _equalityComparer = equalityComparer; - _scheduler = scheduler; - _groupDuration = groupDuration; - } - - public void AddMember(ILight member) - { - lock (_lock) - { - _groupMembers.Add(member); - } - } - - public bool RemoveMember(ILight member) - { - lock (_lock) - { - _groupMembers.Remove(member); - _groupInputs.Remove(member); - CleanupScheduledWork(member); - - return !_groupMembers.Any(); - } - } - - public void Process(InputInfo inputInfo) - { - lock (_lock) - { - if (!_groupMembers.Contains(inputInfo.Light)) - { - return; - } - - // Clean up expired or executed inputs - CleanupExpiredInputs(inputInfo.Timestamp); - - // If there's an existing input for this light, execute it first - if (_groupInputs.TryGetValue(inputInfo.Light, out var existingInput)) - { - existingInput.Execute(); - CleanupScheduledWork(inputInfo.Light); - } - - // Add the new input - _groupInputs[inputInfo.Light] = inputInfo; - - // Check if all group members now have matching transitions - if (AllMembersHaveMatchingTransitions(inputInfo.Transition)) - { - // All members are in sync - apply to the group instead - _groupInputs.Clear(); - CleanupAllScheduledWork(); - LightGroup.ApplyTransition(inputInfo.Transition); - return; - } - - // Schedule this input for individual execution if no group consensus is reached - var scheduledWork = _scheduler.Schedule(_groupDuration, () => - { - lock (_lock) - { - if (_groupInputs.TryGetValue(inputInfo.Light, out var info) && !info.HasExecuted) - { - info.Execute(); - _groupInputs.Remove(inputInfo.Light); - } - _scheduledWork.Remove(inputInfo.Light); - } - }); - _scheduledWork[inputInfo.Light] = scheduledWork; - } - } - - private void CleanupExpiredInputs(DateTime currentTime) - { - foreach (var kvp in _groupInputs.ToArray()) - { - var info = kvp.Value; - if (info.HasExecuted) - { - // This can occur if the light is in multiple groups at once. - _groupInputs.Remove(info.Light); - CleanupScheduledWork(info.Light); - } - else if (info.Timestamp + _groupDuration < currentTime) - { - // We waited long enough for this light to be part of the group, - // but it never received a transition that matched the other lights in the group. - info.Execute(); - _groupInputs.Remove(info.Light); - CleanupScheduledWork(info.Light); - } - } - } - - private bool AllMembersHaveMatchingTransitions(LightTransition transition) - { - // We need inputs from ALL group members - if (_groupInputs.Count != _groupMembers.Count) - { - return false; - } - - // All inputs must have matching transitions - return _groupInputs.Values.All(info => _equalityComparer.Equals(info.Transition, transition)); - } - - private void CleanupScheduledWork(ILight light) - { - if (_scheduledWork.TryGetValue(light, out var disposable)) - { - disposable.Dispose(); - _scheduledWork.Remove(light); - } - } - - private void CleanupAllScheduledWork() - { - foreach (var disposable in _scheduledWork.Values) - { - disposable.Dispose(); - } - _scheduledWork.Clear(); - } - - public void Dispose() - { - lock (_lock) - { - CleanupAllScheduledWork(); - } - } - } - - internal interface IGroupNodeContext - { - void Process(ILight light, LightTransition input); - void Unregister(ILight light); - } -} From 36b2c943a6059b5a62be9052ce30866fca531074 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 23 Jul 2026 18:24:59 +0200 Subject: [PATCH 5/7] Implemented netdaemon extension methods. --- ...onPipelineConfiguratorExtensions.Groups.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Groups.cs diff --git a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Groups.cs b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Groups.cs new file mode 100644 index 0000000..9d0656b --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Groups.cs @@ -0,0 +1,40 @@ +using CodeCasa.AutomationPipelines.Lights.Pipeline; +using CodeCasa.Lights.NetDaemon; +using CodeCasa.Lights.NetDaemon.Extensions; +using NetDaemon.HassModel.Entities; + +namespace CodeCasa.AutomationPipelines.Lights.NetDaemon.Extensions; + +/// +/// Extension methods for light pipeline configurators to work with NetDaemon light groups. +/// +public static partial class LightTransitionPipelineConfiguratorExtensions +{ + /// + /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within 10 milliseconds. + /// + /// The pipeline configurator. + /// The NetDaemon light group entity. + /// The configurator instance for method chaining. + public static ILightTransitionPipelineConfigurator UseLightGroup( + this ILightTransitionPipelineConfigurator configurator, + ILightEntityCore lightGroupEntity) + { + return configurator.UseLightGroup(lightGroupEntity.AsLight()); + } + + /// + /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within the specified time span. + /// + /// The pipeline configurator. + /// The NetDaemon light group entity. + /// The time span for the transition. + /// The configurator instance for method chaining. + public static ILightTransitionPipelineConfigurator UseLightGroup( + this ILightTransitionPipelineConfigurator configurator, + ILightEntityCore lightGroupEntity, + TimeSpan timeSpan) + { + return configurator.UseLightGroup(lightGroupEntity.AsLight(), timeSpan); + } +} From e20e0bce4d64c9b26526c6fa6ee872a09e71405c Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 24 Jul 2026 14:09:28 +0200 Subject: [PATCH 6/7] Logging group usage and allowing custom comparer. --- .../Nodes/GroupNodeContext.cs | 39 +++++++++---------- ...htTransitionPipelineConfigurator.Groups.cs | 14 ++++++- ...htTransitionPipelineConfigurator.Groups.cs | 19 ++++++++- .../Pipeline/LightGroupConfig.cs | 5 +++ .../Pipeline/LightPipelineFactory.cs | 4 +- ...htTransitionPipelineConfigurator.Groups.cs | 18 +++++++-- 6 files changed, 70 insertions(+), 29 deletions(-) create mode 100644 src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightGroupConfig.cs diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs index ea12ab6..48d8447 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs @@ -1,21 +1,22 @@ using System.Reactive.Concurrency; using CodeCasa.Lights; +using Microsoft.Extensions.Logging; namespace CodeCasa.AutomationPipelines.Lights.Nodes { - internal class GroupNodeContext(IScheduler scheduler) + internal class GroupNodeContext(IScheduler scheduler, ILogger>? logger) { private readonly List _groups = new(); private readonly Lock _lock = new(); - public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer? equalityComparer) + public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer equalityComparer) { lock (_lock) { var existingGroup = _groups.FirstOrDefault(g => g.LightGroup == lightGroup); if (existingGroup == null) { - existingGroup = new GroupInfo(lightGroup, light, equalityComparer ?? EqualityComparer.Default, groupDuration, scheduler); + existingGroup = new GroupInfo(lightGroup, light, equalityComparer, groupDuration, scheduler, logger); _groups.Add(existingGroup); } else @@ -70,26 +71,21 @@ public void Execute() } } - internal class GroupInfo : IDisposable + internal class GroupInfo( + ILight lightGroup, + ILight firstGroupMember, + IEqualityComparer equalityComparer, + TimeSpan groupDuration, + IScheduler scheduler, + ILogger>? logger) + : IDisposable { - private readonly IEqualityComparer _equalityComparer; - public ILight LightGroup { get; } - private readonly List _groupMembers; + public ILight LightGroup { get; } = lightGroup; + private readonly List _groupMembers = [firstGroupMember]; private readonly Dictionary _groupInputs = new(); private readonly Dictionary _scheduledWork = new(); - private readonly TimeSpan _groupDuration; - private readonly IScheduler _scheduler; private readonly Lock _lock = new(); - public GroupInfo(ILight lightGroup, ILight firstGroupMember, IEqualityComparer equalityComparer, TimeSpan groupDuration, IScheduler scheduler) - { - LightGroup = lightGroup; - _groupMembers = new List { firstGroupMember }; - _equalityComparer = equalityComparer; - _scheduler = scheduler; - _groupDuration = groupDuration; - } - public void AddMember(ILight member) { lock (_lock) @@ -138,12 +134,13 @@ public void Process(InputInfo inputInfo) // All members are in sync - apply to the group instead _groupInputs.Clear(); CleanupAllScheduledWork(); + logger?.LogInformation($"Group [{LightGroup.Id}] used. All members have matching transition: {inputInfo.Transition}"); LightGroup.ApplyTransition(inputInfo.Transition); return; } // Schedule this input for individual execution if no group consensus is reached - var scheduledWork = _scheduler.Schedule(_groupDuration, () => + var scheduledWork = scheduler.Schedule(groupDuration, () => { lock (_lock) { @@ -170,7 +167,7 @@ private void CleanupExpiredInputs(DateTime currentTime) _groupInputs.Remove(info.Light); CleanupScheduledWork(info.Light); } - else if (info.Timestamp + _groupDuration < currentTime) + else if (info.Timestamp + groupDuration < currentTime) { // We waited long enough for this light to be part of the group, // but it never received a transition that matched the other lights in the group. @@ -190,7 +187,7 @@ private bool AllMembersHaveMatchingTransitions(LightTransition transition) } // All inputs must have matching transitions - return _groupInputs.Values.All(info => _equalityComparer.Equals(info.Transition, transition)); + return _groupInputs.Values.All(info => equalityComparer.Equals(info.Transition, transition)); } private void CleanupScheduledWork(ILight light) diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs index ec20780..9107efa 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/CompositeLightTransitionPipelineConfigurator.Groups.cs @@ -3,7 +3,7 @@ namespace CodeCasa.AutomationPipelines.Lights.Pipeline; -internal partial class CompositeLightTransitionPipelineConfigurator : IPipelineHierarchyContext +internal partial class CompositeLightTransitionPipelineConfigurator { public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup) { @@ -11,9 +11,21 @@ public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGr return this; } + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, EqualityComparer comparer) + { + NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup, comparer)); + return this; + } + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan) { NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup, timeSpan)); return this; } + + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan, EqualityComparer comparer) + { + NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup, timeSpan, comparer)); + return this; + } } diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs index 5f5cbac..68c5d1e 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/ILightTransitionPipelineConfigurator.Groups.cs @@ -5,12 +5,20 @@ namespace CodeCasa.AutomationPipelines.Lights.Pipeline; public partial interface ILightTransitionPipelineConfigurator { /// - /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within 10 milliseconds. + /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within 20 milliseconds. /// /// The light group entity. /// The configurator instance for method chaining. ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup); + /// + /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within 20 milliseconds, using a custom comparer. + /// + /// The light group entity. + /// The equality comparer to determine if transitions are the same. + /// The configurator instance for method chaining. + ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, EqualityComparer comparer); + /// /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within the specified time span. /// @@ -18,4 +26,13 @@ public partial interface ILightTransitionPipelineConfigurator /// The time span for the transition. /// The configurator instance for method chaining. ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan); + + /// + /// Allows you to provide a group light entity to be used if the same transition is applied to all lights at once within the specified time span, using a custom comparer. + /// + /// The light group entity. + /// The time span for the transition. + /// The equality comparer to determine if transitions are the same. + /// The configurator instance for method chaining. + ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan, EqualityComparer comparer); } \ No newline at end of file diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightGroupConfig.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightGroupConfig.cs new file mode 100644 index 0000000..21e9f49 --- /dev/null +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightGroupConfig.cs @@ -0,0 +1,5 @@ +using CodeCasa.Lights; + +namespace CodeCasa.AutomationPipelines.Lights.Pipeline; + +internal record LightGroupConfig(TimeSpan TimeSpan, EqualityComparer Comparer); \ No newline at end of file diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs index bb2e7a0..55a56cb 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightPipelineFactory.cs @@ -117,7 +117,7 @@ internal Dictionary> CreateLightPipelines()); + var groupContext = new GroupNodeContext(compositeServiceProvider.GetRequiredService(), logger); return configurators.ToDictionary(kvp => kvp.Key, kvp => { @@ -129,7 +129,7 @@ internal Dictionary> CreateLightPipelines : IPipelineHierarchyContext +internal partial class LightTransitionPipelineConfigurator { - public Dictionary LightGroups = new(); + public Dictionary LightGroups = new(); public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup) { - return UseLightGroup(lightGroup, TimeSpan.FromMilliseconds(10)); + return UseLightGroup(lightGroup, TimeSpan.FromMilliseconds(20)); + } + + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, EqualityComparer comparer) + { + return UseLightGroup(lightGroup, TimeSpan.FromMilliseconds(20), comparer); } public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan) { - LightGroups[lightGroup] = timeSpan; + return UseLightGroup(lightGroup, timeSpan, EqualityComparer.Default); + } + + public ILightTransitionPipelineConfigurator UseLightGroup(ILight lightGroup, TimeSpan timeSpan, EqualityComparer comparer) + { + LightGroups[lightGroup] = new LightGroupConfig(timeSpan, comparer); return this; } } From f6841796db371bec358402d4f176241045c520d7 Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 24 Jul 2026 14:17:49 +0200 Subject: [PATCH 7/7] Updating references. --- .../CodeCasa.AutomationPipelines.Lights.Mqtt.csproj | 8 ++++---- .../CodeCasa.AutomationPipelines.csproj | 6 +++--- .../CodeCasa.Lights.NetDaemon.Scenes.csproj | 2 +- .../CodeCasa.Lights.NetDaemon.csproj | 2 +- .../CodeCasa.Lights.Timelines.csproj | 2 +- .../CodeCasa.NetDaemon.Extensions.Observables.csproj | 4 ++-- .../CodeCasa.NetDaemon.RuntimeState.csproj | 6 +++--- .../CodeCasa.NetDaemon.Sensors.Composite.csproj | 4 ++-- .../CodeCasa.NetDaemon.TypedEntities.csproj | 2 +- .../CodeCasa.Notifications.InputSelect.NetDaemon.csproj | 2 +- .../CodeCasa.Notifications.Phone.NetDaemon.csproj | 2 +- .../CodeCasa.AutomationPipelines.Lights.Tests.csproj | 4 ++-- .../CodeCasa.AutomationPipelines.Tests.csproj | 2 +- tests/CodeCasa.Lights.Tests/CodeCasa.Lights.Tests.csproj | 2 +- ...CodeCasa.NetDaemon.Extensions.Observables.Tests.csproj | 6 +++--- .../CodeCasa.NetDaemon.Sensors.Composite.Tests.csproj | 4 ++-- ...eCasa.Notifications.InputSelect.NetDaemon.Tests.csproj | 2 +- 17 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/CodeCasa.AutomationPipelines.Lights.Mqtt/CodeCasa.AutomationPipelines.Lights.Mqtt.csproj b/src/CodeCasa.AutomationPipelines.Lights.Mqtt/CodeCasa.AutomationPipelines.Lights.Mqtt.csproj index b946d9d..2b7752c 100644 --- a/src/CodeCasa.AutomationPipelines.Lights.Mqtt/CodeCasa.AutomationPipelines.Lights.Mqtt.csproj +++ b/src/CodeCasa.AutomationPipelines.Lights.Mqtt/CodeCasa.AutomationPipelines.Lights.Mqtt.csproj @@ -34,10 +34,10 @@ - - - - + + + + diff --git a/src/CodeCasa.AutomationPipelines/CodeCasa.AutomationPipelines.csproj b/src/CodeCasa.AutomationPipelines/CodeCasa.AutomationPipelines.csproj index f6fa62f..b6d33cd 100644 --- a/src/CodeCasa.AutomationPipelines/CodeCasa.AutomationPipelines.csproj +++ b/src/CodeCasa.AutomationPipelines/CodeCasa.AutomationPipelines.csproj @@ -34,9 +34,9 @@ - - - + + + diff --git a/src/CodeCasa.Lights.NetDaemon.Scenes/CodeCasa.Lights.NetDaemon.Scenes.csproj b/src/CodeCasa.Lights.NetDaemon.Scenes/CodeCasa.Lights.NetDaemon.Scenes.csproj index 1f86848..1ed9431 100644 --- a/src/CodeCasa.Lights.NetDaemon.Scenes/CodeCasa.Lights.NetDaemon.Scenes.csproj +++ b/src/CodeCasa.Lights.NetDaemon.Scenes/CodeCasa.Lights.NetDaemon.Scenes.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/CodeCasa.Lights.NetDaemon/CodeCasa.Lights.NetDaemon.csproj b/src/CodeCasa.Lights.NetDaemon/CodeCasa.Lights.NetDaemon.csproj index 9512166..fda8678 100644 --- a/src/CodeCasa.Lights.NetDaemon/CodeCasa.Lights.NetDaemon.csproj +++ b/src/CodeCasa.Lights.NetDaemon/CodeCasa.Lights.NetDaemon.csproj @@ -39,7 +39,7 @@ - + diff --git a/src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj b/src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj index dbc7f38..90c83be 100644 --- a/src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj +++ b/src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj @@ -37,7 +37,7 @@ - + diff --git a/src/CodeCasa.NetDaemon.Extensions.Observables/CodeCasa.NetDaemon.Extensions.Observables.csproj b/src/CodeCasa.NetDaemon.Extensions.Observables/CodeCasa.NetDaemon.Extensions.Observables.csproj index c2bf868..c8a0731 100644 --- a/src/CodeCasa.NetDaemon.Extensions.Observables/CodeCasa.NetDaemon.Extensions.Observables.csproj +++ b/src/CodeCasa.NetDaemon.Extensions.Observables/CodeCasa.NetDaemon.Extensions.Observables.csproj @@ -34,9 +34,9 @@ - + - + diff --git a/src/CodeCasa.NetDaemon.RuntimeState/CodeCasa.NetDaemon.RuntimeState.csproj b/src/CodeCasa.NetDaemon.RuntimeState/CodeCasa.NetDaemon.RuntimeState.csproj index a2963f2..3aac239 100644 --- a/src/CodeCasa.NetDaemon.RuntimeState/CodeCasa.NetDaemon.RuntimeState.csproj +++ b/src/CodeCasa.NetDaemon.RuntimeState/CodeCasa.NetDaemon.RuntimeState.csproj @@ -34,9 +34,9 @@ - - - + + + diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj b/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj index eac5565..f3bdea8 100644 --- a/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj @@ -38,8 +38,8 @@ - - + + diff --git a/src/CodeCasa.NetDaemon.TypedEntities/CodeCasa.NetDaemon.TypedEntities.csproj b/src/CodeCasa.NetDaemon.TypedEntities/CodeCasa.NetDaemon.TypedEntities.csproj index fdf31ed..2da2cd9 100644 --- a/src/CodeCasa.NetDaemon.TypedEntities/CodeCasa.NetDaemon.TypedEntities.csproj +++ b/src/CodeCasa.NetDaemon.TypedEntities/CodeCasa.NetDaemon.TypedEntities.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/CodeCasa.Notifications.InputSelect.NetDaemon/CodeCasa.Notifications.InputSelect.NetDaemon.csproj b/src/CodeCasa.Notifications.InputSelect.NetDaemon/CodeCasa.Notifications.InputSelect.NetDaemon.csproj index 94036fb..cafe41d 100644 --- a/src/CodeCasa.Notifications.InputSelect.NetDaemon/CodeCasa.Notifications.InputSelect.NetDaemon.csproj +++ b/src/CodeCasa.Notifications.InputSelect.NetDaemon/CodeCasa.Notifications.InputSelect.NetDaemon.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/CodeCasa.Notifications.Phone.NetDaemon/CodeCasa.Notifications.Phone.NetDaemon.csproj b/src/CodeCasa.Notifications.Phone.NetDaemon/CodeCasa.Notifications.Phone.NetDaemon.csproj index 6b50fbf..4fea7e6 100644 --- a/src/CodeCasa.Notifications.Phone.NetDaemon/CodeCasa.Notifications.Phone.NetDaemon.csproj +++ b/src/CodeCasa.Notifications.Phone.NetDaemon/CodeCasa.Notifications.Phone.NetDaemon.csproj @@ -34,7 +34,7 @@ - + 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 b47233a..62167cb 100644 --- a/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj +++ b/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/tests/CodeCasa.AutomationPipelines.Tests/CodeCasa.AutomationPipelines.Tests.csproj b/tests/CodeCasa.AutomationPipelines.Tests/CodeCasa.AutomationPipelines.Tests.csproj index f19ce7d..487fd99 100644 --- a/tests/CodeCasa.AutomationPipelines.Tests/CodeCasa.AutomationPipelines.Tests.csproj +++ b/tests/CodeCasa.AutomationPipelines.Tests/CodeCasa.AutomationPipelines.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/tests/CodeCasa.Lights.Tests/CodeCasa.Lights.Tests.csproj b/tests/CodeCasa.Lights.Tests/CodeCasa.Lights.Tests.csproj index 86a3b71..f5504c7 100644 --- a/tests/CodeCasa.Lights.Tests/CodeCasa.Lights.Tests.csproj +++ b/tests/CodeCasa.Lights.Tests/CodeCasa.Lights.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/tests/CodeCasa.NetDaemon.Extensions.Observables.Tests/CodeCasa.NetDaemon.Extensions.Observables.Tests.csproj b/tests/CodeCasa.NetDaemon.Extensions.Observables.Tests/CodeCasa.NetDaemon.Extensions.Observables.Tests.csproj index 4bd3981..45360d1 100644 --- a/tests/CodeCasa.NetDaemon.Extensions.Observables.Tests/CodeCasa.NetDaemon.Extensions.Observables.Tests.csproj +++ b/tests/CodeCasa.NetDaemon.Extensions.Observables.Tests/CodeCasa.NetDaemon.Extensions.Observables.Tests.csproj @@ -8,10 +8,10 @@ - + - - + + diff --git a/tests/CodeCasa.NetDaemon.Sensors.Composite.Tests/CodeCasa.NetDaemon.Sensors.Composite.Tests.csproj b/tests/CodeCasa.NetDaemon.Sensors.Composite.Tests/CodeCasa.NetDaemon.Sensors.Composite.Tests.csproj index bb258ac..f6785e3 100644 --- a/tests/CodeCasa.NetDaemon.Sensors.Composite.Tests/CodeCasa.NetDaemon.Sensors.Composite.Tests.csproj +++ b/tests/CodeCasa.NetDaemon.Sensors.Composite.Tests/CodeCasa.NetDaemon.Sensors.Composite.Tests.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests.csproj b/tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests.csproj index 22aff5f..dd257ab 100644 --- a/tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests.csproj +++ b/tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests/CodeCasa.Notifications.InputSelect.NetDaemon.Tests.csproj @@ -8,7 +8,7 @@ - +