Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
<PackageReference Include="MQTTnet" Version="5.2.0.1603" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Extension methods for light pipeline configurators to work with NetDaemon light groups.
/// </summary>
public static partial class LightTransitionPipelineConfiguratorExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <param name="configurator">The pipeline configurator.</param>
/// <param name="lightGroupEntity">The NetDaemon light group entity.</param>
/// <returns>The configurator instance for method chaining.</returns>
public static ILightTransitionPipelineConfigurator<NetDaemonLight> UseLightGroup(
this ILightTransitionPipelineConfigurator<NetDaemonLight> configurator,
ILightEntityCore lightGroupEntity)
{
return configurator.UseLightGroup(lightGroupEntity.AsLight());
}

/// <summary>
/// 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.
/// </summary>
/// <param name="configurator">The pipeline configurator.</param>
/// <param name="lightGroupEntity">The NetDaemon light group entity.</param>
/// <param name="timeSpan">The time span for the transition.</param>
/// <returns>The configurator instance for method chaining.</returns>
public static ILightTransitionPipelineConfigurator<NetDaemonLight> UseLightGroup(
this ILightTransitionPipelineConfigurator<NetDaemonLight> configurator,
ILightEntityCore lightGroupEntity,
TimeSpan timeSpan)
{
return configurator.UseLightGroup(lightGroupEntity.AsLight(), timeSpan);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using CodeCasa.Lights;

namespace CodeCasa.AutomationPipelines.Lights.Nodes;

internal class GroupNode : PipelineNode<LightTransition>
{
private readonly GroupNodeContext _groupNodeContext;
private readonly ILight _light;

public GroupNode(ILight light, GroupNodeContext groupNodeContext)
{
_light = light;
_groupNodeContext = groupNodeContext;
Name = "Group Node";
}

/// <inheritdoc />
protected override void InputReceived(LightTransition? input)
{
if (input != null)
{
_groupNodeContext.Process(_light, input);
}
}

/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
_groupNodeContext.Unregister(_light);
await base.DisposeAsync();
}
}
220 changes: 220 additions & 0 deletions src/CodeCasa.AutomationPipelines.Lights/Nodes/GroupNodeContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using System.Reactive.Concurrency;
using CodeCasa.Lights;
using Microsoft.Extensions.Logging;

namespace CodeCasa.AutomationPipelines.Lights.Nodes
{
internal class GroupNodeContext(IScheduler scheduler, ILogger<Pipeline<LightTransition>>? logger)
{
private readonly List<GroupInfo> _groups = new();
private readonly Lock _lock = new();

public void Register(ILight light, ILight lightGroup, TimeSpan groupDuration, EqualityComparer<LightTransition> equalityComparer)
{
lock (_lock)
{
var existingGroup = _groups.FirstOrDefault(g => g.LightGroup == lightGroup);
if (existingGroup == null)
{
existingGroup = new GroupInfo(lightGroup, light, equalityComparer, groupDuration, scheduler, logger);
_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(
ILight lightGroup,
ILight firstGroupMember,
IEqualityComparer<LightTransition> equalityComparer,
TimeSpan groupDuration,
IScheduler scheduler,
ILogger<Pipeline<LightTransition>>? logger)
: IDisposable
{
public ILight LightGroup { get; } = lightGroup;
private readonly List<ILight> _groupMembers = [firstGroupMember];
private readonly Dictionary<ILight, InputInfo> _groupInputs = new();
private readonly Dictionary<ILight, IDisposable> _scheduledWork = new();
private readonly Lock _lock = new();

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();
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, () =>
{
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();
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using CodeCasa.AutomationPipelines.Lights.Extensions;
using CodeCasa.Lights;

namespace CodeCasa.AutomationPipelines.Lights.Pipeline;

internal partial class CompositeLightTransitionPipelineConfigurator<TLight>
{
public ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup)
{
NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup));
return this;
}

public ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup, EqualityComparer<LightTransition> comparer)
{
NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup, comparer));
return this;
}

public ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup, TimeSpan timeSpan)
{
NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup, timeSpan));
return this;
}

public ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup, TimeSpan timeSpan, EqualityComparer<LightTransition> comparer)
{
NodeContainers.Values.ForEach(b => b.UseLightGroup(lightGroup, timeSpan, comparer));
return this;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using CodeCasa.Lights;

namespace CodeCasa.AutomationPipelines.Lights.Pipeline;

public partial interface ILightTransitionPipelineConfigurator<TLight>
{
/// <summary>
/// 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.
/// </summary>
/// <param name="lightGroup">The light group entity.</param>
/// <returns>The configurator instance for method chaining.</returns>
ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup);

/// <summary>
/// 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.
/// </summary>
/// <param name="lightGroup">The light group entity.</param>
/// <param name="comparer">The equality comparer to determine if transitions are the same.</param>
/// <returns>The configurator instance for method chaining.</returns>
ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup, EqualityComparer<LightTransition> comparer);

/// <summary>
/// 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.
/// </summary>
/// <param name="lightGroup">The light group entity.</param>
/// <param name="timeSpan">The time span for the transition.</param>
/// <returns>The configurator instance for method chaining.</returns>
ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup, TimeSpan timeSpan);

/// <summary>
/// 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.
/// </summary>
/// <param name="lightGroup">The light group entity.</param>
/// <param name="timeSpan">The time span for the transition.</param>
/// <param name="comparer">The equality comparer to determine if transitions are the same.</param>
/// <returns>The configurator instance for method chaining.</returns>
ILightTransitionPipelineConfigurator<TLight> UseLightGroup(ILight lightGroup, TimeSpan timeSpan, EqualityComparer<LightTransition> comparer);
}
Loading