Skip to content

Commit 25abf6a

Browse files
authored
Implemented CodeCasa.Lights.Timelines (#205)
* Implemented CodeCasa.Lights.Timelines * Added final xml comment. * Improved logging for conditional pipeline. * Making Maybe private.
1 parent 0031fe4 commit 25abf6a

5 files changed

Lines changed: 246 additions & 1 deletion

File tree

CodeCasa.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Notifications.Ligh
4343
EndProject
4444
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}"
4545
EndProject
46+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Lights.Timelines", "src\CodeCasa.Lights.Timelines\CodeCasa.Lights.Timelines.csproj", "{2308FD7B-1A02-4901-9914-094A51936E30}"
47+
EndProject
4648
Global
4749
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4850
Debug|Any CPU = Debug|Any CPU
@@ -125,6 +127,10 @@ Global
125127
{725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
126128
{725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
127129
{725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.Build.0 = Release|Any CPU
130+
{2308FD7B-1A02-4901-9914-094A51936E30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
131+
{2308FD7B-1A02-4901-9914-094A51936E30}.Debug|Any CPU.Build.0 = Debug|Any CPU
132+
{2308FD7B-1A02-4901-9914-094A51936E30}.Release|Any CPU.ActiveCfg = Release|Any CPU
133+
{2308FD7B-1A02-4901-9914-094A51936E30}.Release|Any CPU.Build.0 = Release|Any CPU
128134
EndGlobalSection
129135
GlobalSection(SolutionProperties) = preSolution
130136
HideSolutionNode = FALSE
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<Title>CodeCasa.Lights.Timelines</Title>
8+
<Authors>Jasper Lammers</Authors>
9+
<Company>DevJasper</Company>
10+
<Description>Bridge library connecting CodeCasa light abstractions with Occurify timelines for reactive, time-based lighting schedules.</Description>
11+
<RepositoryUrl>https://github.com/DevJasperNL/CodeCasa</RepositoryUrl>
12+
<PackageTags>Home Automation;Home Assistant;Smart Home;Occurify;Timelines;Scheduling;Reactive;Rx;Lights;Lighting;Transitions;Interpolation;C#;.NET</PackageTags>
13+
<PackageLicenseFile>LICENSE</PackageLicenseFile>
14+
<PackageReadmeFile>README.md</PackageReadmeFile>
15+
<PackageId>CodeCasa.Lights.Timelines</PackageId>
16+
<GenerateDocumentationFile>True</GenerateDocumentationFile>
17+
<PackageIcon>cc_icon.png</PackageIcon>
18+
<PackageReleaseNotes>https://github.com/DevJasperNL/CodeCasa/releases</PackageReleaseNotes>
19+
<Version>1.11.1</Version>
20+
</PropertyGroup>
21+
22+
<ItemGroup>
23+
<None Include="..\..\LICENSE">
24+
<Pack>True</Pack>
25+
<PackagePath>\</PackagePath>
26+
</None>
27+
<None Include="..\..\README.md">
28+
<Pack>True</Pack>
29+
<PackagePath>\</PackagePath>
30+
</None>
31+
<None Include="..\..\img\cc_icon.png">
32+
<Pack>True</Pack>
33+
<PackagePath>\</PackagePath>
34+
</None>
35+
</ItemGroup>
36+
37+
<ItemGroup>
38+
<PackageReference Include="Occurify" Version="0.10.0" />
39+
<PackageReference Include="Occurify.Reactive" Version="0.10.0" />
40+
<PackageReference Include="System.Reactive" Version="6.1.0" />
41+
</ItemGroup>
42+
43+
<ItemGroup>
44+
<ProjectReference Include="..\CodeCasa.Lights\CodeCasa.Lights.csproj" />
45+
</ItemGroup>
46+
47+
</Project>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace CodeCasa.Lights.Timelines
2+
{
3+
internal sealed class DictionaryComparer<TKey, TValue> : IEqualityComparer<Dictionary<TKey, TValue>>
4+
where TKey : notnull
5+
{
6+
private readonly IEqualityComparer<TValue> _valueComparer;
7+
8+
public DictionaryComparer(IEqualityComparer<TValue>? valueComparer = null)
9+
{
10+
_valueComparer = valueComparer ?? EqualityComparer<TValue>.Default;
11+
}
12+
13+
public bool Equals(Dictionary<TKey, TValue>? x, Dictionary<TKey, TValue>? y)
14+
{
15+
if (ReferenceEquals(x, y)) return true;
16+
if (x == null || y == null) return false;
17+
if (x.Count != y.Count) return false;
18+
19+
foreach (var (key, value) in x)
20+
{
21+
if (!y.TryGetValue(key, out var yValue) || !_valueComparer.Equals(value, yValue))
22+
return false;
23+
}
24+
return true;
25+
}
26+
27+
public int GetHashCode(Dictionary<TKey, TValue> obj)
28+
{
29+
var hash = new HashCode();
30+
foreach (var (key, value) in obj)
31+
{
32+
hash.Add(key);
33+
hash.Add(value, _valueComparer);
34+
}
35+
return hash.ToHashCode();
36+
}
37+
}
38+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
using CodeCasa.Lights.Extensions;
2+
using Occurify;
3+
using Occurify.Extensions;
4+
using Occurify.Reactive.Extensions;
5+
using System.Reactive.Concurrency;
6+
using System.Reactive.Linq;
7+
8+
namespace CodeCasa.Lights.Timelines.Extensions
9+
{
10+
/// <summary>
11+
/// Provides reactive extension methods for <see cref="Dictionary{TKey, TValue}"/> collections
12+
/// where the keys are <see cref="ITimeline"/> instances.
13+
/// </summary>
14+
public static class TimelineValueCollectionExtensions
15+
{
16+
/// <summary>
17+
/// Converts a timeline dictionary into an observable stream of <see cref="LightTransition"/> objects,
18+
/// including an immediate interpolated starting value.
19+
/// </summary>
20+
/// <param name="sceneTimeline">The dictionary mapping timeline points to <see cref="LightParameters"/>.</param>
21+
/// <param name="scheduler">The Rx scheduler used to manage timing and initial delay.</param>
22+
/// <param name="transitionTimeForTimelineState">
23+
/// The duration of the initial fade from current state. Defaults to 500ms if null.
24+
/// </param>
25+
/// <returns>An observable that emits the current interpolated state, then follows the scheduled timeline.</returns>
26+
public static IObservable<LightTransition> ToLightTransitionObservableIncludingCurrent(
27+
this Dictionary<ITimeline, LightParameters> sceneTimeline,
28+
IScheduler scheduler,
29+
TimeSpan? transitionTimeForTimelineState = null)
30+
{
31+
return CreateTimelineObservableIncludingInitialInterpolatedValue(sceneTimeline,
32+
(lightParameters, transitionTime) => lightParameters.AsTransition(transitionTime),
33+
(previous, next, fraction) => previous.Interpolate(next, fraction),
34+
EqualityComparer<LightParameters>.Default,
35+
scheduler,
36+
transitionTimeForTimelineState);
37+
}
38+
39+
/// <summary>
40+
/// Converts a nested timeline dictionary into an observable stream of light scenes,
41+
/// where each emission contains a dictionary of transitions for multiple light sources.
42+
/// </summary>
43+
/// <param name="sceneTimeline">A dictionary mapping timeline points to a collection of light states keyed by ID.</param>
44+
/// <param name="scheduler">The Rx scheduler used to manage timing and initial delay.</param>
45+
/// <param name="transitionTimeForTimelineState">
46+
/// The duration of the initial fade for all lights in the scene. Defaults to 500ms if null.
47+
/// </param>
48+
/// <returns>An observable that emits a dictionary of transitions representing the current scene state, followed by scheduled updates.</returns>
49+
/// <remarks>
50+
/// This method utilizes a custom dictionary comparer to ensure updates are only emitted when
51+
/// at least one light in the scene has changed its parameters.
52+
/// </remarks>
53+
public static IObservable<Dictionary<string, LightTransition>> ToLightTransitionSceneObservableIncludingCurrent(
54+
this Dictionary<ITimeline, Dictionary<string, LightParameters>> sceneTimeline,
55+
IScheduler scheduler,
56+
TimeSpan? transitionTimeForTimelineState = null)
57+
{
58+
return CreateTimelineObservableIncludingInitialInterpolatedValue(sceneTimeline,
59+
(lightParametersDict, transitionTime) => lightParametersDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.AsTransition(transitionTime)),
60+
(previousDict, nextDict, fraction) => previousDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Interpolate(nextDict[kvp.Key], fraction)),
61+
new DictionaryComparer<string, LightParameters>(EqualityComparer<LightParameters>.Default),
62+
scheduler,
63+
transitionTimeForTimelineState);
64+
}
65+
66+
/// <summary>
67+
/// Creates an observable that immediately emits an interpolated initial state based on <see cref="DateTime.UtcNow"/>,
68+
/// followed by the standard timeline transitions after a specified delay.
69+
/// </summary>
70+
private static IObservable<TOut> CreateTimelineObservableIncludingInitialInterpolatedValue<TIn, TOut>(
71+
this Dictionary<ITimeline, TIn> sceneTimeline,
72+
Func<TIn, TimeSpan, TOut> transformer,
73+
Func<TIn, TIn, double, TIn> interpolator,
74+
IEqualityComparer<TIn> comparer,
75+
IScheduler scheduler,
76+
TimeSpan? transitionTimeForTimelineState = null)
77+
{
78+
var transitionObservable = CreateTimelineObservable(sceneTimeline, transformer, comparer, scheduler);
79+
80+
var utcNow = DateTime.UtcNow;
81+
var valuesAtPrevious = sceneTimeline.GetValuesAtPreviousUtcInstant(utcNow);
82+
var valuesAtCurrentOrNext = sceneTimeline.GetValuesAtCurrentOrNextUtcInstant(utcNow);
83+
84+
if (valuesAtPrevious.Key == null || valuesAtCurrentOrNext.Key == null)
85+
{
86+
return transitionObservable;
87+
}
88+
89+
var fraction = CalculateFraction(valuesAtPrevious.Key.Value, valuesAtCurrentOrNext.Key.Value, utcNow);
90+
91+
var previousScene = valuesAtPrevious.Value.First();
92+
var nextScene = valuesAtCurrentOrNext.Value.First();
93+
94+
var initialSceneTransition = interpolator(previousScene, nextScene, fraction);
95+
96+
var timeSpan = transitionTimeForTimelineState ?? TimeSpan.FromMilliseconds(500);
97+
// We delay the timeline observable to allow the initial scene transition to be emitted/activated first.
98+
var delayedTimelineObservable = Observable
99+
.Timer(timeSpan, scheduler)
100+
.SelectMany(_ => transitionObservable);
101+
102+
return Observable.Return(transformer(initialSceneTransition, timeSpan)).Concat(delayedTimelineObservable);
103+
}
104+
105+
/// <summary>
106+
/// Creates an observable stream that emits transformed values based on state transitions
107+
/// between consecutive instants in a timeline.
108+
/// </summary>
109+
private static IObservable<TOut> CreateTimelineObservable<TIn, TOut>(
110+
Dictionary<ITimeline, TIn> timeline,
111+
Func<TIn, TimeSpan, TOut> transformer,
112+
IEqualityComparer<TIn> comparer,
113+
IScheduler scheduler)
114+
{
115+
return timeline
116+
.ToSampleObservable(scheduler, emitSampleUponSubscribe: false)
117+
.Select(s =>
118+
{
119+
var instant = s.Key;
120+
var nextValues = timeline.GetValuesAtNextUtcInstant(instant);
121+
var nextInstant = nextValues.Key;
122+
if (nextInstant == null)
123+
{
124+
return Maybe<TOut>.None;
125+
}
126+
127+
var current = s.Value.First();
128+
var next = nextValues.Value.First();
129+
if (comparer.Equals(current, next))
130+
{
131+
return Maybe<TOut>.None;
132+
}
133+
var transitionTimeSpan = nextInstant.Value - instant;
134+
135+
return Maybe<TOut>.Some(transformer(next, transitionTimeSpan));
136+
})
137+
.Where(s => s.HasValue)
138+
.Select(s => s.Value!);
139+
}
140+
141+
private sealed record Maybe<T>(bool HasValue, T? Value)
142+
{
143+
public static Maybe<T> None => new(false, default);
144+
public static Maybe<T> Some(T value) => new(true, value);
145+
}
146+
147+
private static double CalculateFraction(DateTime previous, DateTime next, DateTime current)
148+
{
149+
var timeFromPrevious = current - previous;
150+
var totalTransition = next - previous;
151+
return timeFromPrevious / totalTransition;
152+
}
153+
}
154+
}

src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<Title>CodeCasa.NetDaemon.Sensors.Composite</Title>
8-
<PackageId>CodeCasa.NetDaemon.Sensors.Composite</PackageId>
98
<Authors>Jasper Lammers</Authors>
109
<Company>DevJasper</Company>
1110
<Description>High-level composite sensor abstractions for NetDaemon, combining multiple Home Assistant entities into logical observables.</Description>
1211
<RepositoryUrl>https://github.com/DevJasperNL/CodeCasa</RepositoryUrl>
1312
<PackageTags>NetDaemon;Home Automation;Home Assistant;Smart Home;Motion;Illuminance;Reactive Programming;Rx;Rx.NET;Observables;C#;.NET 10;Composite Sensors</PackageTags>
1413
<PackageLicenseFile>LICENSE</PackageLicenseFile>
1514
<PackageReadmeFile>README.md</PackageReadmeFile>
15+
<PackageId>CodeCasa.NetDaemon.Sensors.Composite</PackageId>
1616
<GenerateDocumentationFile>True</GenerateDocumentationFile>
1717
<PackageIcon>ccnd_icon.png</PackageIcon>
1818
<PackageReleaseNotes>https://github.com/DevJasperNL/CodeCasa/releases</PackageReleaseNotes>

0 commit comments

Comments
 (0)