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..2fd17d3
--- /dev/null
+++ b/src/CodeCasa.Lights.Timelines/Extensions/TimelineValueCollectionExtensions.cs
@@ -0,0 +1,154 @@
+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
+{
+ ///
+ /// Provides reactive extension methods for collections
+ /// where the keys are instances.
+ ///
+ 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!);
+ }
+
+ private 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