Skip to content

Commit

Permalink
Auto close the roof at sunrise
Browse files Browse the repository at this point in the history
  • Loading branch information
alexhelms committed Mar 6, 2024
1 parent d763253 commit 52ae4f3
Show file tree
Hide file tree
Showing 25 changed files with 575 additions and 97 deletions.
164 changes: 164 additions & 0 deletions Obspi.Tests/AutoRoofCloseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Obspi.Commands;
using Obspi.Services;

namespace Obspi.Tests;

public class AutoRoofCloseTests : IAsyncDisposable
{
private const int PhoenixUtcOffset = -7;

private readonly ServiceProvider _serviceProvider;
private readonly AsyncServiceScope _scope;

private readonly FakeTimeProvider _timeProvider;
private readonly IObservatory _observatory = Mock.Of<IObservatory>();
private readonly INotificationService _notifications = Mock.Of<INotificationService>();


public AutoRoofCloseTests()
{
// Create the time provider and set it to midnight phoenix time
_timeProvider = new FakeTimeProvider();
_timeProvider.SetLocalTimeZone(TZConvert.GetTimeZoneInfo("America/Phoenix"));

// Mock EnqueueCommand to fake the cmd execution
Mock.Get(_observatory)
.Setup(x => x.EnqueueCommand(It.IsAny<Command>(), It.IsAny<CancellationToken>()))
.Callback<Command, CancellationToken>((cmd, _) => cmd.SetComplete());

var services = new ServiceCollection();
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddTransient<ILogger<AutoRoofCloseHostedService>>(_ => Mock.Of<ILogger<AutoRoofCloseHostedService>>());
services.AddScoped<AutoRoofCloseHostedService>();
services.AddScoped<IObservatory>(_ => _observatory);
services.AddScoped<INotificationService>(_ => _notifications);
_serviceProvider = services.BuildServiceProvider();
_scope = _serviceProvider.CreateAsyncScope();
}

public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _serviceProvider.DisposeAsync();
}

private void SetTimeToSunriseWithOffset(int minuteOffset)
{
var currentMonth = 3;
var currentTime = AutoRoofCloseHostedService.SunRiseTable[currentMonth].AddMinutes(minuteOffset);
var utcNow = new DateTimeOffset(new DateOnly(2024, currentMonth, 2), currentTime.AddHours(-PhoenixUtcOffset), TimeSpan.Zero);
_timeProvider.SetUtcNow(utcNow);
}

[Fact]
public async Task RoofAlreadyClosed_DoesNothing()
{
Mock.Get(_observatory).Setup(x => x.IsRoofClosed).Returns(true);

var service = _scope.ServiceProvider.GetRequiredService<AutoRoofCloseHostedService>();
await service.OnExecuteTest(_scope, CancellationToken.None);

Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MessagePriority>()), Times.Never);
Mock.Get(_observatory)
.Verify(x => x.EnqueueCommand(It.IsAny<Command>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task RoofOpen_BeforeSunrise_DoesNothing()
{
SetTimeToSunriseWithOffset(-1);

var service = _scope.ServiceProvider.GetRequiredService<AutoRoofCloseHostedService>();
await service.OnExecuteTest(_scope, CancellationToken.None);

Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MessagePriority>()), Times.Never);
Mock.Get(_observatory)
.Verify(x => x.EnqueueCommand(It.IsAny<Command>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task RoofOpen_AfterSunrise_ClosesRoof()
{
Mock.Get(_observatory).Setup(x => x.IsRoofSafeToMove).Returns(true);

SetTimeToSunriseWithOffset(1);

var service = _scope.ServiceProvider.GetRequiredService<AutoRoofCloseHostedService>();
await service.OnExecuteTest(_scope, CancellationToken.None);

Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MessagePriority>()), Times.Never);
Mock.Get(_observatory)
.Verify(x => x.EnqueueCommand(It.IsAny<CloseRoofCommand>(), It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task RoofOpen_AfterSunrise_MultipleAttempts_SendNormalNotification()
{
var service = _scope.ServiceProvider.GetRequiredService<AutoRoofCloseHostedService>();
SetTimeToSunriseWithOffset(-1);

for (int i = 0; i < 41; i++)
{
await service.OnExecuteTest(_scope, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
}

Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), MessagePriority.Normal), Times.Exactly(2));
}

[Fact]
public async Task RoofOpen_AfterSunrise_MultipleAttempts_SendNormalAndEmergencyNotification()
{
var service = _scope.ServiceProvider.GetRequiredService<AutoRoofCloseHostedService>();
SetTimeToSunriseWithOffset(-1);

for (int i = 0; i < 103; i++)
{
await service.OnExecuteTest(_scope, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
}

Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), MessagePriority.Normal), Times.Exactly(12));
Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), MessagePriority.Emergency), Times.Exactly(3));
}

[Fact]
public async Task RoofOpen_AfterSunrise_MultipleAttempts_SendNormalAndEmergencyNotification_RoofEventuallyIsClosed()
{
var service = _scope.ServiceProvider.GetRequiredService<AutoRoofCloseHostedService>();
SetTimeToSunriseWithOffset(-1);

for (int i = 0; i < 103; i++)
{
await service.OnExecuteTest(_scope, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
}

Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), MessagePriority.Normal), Times.Exactly(12));
Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), MessagePriority.Emergency), Times.Exactly(3));
Mock.Get(_observatory)
.Verify(x => x.EnqueueCommand(It.IsAny<CloseRoofCommand>(), It.IsAny<CancellationToken>()), Times.Never);

Mock.Get(_notifications).Reset();
Mock.Get(_observatory).Setup(x => x.IsRoofSafeToMove).Returns(true);

await service.OnExecuteTest(_scope, CancellationToken.None);

Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), MessagePriority.Normal), Times.Never);
Mock.Get(_notifications)
.Verify(x => x.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), MessagePriority.Emergency), Times.Never);
Mock.Get(_observatory)
.Verify(x => x.EnqueueCommand(It.IsAny<CloseRoofCommand>(), It.IsAny<CancellationToken>()), Times.Once);
}
}
4 changes: 4 additions & 0 deletions Obspi.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
global using FluentAssertions;
global using Microsoft.Extensions.Time.Testing;
global using Moq;
global using TimeZoneConverter;
38 changes: 38 additions & 0 deletions Obspi.Tests/Obspi.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="TimeZoneConverter" Version="6.1.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Obspi\Obspi.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions Obspi.sln
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
variables.env = variables.env
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Obspi.Tests", "Obspi.Tests\Obspi.Tests.csproj", "{897CD824-31E5-41A6-9EDF-9066709C1FAC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -29,6 +31,10 @@ Global
{1B37D776-0455-4477-B64F-0FFB441E13CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1B37D776-0455-4477-B64F-0FFB441E13CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B37D776-0455-4477-B64F-0FFB441E13CC}.Release|Any CPU.Build.0 = Release|Any CPU
{897CD824-31E5-41A6-9EDF-9066709C1FAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{897CD824-31E5-41A6-9EDF-9066709C1FAC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{897CD824-31E5-41A6-9EDF-9066709C1FAC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{897CD824-31E5-41A6-9EDF-9066709C1FAC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
6 changes: 4 additions & 2 deletions Obspi/Commands/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ public abstract class Command

public CommandState State { get; private set; } = CommandState.Created;

protected abstract Task Execute(Observatory observatory, CancellationToken token);
protected abstract Task Execute(IObservatory observatory, CancellationToken token);

public async Task Run(Observatory observatory, CancellationToken token)
public async Task Run(IObservatory observatory, CancellationToken token)
{
try
{
Expand All @@ -31,6 +31,8 @@ public async Task Run(Observatory observatory, CancellationToken token)
}
}

internal void SetComplete() => _completeEvent.Set();

public void Wait(CancellationToken token = default) => _completeEvent.Wait(token);

public Task WaitAsync(CancellationToken token = default) => _completeEvent.WaitAsync(token);
Expand Down
16 changes: 8 additions & 8 deletions Obspi/Commands/OpenRoofCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ public abstract class OpenCloseRoofCommand : Command
{
private readonly INotificationService _notificationService;

public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(60);

public Action OnTimeout { get; init; } = () => { };

public abstract Action<Observatory, bool> SetOutput { get; }
public abstract Action<IObservatory, bool> SetOutput { get; }

public abstract Func<Observatory, bool> GetInput { get; }
public abstract Func<IObservatory, bool> GetInput { get; }

public abstract string Description { get; }

Expand All @@ -29,7 +29,7 @@ public OpenCloseRoofCommand(INotificationService notificationService)
_notificationService = notificationService;
}

protected override async Task Execute(Observatory observatory, CancellationToken token)
protected override async Task Execute(IObservatory observatory, CancellationToken token)
{
if (!observatory.IsRoofSafeToMove)
throw new InvalidOperationException("Not safe to move roof");
Expand Down Expand Up @@ -100,8 +100,8 @@ await _notificationService.SendMessageAsync(

public class OpenRoofCommand : OpenCloseRoofCommand
{
public override Action<Observatory, bool> SetOutput { get; } = (obs, value) => obs.IO.Outputs.RoofOpen = value;
public override Func<Observatory, bool> GetInput { get; } = obs => obs.IO.Inputs.RoofOpened;
public override Action<IObservatory, bool> SetOutput { get; } = (obs, value) => obs.IO.Outputs.RoofOpen = value;
public override Func<IObservatory, bool> GetInput { get; } = obs => obs.IO.Inputs.RoofOpened;
public override string Description => "Open";
public override string Verb => "Opening";
public override string SuccessMessage => "Roof is now open.";
Expand All @@ -116,8 +116,8 @@ public OpenRoofCommand(INotificationService notificationService)

public class CloseRoofCommand : OpenCloseRoofCommand
{
public override Action<Observatory, bool> SetOutput { get; } = (obs, value) => obs.IO.Outputs.RoofClose = value;
public override Func<Observatory, bool> GetInput { get; } = obs => obs.IO.Inputs.RoofClosed;
public override Action<IObservatory, bool> SetOutput { get; } = (obs, value) => obs.IO.Outputs.RoofClose = value;
public override Func<IObservatory, bool> GetInput { get; } = obs => obs.IO.Inputs.RoofClosed;
public override string Description => "Closed";
public override string Verb => "Closing";
public override string SuccessMessage => "Roof is now closed.";
Expand Down
2 changes: 1 addition & 1 deletion Obspi/Commands/PulsedIoCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public PulsedIoCommand(TimeSpan delay, bool value, Expression<Func<IObspiOutputs
Selector = selector;
}

protected override async Task Execute(Observatory observatory, CancellationToken token)
protected override async Task Execute(IObservatory observatory, CancellationToken token)
{
var prop = Selector.GetPropertyInfo();
prop.SetValue(observatory.IO.Outputs, Value);
Expand Down
4 changes: 2 additions & 2 deletions Obspi/Controllers/DiagnosticsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ namespace Obspi.Controllers;
[Route("api/diagnostics")]
public class DiagnosticsController : ControllerBase
{
private readonly Observatory _observatory;
private readonly IObservatory _observatory;

public DiagnosticsController(Observatory observatory)
public DiagnosticsController(IObservatory observatory)
{
_observatory = observatory;
}
Expand Down
4 changes: 2 additions & 2 deletions Obspi/Controllers/IoController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ public class IoController : ControllerBase
{
private readonly ILogger<IoController> _logger;
private readonly IOptions<I2cDevicesOptions> _i2cOptions;
private readonly Observatory _observatory;
private readonly IObservatory _observatory;

public IoController(
ILogger<IoController> logger,
IOptions<I2cDevicesOptions> i2cOptions,
Observatory observatory)
IObservatory observatory)
{
_logger = logger;
_i2cOptions = i2cOptions;
Expand Down
4 changes: 2 additions & 2 deletions Obspi/Controllers/ObservatoryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ namespace Obspi.Controllers;
[Route("api/observatory")]
public class ObservatoryController : ControllerBase
{
private readonly Observatory _observatory;
private readonly IObservatory _observatory;
private readonly WeatherService _weather;
private readonly INotificationService _notificationService;

public ObservatoryController(Observatory observatory, WeatherService weather, INotificationService notificationService)
public ObservatoryController(IObservatory observatory, WeatherService weather, INotificationService notificationService)
{
_observatory = observatory;
_weather = weather;
Expand Down
7 changes: 6 additions & 1 deletion Obspi/Devices/CloudWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ public DateTime Timestamp
public double RawIR { get; set; }
}

public class CloudWatcher
public interface ICloudWatcher
{
AagCloudWatcherData MostRecentData { get; set; }
}

public class CloudWatcher : ICloudWatcher
{
public AagCloudWatcherData MostRecentData { get; set; } = new();

Expand Down
3 changes: 3 additions & 0 deletions Obspi/Devices/IObspiInputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

public interface IObspiInputs
{
bool? GetValueOrNull(string name);
IObspiInputs ToSnapshot();
List<string> Names { get; }
bool RoofOpened { get; set; }
bool RoofClosed { get; set; }
bool CloudWatcherUnsafe { get; set; }
Expand Down
4 changes: 4 additions & 0 deletions Obspi/Devices/IObspiOutputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

public interface IObspiOutputs
{
bool? GetValueOrNull(string name);
bool TrySetValue(string name, bool state);
IObspiOutputs ToSnapshot();
List<string> Names { get; }
bool Suicide { get; set; }
bool RoofOpen { get; set; }
bool RoofClose { get; set; }
Expand Down
Loading

0 comments on commit 52ae4f3

Please sign in to comment.