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
6 changes: 6 additions & 0 deletions CodeCasa.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/CodeCasa.Lights.Timelines/CodeCasa.Lights.Timelines.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Title>CodeCasa.Lights.Timelines</Title>
<Authors>Jasper Lammers</Authors>
<Company>DevJasper</Company>
<Description>Bridge library connecting CodeCasa light abstractions with Occurify timelines for reactive, time-based lighting schedules.</Description>
<RepositoryUrl>https://github.com/DevJasperNL/CodeCasa</RepositoryUrl>
<PackageTags>Home Automation;Home Assistant;Smart Home;Occurify;Timelines;Scheduling;Reactive;Rx;Lights;Lighting;Transitions;Interpolation;C#;.NET</PackageTags>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageId>CodeCasa.Lights.Timelines</PackageId>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageIcon>cc_icon.png</PackageIcon>
<PackageReleaseNotes>https://github.com/DevJasperNL/CodeCasa/releases</PackageReleaseNotes>
<Version>1.11.1</Version>
</PropertyGroup>

<ItemGroup>
<None Include="..\..\LICENSE">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\..\img\cc_icon.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Occurify" Version="0.10.0" />
<PackageReference Include="Occurify.Reactive" Version="0.10.0" />
<PackageReference Include="System.Reactive" Version="6.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CodeCasa.Lights\CodeCasa.Lights.csproj" />
</ItemGroup>

</Project>
38 changes: 38 additions & 0 deletions src/CodeCasa.Lights.Timelines/DictionaryComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace CodeCasa.Lights.Timelines
{
internal sealed class DictionaryComparer<TKey, TValue> : IEqualityComparer<Dictionary<TKey, TValue>>
where TKey : notnull
{
private readonly IEqualityComparer<TValue> _valueComparer;

public DictionaryComparer(IEqualityComparer<TValue>? valueComparer = null)
{
_valueComparer = valueComparer ?? EqualityComparer<TValue>.Default;
}

public bool Equals(Dictionary<TKey, TValue>? x, Dictionary<TKey, TValue>? 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<TKey, TValue> obj)
{
var hash = new HashCode();
foreach (var (key, value) in obj)
{
hash.Add(key);
hash.Add(value, _valueComparer);
}
return hash.ToHashCode();
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Provides reactive extension methods for <see cref="Dictionary{TKey, TValue}"/> collections
/// where the keys are <see cref="ITimeline"/> instances.
/// </summary>
public static class TimelineValueCollectionExtensions
{
/// <summary>
/// Converts a timeline dictionary into an observable stream of <see cref="LightTransition"/> objects,
/// including an immediate interpolated starting value.
/// </summary>
/// <param name="sceneTimeline">The dictionary mapping timeline points to <see cref="LightParameters"/>.</param>
/// <param name="scheduler">The Rx scheduler used to manage timing and initial delay.</param>
/// <param name="transitionTimeForTimelineState">
/// The duration of the initial fade from current state. Defaults to 500ms if null.
/// </param>
/// <returns>An observable that emits the current interpolated state, then follows the scheduled timeline.</returns>
public static IObservable<LightTransition> ToLightTransitionObservableIncludingCurrent(
this Dictionary<ITimeline, LightParameters> sceneTimeline,
IScheduler scheduler,
TimeSpan? transitionTimeForTimelineState = null)
{
return CreateTimelineObservableIncludingInitialInterpolatedValue(sceneTimeline,
(lightParameters, transitionTime) => lightParameters.AsTransition(transitionTime),
(previous, next, fraction) => previous.Interpolate(next, fraction),
EqualityComparer<LightParameters>.Default,
scheduler,
transitionTimeForTimelineState);
}

/// <summary>
/// Converts a nested timeline dictionary into an observable stream of light scenes,
/// where each emission contains a dictionary of transitions for multiple light sources.
/// </summary>
/// <param name="sceneTimeline">A dictionary mapping timeline points to a collection of light states keyed by ID.</param>
/// <param name="scheduler">The Rx scheduler used to manage timing and initial delay.</param>
/// <param name="transitionTimeForTimelineState">
/// The duration of the initial fade for all lights in the scene. Defaults to 500ms if null.
/// </param>
/// <returns>An observable that emits a dictionary of transitions representing the current scene state, followed by scheduled updates.</returns>
/// <remarks>
/// 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.
/// </remarks>
public static IObservable<Dictionary<string, LightTransition>> ToLightTransitionSceneObservableIncludingCurrent(
this Dictionary<ITimeline, Dictionary<string, LightParameters>> 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<string, LightParameters>(EqualityComparer<LightParameters>.Default),
scheduler,
transitionTimeForTimelineState);
}

/// <summary>
/// Creates an observable that immediately emits an interpolated initial state based on <see cref="DateTime.UtcNow"/>,
/// followed by the standard timeline transitions after a specified delay.
/// </summary>
private static IObservable<TOut> CreateTimelineObservableIncludingInitialInterpolatedValue<TIn, TOut>(
this Dictionary<ITimeline, TIn> sceneTimeline,
Func<TIn, TimeSpan, TOut> transformer,
Func<TIn, TIn, double, TIn> interpolator,
IEqualityComparer<TIn> 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);
}

/// <summary>
/// Creates an observable stream that emits transformed values based on state transitions
/// between consecutive instants in a timeline.
/// </summary>
private static IObservable<TOut> CreateTimelineObservable<TIn, TOut>(
Dictionary<ITimeline, TIn> timeline,
Func<TIn, TimeSpan, TOut> transformer,
IEqualityComparer<TIn> 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<TOut>.None;
}

var current = s.Value.First();
var next = nextValues.Value.First();
if (comparer.Equals(current, next))
{
return Maybe<TOut>.None;
}
var transitionTimeSpan = nextInstant.Value - instant;

return Maybe<TOut>.Some(transformer(next, transitionTimeSpan));
})
.Where(s => s.HasValue)
.Select(s => s.Value!);
}

private sealed record Maybe<T>(bool HasValue, T? Value)
{
public static Maybe<T> None => new(false, default);
public static Maybe<T> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Title>CodeCasa.NetDaemon.Sensors.Composite</Title>
<PackageId>CodeCasa.NetDaemon.Sensors.Composite</PackageId>
<Authors>Jasper Lammers</Authors>
<Company>DevJasper</Company>
<Description>High-level composite sensor abstractions for NetDaemon, combining multiple Home Assistant entities into logical observables.</Description>
<RepositoryUrl>https://github.com/DevJasperNL/CodeCasa</RepositoryUrl>
<PackageTags>NetDaemon;Home Automation;Home Assistant;Smart Home;Motion;Illuminance;Reactive Programming;Rx;Rx.NET;Observables;C#;.NET 10;Composite Sensors</PackageTags>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageId>CodeCasa.NetDaemon.Sensors.Composite</PackageId>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageIcon>ccnd_icon.png</PackageIcon>
<PackageReleaseNotes>https://github.com/DevJasperNL/CodeCasa/releases</PackageReleaseNotes>
Expand Down