From 44fda663de472a942797e12c3a12590f5cae8541 Mon Sep 17 00:00:00 2001 From: Duane McKibbin Date: Mon, 9 Mar 2026 11:32:44 +0200 Subject: [PATCH] Add sun events, i.e. scheduler.RunAtSunset() --- .../Scheduling/SunEventTests.cs | 157 ++++++++++++++++++ .../CronExtensions.cs | 6 +- .../DependencyInjectionSetup.cs | 17 +- .../NetDaemon.Extensions.Scheduling.csproj | 1 + .../SunEvents/Coordinates.cs | 2 + .../SunEvents/ISolarCalendar.cs | 16 ++ .../SunEvents/ISunEventScheduler.cs | 36 ++++ .../SunEvents/SolarCalendar.cs | 30 ++++ .../SunEvents/SunEventScheduler.cs | 65 ++++++++ 9 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 src/Extensions/NetDaemon.Extensions.Scheduling.Tests/Scheduling/SunEventTests.cs create mode 100644 src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/Coordinates.cs create mode 100644 src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISolarCalendar.cs create mode 100644 src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISunEventScheduler.cs create mode 100644 src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SolarCalendar.cs create mode 100644 src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SunEventScheduler.cs diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling.Tests/Scheduling/SunEventTests.cs b/src/Extensions/NetDaemon.Extensions.Scheduling.Tests/Scheduling/SunEventTests.cs new file mode 100644 index 000000000..6cc332fa0 --- /dev/null +++ b/src/Extensions/NetDaemon.Extensions.Scheduling.Tests/Scheduling/SunEventTests.cs @@ -0,0 +1,157 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Reactive.Testing; +using Moq; +using NetDaemon.Extensions.Scheduler; +using NetDaemon.Extensions.Scheduler.SunEvents; +using Xunit; + +namespace NetDaemon.Extensions.Scheduling.Tests; + +public class SunEventTests +{ + [Fact] + public void TestWhereSunEventMustStillHappenToday() + { + var count = 0; + var sched = new TestScheduler(); + var mockSolarCalendar = new Mock(); + + var beforeEvent = new DateTime(2026, 1, 1, 4, 0, 0); + var eventTime = new DateTime(2026, 1, 1, 6, 0, 0); + var endOfDay = new DateTime(2026, 1, 1, 23, 59, 0); + + var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched); + sched.AdvanceTo(beforeEvent.ToUniversalTime().Ticks); + var sub = sunScheduler.RunAtSunEvent(() => eventTime, () => + { + count++; + }); + + count.Should().Be(0, because: "Sun event has not happened yet"); + sched.AdvanceTo(eventTime.ToUniversalTime().Ticks); + count.Should().Be(1, because: "Sun event has now passed"); + sched.AdvanceTo(endOfDay.ToUniversalTime().Ticks); + count.Should().Be(1, because: "Day has ended but sun event already happened earlier"); + } + + [Fact] + public void TestWhereSunEventHasAlreadyHappenedToday() + { + var count = 0; + var sched = new TestScheduler(); + var mockSolarCalendar = new Mock(); + + var eventTime = new DateTime(2026, 1, 1, 6, 0, 0); + var afterEvent = new DateTime(2026, 1, 1, 7, 0, 0); + var endOfDay = new DateTime(2026, 1, 1, 23, 50, 0); + + var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched); + sched.AdvanceTo(afterEvent.ToUniversalTime().Ticks); + var sub = sunScheduler.RunAtSunEvent(() => eventTime, () => + { + count++; + }); + + sched.AdvanceTo(endOfDay.ToUniversalTime().Ticks); + count.Should().Be(0, because: "Sun event has already happened today"); + } + + [Fact] + public void TestCheckingForEventOnNextDay() + { + var count = 0; + var sched = new TestScheduler(); + var mockSolarCalendar = new Mock(); + + var today = new DateTime(2026, 1, 1, 7, 0, 0); + var beginningOfNextDay = new DateTime(2026, 1, 2, 0, 0, 0); + var eventTime = new DateTime(2026, 1, 2, 6, 0, 0); + + var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched); + sched.AdvanceTo(today.ToUniversalTime().Ticks); + var sub = sunScheduler.RunAtSunEvent(() => eventTime, () => + { + count++; + }); + + count.Should().Be(0, because: "Sun event has not happened yet"); + sched.AdvanceTo(beginningOfNextDay.ToUniversalTime().Ticks); + count.Should().Be(0, because: "Event should be scheduled but not executed yet"); + sched.AdvanceTo(eventTime.ToUniversalTime().Ticks); + count.Should().Be(1, because: "Event has now passed"); + } + + [Fact] + public void TestSunriseGetsCorrectTime() + { + var count = 0; + var sched = new TestScheduler(); + var mockSolarCalendar = new Mock(); + var eventTime = new DateTime(2026, 1, 1, 6, 0, 0); + + mockSolarCalendar.Setup(c => c.Sunrise).Returns(eventTime); + var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched); + + var sub = sunScheduler.RunAtSunrise(() => + { + count++; + }); + mockSolarCalendar.VerifyAll(); + } + + [Fact] + public void TestSunsetGetsCorrectTime() + { + var count = 0; + var sched = new TestScheduler(); + var mockSolarCalendar = new Mock(); + var eventTime = new DateTime(2026, 1, 1, 6, 0, 0); + + mockSolarCalendar.Setup(c => c.Sunset).Returns(eventTime); + var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched); + + var sub = sunScheduler.RunAtSunset(() => + { + count++; + }); + mockSolarCalendar.VerifyAll(); + } + + [Fact] + public void TestDawnGetsCorrectTime() + { + var count = 0; + var sched = new TestScheduler(); + var mockSolarCalendar = new Mock(); + var eventTime = new DateTime(2026, 1, 1, 6, 0, 0); + + mockSolarCalendar.Setup(c => c.Dawn).Returns(eventTime); + var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched); + + var sub = sunScheduler.RunAtDawn(() => + { + count++; + }); + mockSolarCalendar.VerifyAll(); + } + + [Fact] + public void TestDuskGetsCorrectTime() + { + var count = 0; + var sched = new TestScheduler(); + var mockSolarCalendar = new Mock(); + var eventTime = new DateTime(2026, 1, 1, 6, 0, 0); + + mockSolarCalendar.Setup(c => c.Dusk).Returns(eventTime); + var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched); + + var sub = sunScheduler.RunAtDusk(() => + { + count++; + }); + mockSolarCalendar.VerifyAll(); + } +} diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/CronExtensions.cs b/src/Extensions/NetDaemon.Extensions.Scheduling/CronExtensions.cs index 43de46050..d36dfc794 100644 --- a/src/Extensions/NetDaemon.Extensions.Scheduling/CronExtensions.cs +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/CronExtensions.cs @@ -37,10 +37,10 @@ private static void RecursiveSchedule(IScheduler scheduler, CronExpression cronE var next = cronExpression.GetNextOccurrence(now, TimeZoneInfo.Local); if (next.HasValue) { - disposableBox.Value = scheduler.Schedule(next.Value, EcecuteAndReschedule); + disposableBox.Value = scheduler.Schedule(next.Value, ExecuteAndReschedule); } - void EcecuteAndReschedule() + void ExecuteAndReschedule() { try { @@ -52,4 +52,4 @@ void EcecuteAndReschedule() } } } -} \ No newline at end of file +} diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/DependencyInjectionSetup.cs b/src/Extensions/NetDaemon.Extensions.Scheduling/DependencyInjectionSetup.cs index f1ea44696..d9ad5190c 100644 --- a/src/Extensions/NetDaemon.Extensions.Scheduling/DependencyInjectionSetup.cs +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/DependencyInjectionSetup.cs @@ -1,6 +1,7 @@ using System.Reactive.Concurrency; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using NetDaemon.Extensions.Scheduler.SunEvents; namespace NetDaemon.Extensions.Scheduler; @@ -19,4 +20,18 @@ public static IServiceCollection AddNetDaemonScheduler(this IServiceCollection s services.AddScoped(s => new DisposableScheduler(DefaultScheduler.Instance.WrapWithLogger(s.GetRequiredService>()))); return services; } -} \ No newline at end of file + + /// + /// Adds sun event scheduling capabilities through dependency injection + /// + /// Latitude of location to use for sun event scheduling + /// Longitude of location to use for sun event scheduling + /// Provided service collection + public static IServiceCollection AddSunEventScheduler(this IServiceCollection services, decimal latitude, decimal longitude) + { + services.AddNetDaemonScheduler(); + services.AddScoped((services) => new SolarCalendar(new Coordinates(latitude, longitude))); + services.AddScoped(); + return services; + } +} diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/NetDaemon.Extensions.Scheduling.csproj b/src/Extensions/NetDaemon.Extensions.Scheduling/NetDaemon.Extensions.Scheduling.csproj index 2acaee371..d1fbede2e 100644 --- a/src/Extensions/NetDaemon.Extensions.Scheduling/NetDaemon.Extensions.Scheduling.csproj +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/NetDaemon.Extensions.Scheduling.csproj @@ -39,6 +39,7 @@ all + ..\..\..\.linting\roslynator.ruleset diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/Coordinates.cs b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/Coordinates.cs new file mode 100644 index 000000000..75960ad10 --- /dev/null +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/Coordinates.cs @@ -0,0 +1,2 @@ +namespace NetDaemon.Extensions.Scheduler.SunEvents; +internal record Coordinates(decimal Latitude, decimal Longitude); \ No newline at end of file diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISolarCalendar.cs b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISolarCalendar.cs new file mode 100644 index 000000000..2d4588976 --- /dev/null +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISolarCalendar.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +[assembly: InternalsVisibleTo("NetDaemon.Extensions.Scheduling.Tests")] +namespace NetDaemon.Extensions.Scheduler.SunEvents +{ + internal interface ISolarCalendar + { + DateTimeOffset Sunset { get; } + DateTimeOffset Sunrise { get; } + DateTimeOffset Dusk { get; } + DateTimeOffset Dawn { get; } + } +} diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISunEventScheduler.cs b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISunEventScheduler.cs new file mode 100644 index 000000000..5618b346d --- /dev/null +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/ISunEventScheduler.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace NetDaemon.Extensions.Scheduler.SunEvents +{ + /// + /// Provides scheduling capability based on sun events + /// + public interface ISunEventScheduler + { + /// + /// Runs at action at Sunset based on configured coordinates + /// + /// Action to run + IDisposable RunAtSunset(Action action); + + /// + /// Runs at action at Dawn (Civil) based on configured coordinates + /// + /// Action to run + IDisposable RunAtDawn(Action action); + + /// + /// Runs at action at Sunrise based on configured coordinates + /// + /// Action to run + IDisposable RunAtSunrise(Action action); + + /// + /// Runs at action at Dusk (Civil) based on configured coordinates + /// + /// Action to run + IDisposable RunAtDusk(Action action); + } +} diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SolarCalendar.cs b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SolarCalendar.cs new file mode 100644 index 000000000..f883595c3 --- /dev/null +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SolarCalendar.cs @@ -0,0 +1,30 @@ +using Innovative.SolarCalculator; + +namespace NetDaemon.Extensions.Scheduler.SunEvents; + +internal class SolarCalendar : ISolarCalendar +{ + private readonly SolarTimes _cachedCalculator; + + public SolarCalendar(Coordinates coordinates) + { + _cachedCalculator = new SolarTimes(DateTime.Now, coordinates.Latitude, coordinates.Longitude); + } + + private SolarTimes SolarCalculator + { + get + { + _cachedCalculator.ForDate = DateTime.Now; + return _cachedCalculator; + } + } + + public DateTimeOffset Sunset => SolarCalculator.Sunset; + + public DateTimeOffset Sunrise => SolarCalculator.Sunrise; + + public DateTimeOffset Dusk => SolarCalculator.DuskCivil; + + public DateTimeOffset Dawn => SolarCalculator.DawnCivil; +} diff --git a/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SunEventScheduler.cs b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SunEventScheduler.cs new file mode 100644 index 000000000..007cc53c8 --- /dev/null +++ b/src/Extensions/NetDaemon.Extensions.Scheduling/SunEvents/SunEventScheduler.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; +using System.Reactive.Concurrency; +using System.Runtime.CompilerServices; +using System.Text; + +[assembly: InternalsVisibleTo("NetDaemon.Extensions.Scheduling.Tests")] + +namespace NetDaemon.Extensions.Scheduler.SunEvents; + +internal sealed class SunEventScheduler : ISunEventScheduler +{ + private readonly IScheduler _reactiveScheduler; + private readonly ISolarCalendar _solarCalendar; + + public SunEventScheduler(ISolarCalendar solarCalendar, IScheduler reactiveScheduler) + { + _reactiveScheduler = reactiveScheduler; + _solarCalendar = solarCalendar; + } + + internal IDisposable RunAtSunEvent(Func getSunEventTime, Action action) + { + var todaysSunEvent = getSunEventTime().ToLocalTime(); + var now = _reactiveScheduler.Now.ToLocalTime(); + var tomorrow = now.Date.AddDays(1); + + //Only schedule if the sun event is still going to occur today, the cron schedule will take over from tomorrow + if (todaysSunEvent > now && todaysSunEvent < tomorrow) + { + _reactiveScheduler.Schedule(todaysSunEvent, action); + } + + return _reactiveScheduler.ScheduleCron("0 0 * * *", () => + { + _reactiveScheduler.Schedule(getSunEventTime(), action); + }); + } + + /// + public IDisposable RunAtSunset(Action action) + { + return RunAtSunEvent(() => _solarCalendar.Sunset, action); + } + + /// + public IDisposable RunAtDawn(Action action) + { + return RunAtSunEvent(() => _solarCalendar.Dawn, action); + } + + /// + public IDisposable RunAtSunrise(Action action) + { + return RunAtSunEvent(() => _solarCalendar.Sunrise, action); + } + + /// + public IDisposable RunAtDusk(Action action) + { + return RunAtSunEvent(() => _solarCalendar.Dusk, action); + } +}