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
Original file line number Diff line number Diff line change
@@ -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<ISolarCalendar>();

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<ISolarCalendar>();

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<ISolarCalendar>();

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<ISolarCalendar>();
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<ISolarCalendar>();
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<ISolarCalendar>();
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<ISolarCalendar>();
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -52,4 +52,4 @@ void EcecuteAndReschedule()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -19,4 +20,18 @@ public static IServiceCollection AddNetDaemonScheduler(this IServiceCollection s
services.AddScoped<IScheduler>(s => new DisposableScheduler(DefaultScheduler.Instance.WrapWithLogger(s.GetRequiredService<ILogger<IScheduler>>())));
return services;
}
}

/// <summary>
/// Adds sun event scheduling capabilities through dependency injection
/// </summary>
/// <param name="latitude">Latitude of location to use for sun event scheduling</param>
/// <param name="longitude">Longitude of location to use for sun event scheduling</param>
/// <param name="services">Provided service collection</param>
public static IServiceCollection AddSunEventScheduler(this IServiceCollection services, decimal latitude, decimal longitude)
{
services.AddNetDaemonScheduler();
services.AddScoped<ISolarCalendar>((services) => new SolarCalendar(new Coordinates(latitude, longitude)));
services.AddScoped<ISunEventScheduler, SunEventScheduler>();
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Reactive" Version="6.1.0" />
<PackageReference Include="SolarCalculator" Version="3.6.0" />
</ItemGroup>
<PropertyGroup>
<CodeAnalysisRuleSet>..\..\..\.linting\roslynator.ruleset</CodeAnalysisRuleSet>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
namespace NetDaemon.Extensions.Scheduler.SunEvents;
internal record Coordinates(decimal Latitude, decimal Longitude);
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace NetDaemon.Extensions.Scheduler.SunEvents
{
/// <summary>
/// Provides scheduling capability based on sun events
/// </summary>
public interface ISunEventScheduler
{
/// <summary>
/// Runs at action at Sunset based on configured coordinates
/// </summary>
/// <param name="action">Action to run</param>
IDisposable RunAtSunset(Action action);

/// <summary>
/// Runs at action at Dawn (Civil) based on configured coordinates
/// </summary>
/// <param name="action">Action to run</param>
IDisposable RunAtDawn(Action action);

/// <summary>
/// Runs at action at Sunrise based on configured coordinates
/// </summary>
/// <param name="action">Action to run</param>
IDisposable RunAtSunrise(Action action);

/// <summary>
/// Runs at action at Dusk (Civil) based on configured coordinates
/// </summary>
/// <param name="action">Action to run</param>
IDisposable RunAtDusk(Action action);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<DateTimeOffset> 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);
});
}

/// <inheritdoc/>
public IDisposable RunAtSunset(Action action)
{
return RunAtSunEvent(() => _solarCalendar.Sunset, action);
}

/// <inheritdoc/>
public IDisposable RunAtDawn(Action action)
{
return RunAtSunEvent(() => _solarCalendar.Dawn, action);
}

/// <inheritdoc/>
public IDisposable RunAtSunrise(Action action)
{
return RunAtSunEvent(() => _solarCalendar.Sunrise, action);
}

/// <inheritdoc/>
public IDisposable RunAtDusk(Action action)
{
return RunAtSunEvent(() => _solarCalendar.Dusk, action);
}
}
Loading