Skip to content

Commit 99b2af6

Browse files
duanemckhelto4real
andauthored
Add sun events, i.e. scheduler.RunAtSunset() (#1358)
Co-authored-by: Tomas Hellström <tomas.hellstrom@yahoo.se>
1 parent 035ee37 commit 99b2af6

9 files changed

Lines changed: 326 additions & 4 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using FluentAssertions;
2+
using Microsoft.Extensions.Logging;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using Microsoft.Reactive.Testing;
5+
using Moq;
6+
using NetDaemon.Extensions.Scheduler;
7+
using NetDaemon.Extensions.Scheduler.SunEvents;
8+
using Xunit;
9+
10+
namespace NetDaemon.Extensions.Scheduling.Tests;
11+
12+
public class SunEventTests
13+
{
14+
[Fact]
15+
public void TestWhereSunEventMustStillHappenToday()
16+
{
17+
var count = 0;
18+
var sched = new TestScheduler();
19+
var mockSolarCalendar = new Mock<ISolarCalendar>();
20+
21+
var beforeEvent = new DateTime(2026, 1, 1, 4, 0, 0);
22+
var eventTime = new DateTime(2026, 1, 1, 6, 0, 0);
23+
var endOfDay = new DateTime(2026, 1, 1, 23, 59, 0);
24+
25+
var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched);
26+
sched.AdvanceTo(beforeEvent.ToUniversalTime().Ticks);
27+
var sub = sunScheduler.RunAtSunEvent(() => eventTime, () =>
28+
{
29+
count++;
30+
});
31+
32+
count.Should().Be(0, because: "Sun event has not happened yet");
33+
sched.AdvanceTo(eventTime.ToUniversalTime().Ticks);
34+
count.Should().Be(1, because: "Sun event has now passed");
35+
sched.AdvanceTo(endOfDay.ToUniversalTime().Ticks);
36+
count.Should().Be(1, because: "Day has ended but sun event already happened earlier");
37+
}
38+
39+
[Fact]
40+
public void TestWhereSunEventHasAlreadyHappenedToday()
41+
{
42+
var count = 0;
43+
var sched = new TestScheduler();
44+
var mockSolarCalendar = new Mock<ISolarCalendar>();
45+
46+
var eventTime = new DateTime(2026, 1, 1, 6, 0, 0);
47+
var afterEvent = new DateTime(2026, 1, 1, 7, 0, 0);
48+
var endOfDay = new DateTime(2026, 1, 1, 23, 50, 0);
49+
50+
var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched);
51+
sched.AdvanceTo(afterEvent.ToUniversalTime().Ticks);
52+
var sub = sunScheduler.RunAtSunEvent(() => eventTime, () =>
53+
{
54+
count++;
55+
});
56+
57+
sched.AdvanceTo(endOfDay.ToUniversalTime().Ticks);
58+
count.Should().Be(0, because: "Sun event has already happened today");
59+
}
60+
61+
[Fact]
62+
public void TestCheckingForEventOnNextDay()
63+
{
64+
var count = 0;
65+
var sched = new TestScheduler();
66+
var mockSolarCalendar = new Mock<ISolarCalendar>();
67+
68+
var today = new DateTime(2026, 1, 1, 7, 0, 0);
69+
var beginningOfNextDay = new DateTime(2026, 1, 2, 0, 0, 0);
70+
var eventTime = new DateTime(2026, 1, 2, 6, 0, 0);
71+
72+
var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched);
73+
sched.AdvanceTo(today.ToUniversalTime().Ticks);
74+
var sub = sunScheduler.RunAtSunEvent(() => eventTime, () =>
75+
{
76+
count++;
77+
});
78+
79+
count.Should().Be(0, because: "Sun event has not happened yet");
80+
sched.AdvanceTo(beginningOfNextDay.ToUniversalTime().Ticks);
81+
count.Should().Be(0, because: "Event should be scheduled but not executed yet");
82+
sched.AdvanceTo(eventTime.ToUniversalTime().Ticks);
83+
count.Should().Be(1, because: "Event has now passed");
84+
}
85+
86+
[Fact]
87+
public void TestSunriseGetsCorrectTime()
88+
{
89+
var count = 0;
90+
var sched = new TestScheduler();
91+
var mockSolarCalendar = new Mock<ISolarCalendar>();
92+
var eventTime = new DateTime(2026, 1, 1, 6, 0, 0);
93+
94+
mockSolarCalendar.Setup(c => c.Sunrise).Returns(eventTime);
95+
var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched);
96+
97+
var sub = sunScheduler.RunAtSunrise(() =>
98+
{
99+
count++;
100+
});
101+
mockSolarCalendar.VerifyAll();
102+
}
103+
104+
[Fact]
105+
public void TestSunsetGetsCorrectTime()
106+
{
107+
var count = 0;
108+
var sched = new TestScheduler();
109+
var mockSolarCalendar = new Mock<ISolarCalendar>();
110+
var eventTime = new DateTime(2026, 1, 1, 6, 0, 0);
111+
112+
mockSolarCalendar.Setup(c => c.Sunset).Returns(eventTime);
113+
var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched);
114+
115+
var sub = sunScheduler.RunAtSunset(() =>
116+
{
117+
count++;
118+
});
119+
mockSolarCalendar.VerifyAll();
120+
}
121+
122+
[Fact]
123+
public void TestDawnGetsCorrectTime()
124+
{
125+
var count = 0;
126+
var sched = new TestScheduler();
127+
var mockSolarCalendar = new Mock<ISolarCalendar>();
128+
var eventTime = new DateTime(2026, 1, 1, 6, 0, 0);
129+
130+
mockSolarCalendar.Setup(c => c.Dawn).Returns(eventTime);
131+
var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched);
132+
133+
var sub = sunScheduler.RunAtDawn(() =>
134+
{
135+
count++;
136+
});
137+
mockSolarCalendar.VerifyAll();
138+
}
139+
140+
[Fact]
141+
public void TestDuskGetsCorrectTime()
142+
{
143+
var count = 0;
144+
var sched = new TestScheduler();
145+
var mockSolarCalendar = new Mock<ISolarCalendar>();
146+
var eventTime = new DateTime(2026, 1, 1, 6, 0, 0);
147+
148+
mockSolarCalendar.Setup(c => c.Dusk).Returns(eventTime);
149+
var sunScheduler = new SunEventScheduler(mockSolarCalendar.Object, sched);
150+
151+
var sub = sunScheduler.RunAtDusk(() =>
152+
{
153+
count++;
154+
});
155+
mockSolarCalendar.VerifyAll();
156+
}
157+
}

src/Extensions/NetDaemon.Extensions.Scheduling/CronExtensions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ private static void RecursiveSchedule(IScheduler scheduler, CronExpression cronE
3737
var next = cronExpression.GetNextOccurrence(now, TimeZoneInfo.Local);
3838
if (next.HasValue)
3939
{
40-
disposableBox.Value = scheduler.Schedule(next.Value, EcecuteAndReschedule);
40+
disposableBox.Value = scheduler.Schedule(next.Value, ExecuteAndReschedule);
4141
}
4242

43-
void EcecuteAndReschedule()
43+
void ExecuteAndReschedule()
4444
{
4545
try
4646
{
@@ -52,4 +52,4 @@ void EcecuteAndReschedule()
5252
}
5353
}
5454
}
55-
}
55+
}

src/Extensions/NetDaemon.Extensions.Scheduling/DependencyInjectionSetup.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Reactive.Concurrency;
22
using Microsoft.Extensions.DependencyInjection;
33
using Microsoft.Extensions.Logging;
4+
using NetDaemon.Extensions.Scheduler.SunEvents;
45

56
namespace NetDaemon.Extensions.Scheduler;
67

@@ -19,4 +20,18 @@ public static IServiceCollection AddNetDaemonScheduler(this IServiceCollection s
1920
services.AddScoped<IScheduler>(s => new DisposableScheduler(DefaultScheduler.Instance.WrapWithLogger(s.GetRequiredService<ILogger<IScheduler>>())));
2021
return services;
2122
}
22-
}
23+
24+
/// <summary>
25+
/// Adds sun event scheduling capabilities through dependency injection
26+
/// </summary>
27+
/// <param name="latitude">Latitude of location to use for sun event scheduling</param>
28+
/// <param name="longitude">Longitude of location to use for sun event scheduling</param>
29+
/// <param name="services">Provided service collection</param>
30+
public static IServiceCollection AddSunEventScheduler(this IServiceCollection services, decimal latitude, decimal longitude)
31+
{
32+
services.AddNetDaemonScheduler();
33+
services.AddScoped<ISolarCalendar>((services) => new SolarCalendar(new Coordinates(latitude, longitude)));
34+
services.AddScoped<ISunEventScheduler, SunEventScheduler>();
35+
return services;
36+
}
37+
}

src/Extensions/NetDaemon.Extensions.Scheduling/NetDaemon.Extensions.Scheduling.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
<PrivateAssets>all</PrivateAssets>
4040
</PackageReference>
4141
<PackageReference Include="System.Reactive" Version="6.1.0" />
42+
<PackageReference Include="SolarCalculator" Version="3.6.0" />
4243
</ItemGroup>
4344
<PropertyGroup>
4445
<CodeAnalysisRuleSet>..\..\..\.linting\roslynator.ruleset</CodeAnalysisRuleSet>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
namespace NetDaemon.Extensions.Scheduler.SunEvents;
2+
internal record Coordinates(decimal Latitude, decimal Longitude);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Runtime.CompilerServices;
4+
using System.Text;
5+
6+
[assembly: InternalsVisibleTo("NetDaemon.Extensions.Scheduling.Tests")]
7+
namespace NetDaemon.Extensions.Scheduler.SunEvents
8+
{
9+
internal interface ISolarCalendar
10+
{
11+
DateTimeOffset Sunset { get; }
12+
DateTimeOffset Sunrise { get; }
13+
DateTimeOffset Dusk { get; }
14+
DateTimeOffset Dawn { get; }
15+
}
16+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
5+
namespace NetDaemon.Extensions.Scheduler.SunEvents
6+
{
7+
/// <summary>
8+
/// Provides scheduling capability based on sun events
9+
/// </summary>
10+
public interface ISunEventScheduler
11+
{
12+
/// <summary>
13+
/// Runs at action at Sunset based on configured coordinates
14+
/// </summary>
15+
/// <param name="action">Action to run</param>
16+
IDisposable RunAtSunset(Action action);
17+
18+
/// <summary>
19+
/// Runs at action at Dawn (Civil) based on configured coordinates
20+
/// </summary>
21+
/// <param name="action">Action to run</param>
22+
IDisposable RunAtDawn(Action action);
23+
24+
/// <summary>
25+
/// Runs at action at Sunrise based on configured coordinates
26+
/// </summary>
27+
/// <param name="action">Action to run</param>
28+
IDisposable RunAtSunrise(Action action);
29+
30+
/// <summary>
31+
/// Runs at action at Dusk (Civil) based on configured coordinates
32+
/// </summary>
33+
/// <param name="action">Action to run</param>
34+
IDisposable RunAtDusk(Action action);
35+
}
36+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using Innovative.SolarCalculator;
2+
3+
namespace NetDaemon.Extensions.Scheduler.SunEvents;
4+
5+
internal class SolarCalendar : ISolarCalendar
6+
{
7+
private readonly SolarTimes _cachedCalculator;
8+
9+
public SolarCalendar(Coordinates coordinates)
10+
{
11+
_cachedCalculator = new SolarTimes(DateTime.Now, coordinates.Latitude, coordinates.Longitude);
12+
}
13+
14+
private SolarTimes SolarCalculator
15+
{
16+
get
17+
{
18+
_cachedCalculator.ForDate = DateTime.Now;
19+
return _cachedCalculator;
20+
}
21+
}
22+
23+
public DateTimeOffset Sunset => SolarCalculator.Sunset;
24+
25+
public DateTimeOffset Sunrise => SolarCalculator.Sunrise;
26+
27+
public DateTimeOffset Dusk => SolarCalculator.DuskCivil;
28+
29+
public DateTimeOffset Dawn => SolarCalculator.DawnCivil;
30+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Reactive.Concurrency;
6+
using System.Runtime.CompilerServices;
7+
using System.Text;
8+
9+
[assembly: InternalsVisibleTo("NetDaemon.Extensions.Scheduling.Tests")]
10+
11+
namespace NetDaemon.Extensions.Scheduler.SunEvents;
12+
13+
internal sealed class SunEventScheduler : ISunEventScheduler
14+
{
15+
private readonly IScheduler _reactiveScheduler;
16+
private readonly ISolarCalendar _solarCalendar;
17+
18+
public SunEventScheduler(ISolarCalendar solarCalendar, IScheduler reactiveScheduler)
19+
{
20+
_reactiveScheduler = reactiveScheduler;
21+
_solarCalendar = solarCalendar;
22+
}
23+
24+
internal IDisposable RunAtSunEvent(Func<DateTimeOffset> getSunEventTime, Action action)
25+
{
26+
var todaysSunEvent = getSunEventTime().ToLocalTime();
27+
var now = _reactiveScheduler.Now.ToLocalTime();
28+
var tomorrow = now.Date.AddDays(1);
29+
30+
//Only schedule if the sun event is still going to occur today, the cron schedule will take over from tomorrow
31+
if (todaysSunEvent > now && todaysSunEvent < tomorrow)
32+
{
33+
_reactiveScheduler.Schedule(todaysSunEvent, action);
34+
}
35+
36+
return _reactiveScheduler.ScheduleCron("0 0 * * *", () =>
37+
{
38+
_reactiveScheduler.Schedule(getSunEventTime(), action);
39+
});
40+
}
41+
42+
/// <inheritdoc/>
43+
public IDisposable RunAtSunset(Action action)
44+
{
45+
return RunAtSunEvent(() => _solarCalendar.Sunset, action);
46+
}
47+
48+
/// <inheritdoc/>
49+
public IDisposable RunAtDawn(Action action)
50+
{
51+
return RunAtSunEvent(() => _solarCalendar.Dawn, action);
52+
}
53+
54+
/// <inheritdoc/>
55+
public IDisposable RunAtSunrise(Action action)
56+
{
57+
return RunAtSunEvent(() => _solarCalendar.Sunrise, action);
58+
}
59+
60+
/// <inheritdoc/>
61+
public IDisposable RunAtDusk(Action action)
62+
{
63+
return RunAtSunEvent(() => _solarCalendar.Dusk, action);
64+
}
65+
}

0 commit comments

Comments
 (0)