From 320e88512d90e48fc3d275217a8da7945b8ab4b8 Mon Sep 17 00:00:00 2001 From: Jasper Date: Wed, 25 Feb 2026 18:41:21 +0100 Subject: [PATCH 1/4] Implemented CodeCasa.Lights.Timelines --- CodeCasa.sln | 6 + .../CodeCasa.Lights.Timelines.csproj | 47 ++++++ .../DictionaryComparer.cs | 38 +++++ .../TimelineValueCollectionExtensions.cs | 150 ++++++++++++++++++ ...odeCasa.NetDaemon.Sensors.Composite.csproj | 2 +- 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj create mode 100644 src/CodeCasa.Lights.Timelines/DictionaryComparer.cs create mode 100644 src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs diff --git a/CodeCasa.sln b/CodeCasa.sln index 9a7f567..6e8f237 100644 --- a/CodeCasa.sln +++ b/CodeCasa.sln @@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Notifications.Ligh EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.NetDaemon.Sensors.Composite", "src\CodeCasa.NetDaemon.Sensors.Composite\CodeCasa.NetDaemon.Sensors.Composite.csproj", "{725ED4CC-2360-4FB5-A199-F1F42B7526B1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Lights.Timelines", "src\CodeCasa.Lights.Timelines\CodeCasa.Lights.Timelines.csproj", "{2308FD7B-1A02-4901-9914-094A51936E30}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -125,6 +127,10 @@ Global {725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Debug|Any CPU.Build.0 = Debug|Any CPU {725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.ActiveCfg = Release|Any CPU {725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.Build.0 = Release|Any CPU + {2308FD7B-1A02-4901-9914-094A51936E30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2308FD7B-1A02-4901-9914-094A51936E30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2308FD7B-1A02-4901-9914-094A51936E30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2308FD7B-1A02-4901-9914-094A51936E30}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj b/src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj new file mode 100644 index 0000000..dbc7f38 --- /dev/null +++ b/src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj @@ -0,0 +1,47 @@ + + + + net10.0 + enable + enable + CodeCasa.Lights.Timelines + Jasper Lammers + DevJasper + Bridge library connecting CodeCasa light abstractions with Occurify timelines for reactive, time-based lighting schedules. + https://github.com/DevJasperNL/CodeCasa + Home Automation;Home Assistant;Smart Home;Occurify;Timelines;Scheduling;Reactive;Rx;Lights;Lighting;Transitions;Interpolation;C#;.NET + LICENSE + README.md + CodeCasa.Lights.Timelines + True + cc_icon.png + https://github.com/DevJasperNL/CodeCasa/releases + 1.11.1 + + + + + True + \ + + + True + \ + + + True + \ + + + + + + + + + + + + + + diff --git a/src/CodeCasa.Lights.Timelines/DictionaryComparer.cs b/src/CodeCasa.Lights.Timelines/DictionaryComparer.cs new file mode 100644 index 0000000..622f6d1 --- /dev/null +++ b/src/CodeCasa.Lights.Timelines/DictionaryComparer.cs @@ -0,0 +1,38 @@ +namespace CodeCasa.Lights.Timelines +{ + internal sealed class DictionaryComparer : IEqualityComparer> + where TKey : notnull + { + private readonly IEqualityComparer _valueComparer; + + public DictionaryComparer(IEqualityComparer? valueComparer = null) + { + _valueComparer = valueComparer ?? EqualityComparer.Default; + } + + public bool Equals(Dictionary? x, Dictionary? y) + { + if (ReferenceEquals(x, y)) return true; + if (x == null || y == null) return false; + if (x.Count != y.Count) return false; + + foreach (var (key, value) in x) + { + if (!y.TryGetValue(key, out var yValue) || !_valueComparer.Equals(value, yValue)) + return false; + } + return true; + } + + public int GetHashCode(Dictionary obj) + { + var hash = new HashCode(); + foreach (var (key, value) in obj) + { + hash.Add(key); + hash.Add(value, _valueComparer); + } + return hash.ToHashCode(); + } + } +} diff --git a/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs b/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs new file mode 100644 index 0000000..bc2c529 --- /dev/null +++ b/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs @@ -0,0 +1,150 @@ +using CodeCasa.Lights.Extensions; +using Occurify; +using Occurify.Extensions; +using Occurify.Reactive.Extensions; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +namespace CodeCasa.Lights.Timelines.Extensions +{ + public static class TimelineValueCollectionExtensions + { + /// + /// Converts a timeline dictionary into an observable stream of objects, + /// including an immediate interpolated starting value. + /// + /// The dictionary mapping timeline points to . + /// The Rx scheduler used to manage timing and initial delay. + /// + /// The duration of the initial fade from current state. Defaults to 500ms if null. + /// + /// An observable that emits the current interpolated state, then follows the scheduled timeline. + public static IObservable ToLightTransitionObservableIncludingCurrent( + this Dictionary sceneTimeline, + IScheduler scheduler, + TimeSpan? transitionTimeForTimelineState = null) + { + return CreateTimelineObservableIncludingInitialInterpolatedValue(sceneTimeline, + (lightParameters, transitionTime) => lightParameters.AsTransition(transitionTime), + (previous, next, fraction) => previous.Interpolate(next, fraction), + EqualityComparer.Default, + scheduler, + transitionTimeForTimelineState); + } + + /// + /// Converts a nested timeline dictionary into an observable stream of light scenes, + /// where each emission contains a dictionary of transitions for multiple light sources. + /// + /// A dictionary mapping timeline points to a collection of light states keyed by ID. + /// The Rx scheduler used to manage timing and initial delay. + /// + /// The duration of the initial fade for all lights in the scene. Defaults to 500ms if null. + /// + /// An observable that emits a dictionary of transitions representing the current scene state, followed by scheduled updates. + /// + /// This method utilizes a custom dictionary comparer to ensure updates are only emitted when + /// at least one light in the scene has changed its parameters. + /// + public static IObservable> ToLightTransitionSceneObservableIncludingCurrent( + this Dictionary> sceneTimeline, + IScheduler scheduler, + TimeSpan? transitionTimeForTimelineState = null) + { + return CreateTimelineObservableIncludingInitialInterpolatedValue(sceneTimeline, + (lightParametersDict, transitionTime) => lightParametersDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.AsTransition(transitionTime)), + (previousDict, nextDict, fraction) => previousDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Interpolate(nextDict[kvp.Key], fraction)), + new DictionaryComparer(EqualityComparer.Default), + scheduler, + transitionTimeForTimelineState); + } + + /// + /// Creates an observable that immediately emits an interpolated initial state based on , + /// followed by the standard timeline transitions after a specified delay. + /// + private static IObservable CreateTimelineObservableIncludingInitialInterpolatedValue( + this Dictionary sceneTimeline, + Func transformer, + Func interpolator, + IEqualityComparer comparer, + IScheduler scheduler, + TimeSpan? transitionTimeForTimelineState = null) + { + var transitionObservable = CreateTimelineObservable(sceneTimeline, transformer, comparer, scheduler); + + var utcNow = DateTime.UtcNow; + var valuesAtPrevious = sceneTimeline.GetValuesAtPreviousUtcInstant(utcNow); + var valuesAtCurrentOrNext = sceneTimeline.GetValuesAtCurrentOrNextUtcInstant(utcNow); + + if (valuesAtPrevious.Key == null || valuesAtCurrentOrNext.Key == null) + { + return transitionObservable; + } + + var fraction = CalculateFraction(valuesAtPrevious.Key.Value, valuesAtCurrentOrNext.Key.Value, utcNow); + + var previousScene = valuesAtPrevious.Value.First(); + var nextScene = valuesAtCurrentOrNext.Value.First(); + + var initialSceneTransition = interpolator(previousScene, nextScene, fraction); + + var timeSpan = transitionTimeForTimelineState ?? TimeSpan.FromMilliseconds(500); + // We delay the timeline observable to allow the initial scene transition to be emitted/activated first. + var delayedTimelineObservable = Observable + .Timer(timeSpan, scheduler) + .SelectMany(_ => transitionObservable); + + return Observable.Return(transformer(initialSceneTransition, timeSpan)).Concat(delayedTimelineObservable); + } + + /// + /// Creates an observable stream that emits transformed values based on state transitions + /// between consecutive instants in a timeline. + /// + private static IObservable CreateTimelineObservable( + Dictionary timeline, + Func transformer, + IEqualityComparer comparer, + IScheduler scheduler) + { + return timeline + .ToSampleObservable(scheduler, emitSampleUponSubscribe: false) + .Select(s => + { + var instant = s.Key; + var nextValues = timeline.GetValuesAtNextUtcInstant(instant); + var nextInstant = nextValues.Key; + if (nextInstant == null) + { + return Maybe.None; + } + + var current = s.Value.First(); + var next = nextValues.Value.First(); + if (comparer.Equals(current, next)) + { + return Maybe.None; + } + var transitionTimeSpan = nextInstant.Value - instant; + + return Maybe.Some(transformer(next, transitionTimeSpan)); + }) + .Where(s => s.HasValue) + .Select(s => s.Value!); + } + + public sealed record Maybe(bool HasValue, T? Value) + { + public static Maybe None => new(false, default); + public static Maybe Some(T value) => new(true, value); + } + + private static double CalculateFraction(DateTime previous, DateTime next, DateTime current) + { + var timeFromPrevious = current - previous; + var totalTransition = next - previous; + return timeFromPrevious / totalTransition; + } + } +} 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 7aa40d4..50a1144 100644 --- a/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj @@ -5,7 +5,6 @@ enable enable CodeCasa.NetDaemon.Sensors.Composite - CodeCasa.NetDaemon.Sensors.Composite Jasper Lammers DevJasper High-level composite sensor abstractions for NetDaemon, combining multiple Home Assistant entities into logical observables. @@ -13,6 +12,7 @@ NetDaemon;Home Automation;Home Assistant;Smart Home;Motion;Illuminance;Reactive Programming;Rx;Rx.NET;Observables;C#;.NET 10;Composite Sensors LICENSE README.md + CodeCasa.NetDaemon.Sensors.Composite True ccnd_icon.png https://github.com/DevJasperNL/CodeCasa/releases From 63e9eaff734e05219b710fb76e2fbd179eeca6c1 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 26 Feb 2026 10:42:24 +0100 Subject: [PATCH 2/4] Added final xml comment. --- .../Extensions/TimelineValueCollectionExtensions.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs b/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs index bc2c529..00c1cbc 100644 --- a/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs +++ b/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs @@ -7,6 +7,10 @@ namespace CodeCasa.Lights.Timelines.Extensions { + /// + /// Provides reactive extension methods for collections + /// where the keys are instances. + /// public static class TimelineValueCollectionExtensions { /// From 20fbbeb4ca9ee60b46ccc72956e5bd2b9c5eee20 Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 27 Feb 2026 17:15:59 +0100 Subject: [PATCH 3/4] Improved logging for conditional pipeline. --- .../Pipeline/LightTransitionPipelineConfigurator.When.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.When.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.When.cs index 06e5c29..633b1ae 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.When.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.When.cs @@ -125,7 +125,7 @@ public ILightTransitionPipelineConfigurator AddPipelineWhen if (instantiationScope == InstantiationScope.Shared) { // Note: even though InstantiationScope is primarily intended for composite pipelines, we try to stay consistent with the lifetime of the inner pipeline to avoid confusion. - var pipeline = _serviceProvider.GetRequiredService().CreateLightPipeline(Light, pipelineConfigurator.SetLoggingContext(LogName, LoggingEnabled ?? false)); + var pipeline = _serviceProvider.GetRequiredService().CreateLightPipeline(Light, pipelineConfigurator.SetLoggingContext($"{LogName}->Condition", LoggingEnabled ?? false)); return When(_ => pipeline); } return When(c => @@ -139,7 +139,7 @@ public ILightTransitionPipelineConfigurator AddPipelineWhen(IObservable< if (instantiationScope == InstantiationScope.Shared) { // Note: even though InstantiationScope is primarily intended for composite pipelines, we try to stay consistent with the lifetime of the inner pipeline to avoid confusion. - var pipeline = _serviceProvider.GetRequiredService().CreateLightPipeline(Light, pipelineConfigurator.SetLoggingContext(LogName, LoggingEnabled ?? false)); + var pipeline = _serviceProvider.GetRequiredService().CreateLightPipeline(Light, pipelineConfigurator.SetLoggingContext($"{LogName}->Condition", LoggingEnabled ?? false)); return When(observable, _ => pipeline); } return When(observable, c => From fb6e4c21165dadb8ab73689312b1704d88159a73 Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 27 Feb 2026 17:27:46 +0100 Subject: [PATCH 4/4] Making Maybe private. --- .../Extensions/TimelineValueCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs b/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs index 00c1cbc..2fd17d3 100644 --- a/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs +++ b/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs @@ -138,7 +138,7 @@ private static IObservable CreateTimelineObservable( .Select(s => s.Value!); } - public sealed record Maybe(bool HasValue, T? Value) + private sealed record Maybe(bool HasValue, T? Value) { public static Maybe None => new(false, default); public static Maybe Some(T value) => new(true, value);