diff --git a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs index e6a7875..b4a5c61 100644 --- a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs +++ b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionPipelineConfiguratorExtensions.Scene.cs @@ -203,11 +203,11 @@ public static ILightTransitionPipelineConfigurator AddToggle( IObservable triggerObservable, params IEntityCore[] sceneEntities) { - return configurator.AddToggle(triggerObservable, (Action>)(c => + return configurator.AddToggle(triggerObservable, c => { foreach (var scene in sceneEntities) c.AddScene(scene); - })); + }); } // ------------------------------------------------------------------------- @@ -230,11 +230,11 @@ public static ILightTransitionPipelineConfigurator AddCycle( IObservable triggerObservable, params IEntityCore[] sceneEntities) { - return configurator.AddCycle(triggerObservable, (Action>)(c => + return configurator.AddCycle(triggerObservable, c => { foreach (var scene in sceneEntities) c.AddScene(scene); - })); + }); } } diff --git a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs index d0f8dae..c828cf9 100644 --- a/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs +++ b/src/CodeCasa.AutomationPipelines.Lights.NetDaemon/Extensions/LightTransitionReactiveNodeConfiguratorExtensions.Scene.cs @@ -52,11 +52,11 @@ public static ILightTransitionReactiveNodeConfigurator AddToggle IObservable triggerObservable, params IEntityCore[] sceneEntities) { - return configurator.AddToggle(triggerObservable, (Action>)(c => + return configurator.AddToggle(triggerObservable, c => { foreach (var scene in sceneEntities) c.AddScene(scene); - })); + }); } // ------------------------------------------------------------------------- @@ -79,11 +79,11 @@ public static ILightTransitionReactiveNodeConfigurator AddCycle< IObservable triggerObservable, params IEntityCore[] sceneEntities) { - return configurator.AddCycle(triggerObservable, (Action>)(c => + return configurator.AddCycle(triggerObservable, c => { foreach (var scene in sceneEntities) c.AddScene(scene); - })); + }); } } diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ObservableExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ObservableExtensions.cs index c244252..0aff28f 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ObservableExtensions.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ObservableExtensions.cs @@ -35,7 +35,7 @@ public static IObservable ToCycleObservable( public static IObservable ToToggleObservable( this IObservable triggerObservable, - Func offCondition, + Func offCondition, Func offValueFactory, IEnumerable> valueFactories, TimeSpan timeout, @@ -55,19 +55,20 @@ public static IObservable ToCycleObservable( { var utcNow = DateTime.UtcNow; var consecutive = previousLastChanged != null && utcNow - previousLastChanged < timeout; - previousLastChanged = utcNow; - + if (!consecutive) { index = 0; - if (offCondition()) + if (offCondition(previousLastChanged)) { + previousLastChanged = utcNow; return offValueFactory(); } } var value = index >= valueFactoryArray.Length ? offValueFactory() : valueFactoryArray[index](); index = index < maxIndexValue ? index + 1 : 0; + previousLastChanged = utcNow; return value; }); } diff --git a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Reactive.Toggle.cs b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Reactive.Toggle.cs index 3c70822..08065c0 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Reactive.Toggle.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Pipeline/LightTransitionPipelineConfigurator.Reactive.Toggle.cs @@ -90,6 +90,6 @@ public ILightTransitionPipelineConfigurator AddToggle(IObservable public ILightTransitionPipelineConfigurator AddToggle(IObservable triggerObservable, Action configure) { - return AddToggle(triggerObservable, (Action>)(c => c.AddTimeline(configure))); + return AddToggle(triggerObservable, c => c.AddTimeline(configure)); } } \ No newline at end of file diff --git a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Toggle.cs b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Toggle.cs index 26a7c2c..2e301cb 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Toggle.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Toggle.cs @@ -65,20 +65,37 @@ public ILightTransitionReactiveNodeConfigurator AddToggle(IObservable { var toggleConfigurators = configurators.ToDictionary(kvp => kvp.Key, kvp => new LightTransitionToggleConfigurator(kvp.Value.Light, scheduler)); - var compositeCycleConfigurator = new CompositeLightTransitionToggleConfigurator(toggleConfigurators, []); - configure(compositeCycleConfigurator); + var compositeToggleConfigurator = new CompositeLightTransitionToggleConfigurator(toggleConfigurators, []); + configure(compositeToggleConfigurator); var shareableTriggerObservable = _observableSharingStrategy.Apply(triggerObservable); - configurators.ForEach(kvp => kvp.Value.AddNodeSource(shareableTriggerObservable.ToToggleObservable( - () => configurators.Values.Any(c => c.Light.IsOn()), - () => new TurnOffThenPassThroughNode(), - toggleConfigurators[kvp.Key].NodeFactories.Select(fact => - { - return new Func>(() => - fact.CreateScopedNode(kvp.Value.ServiceProvider) // Note: This service provider already has the light registered. We scope it further for node lifetime. + + configurators.ForEach(kvp => + { + var toggleConfig = toggleConfigurators[kvp.Key]; + var gracePeriod = toggleConfig.GracePeriod ?? TimeSpan.FromSeconds(1); + kvp.Value.AddNodeSource(shareableTriggerObservable.ToToggleObservable( + lastActivationTime => + { + var utcNow = DateTime.UtcNow; + if (utcNow - kvp.Value.Light.LastChangedUtc <= gracePeriod && + (!lastActivationTime.HasValue || utcNow - lastActivationTime > gracePeriod)) + { + return !configurators.Values.Any(c => c.Light.IsOn()); + } + + return configurators.Values.Any(c => c.Light.IsOn()); + }, + () => new TurnOffThenPassThroughNode(), + toggleConfig.NodeFactories.Select(fact => + { + return new Func>(() => + fact.CreateScopedNode(kvp.Value + .ServiceProvider) // Note: This service provider already has the light registered. We scope it further for node lifetime. ); - }), - toggleConfigurators[kvp.Key].ToggleTimeout ?? TimeSpan.FromMilliseconds(1000), - toggleConfigurators[kvp.Key].IncludeOffValue))); + }), + toggleConfig.ToggleTimeout ?? TimeSpan.FromMilliseconds(1000), + toggleConfig.IncludeOffValue)); + }); return this; } @@ -96,5 +113,5 @@ public ILightTransitionReactiveNodeConfigurator AddToggle(IObservable /// public ILightTransitionReactiveNodeConfigurator AddToggle(IObservable triggerObservable, Action configure) - => AddToggle(triggerObservable, (Action>)(c => c.AddTimeline(configure))); + => AddToggle(triggerObservable, c => c.AddTimeline(configure)); } \ No newline at end of file diff --git a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Toggle.cs b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Toggle.cs index 900cceb..a3a70a6 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Toggle.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Toggle.cs @@ -65,8 +65,20 @@ public ILightTransitionReactiveNodeConfigurator AddToggle(IObservable { var toggleConfigurator = new LightTransitionToggleConfigurator(Light, _scheduler); configure(toggleConfigurator); + + var gracePeriod = toggleConfigurator.GracePeriod ?? TimeSpan.FromSeconds(1); AddNodeSource(triggerObservable.ToToggleObservable( - () => Light.IsOn(), + lastActivationTime => + { + var utcNow = DateTime.UtcNow; + if (utcNow - Light.LastChangedUtc <= gracePeriod && + (!lastActivationTime.HasValue || utcNow - lastActivationTime > gracePeriod)) + { + return !Light.IsOn(); + } + + return Light.IsOn(); + }, () => new TurnOffThenPassThroughNode(), toggleConfigurator.NodeFactories.Select(fact => { @@ -93,5 +105,5 @@ public ILightTransitionReactiveNodeConfigurator AddToggle(IObservable /// public ILightTransitionReactiveNodeConfigurator AddToggle(IObservable triggerObservable, Action configure) - => AddToggle(triggerObservable, (Action>)(c => c.AddTimeline(configure))); + => AddToggle(triggerObservable, c => c.AddTimeline(configure)); } diff --git a/src/CodeCasa.AutomationPipelines.Lights/Toggle/CompositeLightTransitionToggleConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/Toggle/CompositeLightTransitionToggleConfigurator.cs index 7a41669..b3266e0 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Toggle/CompositeLightTransitionToggleConfigurator.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Toggle/CompositeLightTransitionToggleConfigurator.cs @@ -34,6 +34,13 @@ public ILightTransitionToggleConfigurator ExcludeOffFromToggleCycle() return this; } + public ILightTransitionToggleConfigurator SetGracePeriod(TimeSpan gracePeriod) + { + activeConfigurators.Values.ForEach(c => c.SetGracePeriod(gracePeriod)); + inactiveConfigurators.Values.ForEach(c => c.SetGracePeriod(gracePeriod)); + return this; + } + public ILightTransitionToggleConfigurator AddOff() { return Add(); diff --git a/src/CodeCasa.AutomationPipelines.Lights/Toggle/ILightTransitionToggleConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/Toggle/ILightTransitionToggleConfigurator.cs index ff6ae6a..e852bd9 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Toggle/ILightTransitionToggleConfigurator.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Toggle/ILightTransitionToggleConfigurator.cs @@ -31,6 +31,12 @@ public interface ILightTransitionToggleConfigurator where TLight : ILigh /// The configurator instance for method chaining. ILightTransitionToggleConfigurator ExcludeOffFromToggleCycle(); + /// + /// Sets the grace period during which a manual interaction will use the light's + /// previous state instead of its current state. + /// + ILightTransitionToggleConfigurator SetGracePeriod(TimeSpan gracePeriod); + /// /// Adds an "off" state to the toggle sequence. /// diff --git a/src/CodeCasa.AutomationPipelines.Lights/Toggle/LightTransitionToggleConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/Toggle/LightTransitionToggleConfigurator.cs index 8dfa47d..f004fe1 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Toggle/LightTransitionToggleConfigurator.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Toggle/LightTransitionToggleConfigurator.cs @@ -13,6 +13,7 @@ internal class LightTransitionToggleConfigurator(TLight light, ISchedule public TLight Light { get; } = light; internal TimeSpan? ToggleTimeout { get; private set; } internal bool? IncludeOffValue { get; private set; } + internal TimeSpan? GracePeriod { get; private set; } internal List>> NodeFactories { get; @@ -36,6 +37,12 @@ public ILightTransitionToggleConfigurator ExcludeOffFromToggleCycle() return this; } + public ILightTransitionToggleConfigurator SetGracePeriod(TimeSpan gracePeriod) + { + GracePeriod = gracePeriod; + return this; + } + public ILightTransitionToggleConfigurator AddOff() { return Add(); diff --git a/src/CodeCasa.Lights.NetDaemon/NetDaemonLight.cs b/src/CodeCasa.Lights.NetDaemon/NetDaemonLight.cs index 0866fef..b355768 100644 --- a/src/CodeCasa.Lights.NetDaemon/NetDaemonLight.cs +++ b/src/CodeCasa.Lights.NetDaemon/NetDaemonLight.cs @@ -71,4 +71,10 @@ public ILight[] GetChildren() public IObservable> StateChangesWithCurrent() => _lightEntity.StateChangesWithCurrent().Select(sc => new Abstractions.StateChange(this, sc.Old?.Attributes?.ToLightParameters(), sc.New?.Attributes?.ToLightParameters())); + + /// + public DateTime? LastChangedUtc => _lightEntity.EntityState?.LastChanged?.ToUniversalTime(); + + /// + public DateTime? LastUpdatedUtc => _lightEntity.EntityState?.LastUpdated?.ToUniversalTime(); } \ No newline at end of file diff --git a/src/CodeCasa.Lights/ILight.cs b/src/CodeCasa.Lights/ILight.cs index 74cbfc7..a9d3177 100644 --- a/src/CodeCasa.Lights/ILight.cs +++ b/src/CodeCasa.Lights/ILight.cs @@ -40,4 +40,28 @@ public interface ILight /// /// An that emits events. IObservable> StateChangesWithCurrent(); + + /// + /// Gets the UTC timestamp when the entity's actual state last changed (e.g., from 'off' to 'on'). + /// + /// + /// The in UTC of the last state change, or if unavailable. + /// + /// + /// This value only updates when the primary state value changes. It does not update if only + /// entity attributes (like brightness or temperature) change. + /// + DateTime? LastChangedUtc { get; } + + /// + /// Gets the UTC timestamp when the entity was last updated. + /// + /// + /// The in UTC of the last update, or if unavailable. + /// + /// + /// This value updates whenever the entity is processed by the system, including when + /// attributes change (e.g., brightness, color) or when a sensor reports the exact same state again. + /// + DateTime? LastUpdatedUtc { get; } } \ No newline at end of file