diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 27835ef..c1c16f6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -45,6 +45,7 @@ jobs: publish: runs-on: ubuntu-latest needs: build + if: github.event_name == 'push' steps: - name: Download NuGet packages diff --git a/src/TianWen.Lib.CLI/ConsoleHost.cs b/src/TianWen.Lib.CLI/ConsoleHost.cs index aec6190..18e0cc2 100644 --- a/src/TianWen.Lib.CLI/ConsoleHost.cs +++ b/src/TianWen.Lib.CLI/ConsoleHost.cs @@ -122,22 +122,44 @@ public async ValueTask RenderImageAsync(IMagickImage image) Console.Write(renderer.Render(image, widthScale)); } - public async Task> ListProfilesAsync(CancellationToken cancellationToken) + public async Task> ListAllDevicesAsync(bool forceDiscovery, CancellationToken cancellationToken) { TimeSpan discoveryTimeout; #if DEBUG - discoveryTimeout = TimeSpan.FromHours(1); + discoveryTimeout = TimeSpan.FromMinutes(15); #else discoveryTimeout = TimeSpan.FromSeconds(25); #endif using var cts = new CancellationTokenSource(discoveryTimeout, External.TimeProvider); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); - if (await deviceManager.CheckSupportAsync(linked.Token)) + await deviceManager.CheckSupportAsync(linked.Token); + + if (forceDiscovery) + { + await deviceManager.DiscoverAsync(linked.Token); + } + + return [.. deviceManager.RegisteredDeviceTypes.SelectMany(deviceManager.RegisteredDevices)]; + } + + public async Task> ListDevicesAsync(DeviceType deviceType, bool forceDiscovery, CancellationToken cancellationToken) + where TDevice : DeviceBase + { + TimeSpan discoveryTimeout; +#if DEBUG + discoveryTimeout = TimeSpan.FromMinutes(15); +#else + discoveryTimeout = TimeSpan.FromSeconds(25); +#endif + using var cts = new CancellationTokenSource(discoveryTimeout, External.TimeProvider); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); + + if (await deviceManager.CheckSupportAsync(linked.Token) && forceDiscovery) { - await deviceManager.DiscoverOnlyDeviceType(DeviceType.Profile, linked.Token); + await deviceManager.DiscoverOnlyDeviceType(deviceType, linked.Token); } - return [..deviceManager.RegisteredDevices(DeviceType.Profile).OfType()]; + return [.. deviceManager.RegisteredDevices(deviceType).OfType()]; } } \ No newline at end of file diff --git a/src/TianWen.Lib.CLI/DeviceSubCommand.cs b/src/TianWen.Lib.CLI/DeviceSubCommand.cs new file mode 100644 index 0000000..4013e79 --- /dev/null +++ b/src/TianWen.Lib.CLI/DeviceSubCommand.cs @@ -0,0 +1,54 @@ +using System.CommandLine; +using TianWen.Lib.Devices; + +namespace TianWen.Lib.CLI; + +internal class DeviceSubCommand(IConsoleHost consoleHost) +{ + private bool _discoveryRan; + + public Command Build() + { + var discoverCommand = new Command("discover", "Discover all connected devices"); + discoverCommand.SetAction(DiscoverDevicesAsync); + + var listCommand = new Command("list", "List all connected devices"); + listCommand.SetAction(ListDevicesAsync); + + return new Command("device", "Manage connected devices") + { + Subcommands = { + discoverCommand, + listCommand + } + }; + } + + internal async Task DiscoverDevicesAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + foreach (var device in await DoListDevicesExceptProfiles(true, cancellationToken)) + { + Console.WriteLine(); + Console.WriteLine(device.ToString()); + } + } + + internal async Task ListDevicesAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + foreach (var device in await DoListDevicesExceptProfiles(false, cancellationToken)) + { + Console.WriteLine(); + Console.WriteLine(device.ToString()); + } + } + + private async Task> DoListDevicesExceptProfiles(bool forceDiscovery, CancellationToken cancellationToken) + { + var result = (await consoleHost.ListAllDevicesAsync(forceDiscovery || !_discoveryRan, cancellationToken)) + .Where(d => d.DeviceType is not DeviceType.Profile); + + _discoveryRan = true; + + return result; + } +} diff --git a/src/TianWen.Lib.CLI/IConsoleHost.cs b/src/TianWen.Lib.CLI/IConsoleHost.cs index f8f378e..efe9118 100644 --- a/src/TianWen.Lib.CLI/IConsoleHost.cs +++ b/src/TianWen.Lib.CLI/IConsoleHost.cs @@ -1,6 +1,5 @@ using ImageMagick; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using TianWen.Lib.Devices; namespace TianWen.Lib.CLI; @@ -9,10 +8,13 @@ public interface IConsoleHost { Task HasSixelSupportAsync(); - Task> ListProfilesAsync(CancellationToken cancellationToken); + Task> ListDevicesAsync(DeviceType deviceType, bool forceDiscovery, CancellationToken cancellationToken) + where TDevice : DeviceBase; + + Task> ListAllDevicesAsync(bool forceDiscovery, CancellationToken cancellationToken); IDeviceUriRegistry DeviceUriRegistry { get; } - + IHostApplicationLifetime ApplicationLifetime { get; } IExternal External { get; } diff --git a/src/TianWen.Lib.CLI/ProfileSubCommand.cs b/src/TianWen.Lib.CLI/ProfileSubCommand.cs index c63e4a1..93d309b 100644 --- a/src/TianWen.Lib.CLI/ProfileSubCommand.cs +++ b/src/TianWen.Lib.CLI/ProfileSubCommand.cs @@ -46,7 +46,7 @@ internal async Task CreateProfileAsync(ParseResult parseResult, CancellationToke internal async Task ListProfilesAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var allProfiles = await consoleHost.ListProfilesAsync(cancellationToken); + var allProfiles = await ListProfilesAsync(cancellationToken); var selectedProfile = parseResult.GetSelected(allProfiles, selectedProfileOption); @@ -69,7 +69,7 @@ internal async Task DeleteProfileAsync(ParseResult parseResult, CancellationToke { var profileNameOrId = parseResult.GetRequiredValue(profileNameOrIdArg); - var profiles = await consoleHost.ListProfilesAsync(cancellationToken); + var profiles = await ListProfilesAsync(cancellationToken); Profile profileToDelete; if (Guid.TryParse(profileNameOrId, out var profileId)) { @@ -111,6 +111,9 @@ internal async Task DeleteProfileAsync(ParseResult parseResult, CancellationToke Console.WriteLine($"Deleted profile '{profileToDelete.DisplayName}' ({profileToDelete.ProfileId})"); // refresh cache - var profilesAfterDelete = await consoleHost.ListProfilesAsync(cancellationToken); + var profilesAfterDelete = await ListProfilesAsync(cancellationToken); } + + private Task> ListProfilesAsync(CancellationToken cancellationToken) => + consoleHost.ListDevicesAsync(DeviceType.Profile, true, cancellationToken); } diff --git a/src/TianWen.Lib.CLI/Program.cs b/src/TianWen.Lib.CLI/Program.cs index a34524b..90cc17f 100644 --- a/src/TianWen.Lib.CLI/Program.cs +++ b/src/TianWen.Lib.CLI/Program.cs @@ -10,7 +10,7 @@ Console.InputEncoding = Encoding.UTF8; Console.OutputEncoding = Encoding.UTF8; -Pastel.ConsoleExtensions.Enable(); +ConsoleExtensions.Enable(); var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = args, DisableDefaults = true }); builder.Services @@ -57,7 +57,8 @@ Options = { selectedProfileOption }, Subcommands = { - new ProfileSubCommand(consoleHost, selectedProfileOption).Build() + new ProfileSubCommand(consoleHost, selectedProfileOption).Build(), + new DeviceSubCommand(consoleHost).Build() } }; @@ -76,132 +77,4 @@ await host.StopAsync(); -await host.WaitForShutdownAsync(); -/* -var argIdx = 0; -var mountDeviceId = args.Length > argIdx ? args[argIdx++] : "ASCOM.DeviceHub.Telescope"; -var cameraDeviceId = args.Length > argIdx ? args[argIdx++] : "ASCOM.Simulator.Camera"; -var coverDeviceId = args.Length > argIdx ? args[argIdx++] : "ASCOM.Simulator.CoverCalibrator"; -var focuserDeviceId = args.Length > argIdx ? args[argIdx++] : "ASCOM.Simulator.Focuser"; -var expDuration = TimeSpan.FromSeconds(args.Length > 2 ? int.Parse(args[argIdx++]) : 10); -var outputFolder = Directory.CreateDirectory(args.Length > 3 ? args[argIdx++] : Path.Combine(Directory.GetCurrentDirectory(), "TianWen.Lib.TestCli", "Light")); -IExternal external = new ConsoleOutput(outputFolder); - - -var observations = new List -{ - // new Observation(new Target(19.5, -20, "Mercury", CatalogIndex.Mercury), DateTimeOffset.Now, TimeSpan.FromMinutes(100), false, TimeSpan.FromSeconds(20)), - new Observation(new Target(5.5877777777777773, -5.389444444444444, "Orion Nebula", CatalogUtils.TryGetCleanedUpCatalogName("M42", out var catIdx) ? catIdx : null), DateTimeOffset.Now, TimeSpan.FromMinutes(100), false, TimeSpan.FromSeconds(20)) -}; - -var guiderDevice = new GuiderDevice(DeviceType.PHD2, "localhost/1", ""); - -using var profile = new AscomProfile(); -var allCameras = profile.RegisteredDevices(DeviceType.Camera); -var cameraDevice = allCameras.FirstOrDefault(e => e.DeviceId == cameraDeviceId); - -var allMounts = profile.RegisteredDevices(DeviceType.Telescope); -var mountDevice = allMounts.FirstOrDefault(e => e.DeviceId == mountDeviceId); - -var allCovers = profile.RegisteredDevices(DeviceType.CoverCalibrator); -var coverDevice = allCovers.FirstOrDefault(e => e.DeviceId == coverDeviceId); - -var allFocusers = profile.RegisteredDevices(DeviceType.Focuser); -var focuserDevice = allFocusers.FirstOrDefault(e => e.DeviceId == focuserDeviceId); - -Mount mount; -if (mountDevice is not null) -{ - mount = new Mount(mountDevice, external); - external.LogInfo($"Found mount {mountDevice.DisplayName}, using {mount.Driver.DriverInfo ?? mount.Driver.GetType().Name}"); -} -else -{ - external.LogError($"Could not connect to mount {mountDevice?.ToString()}"); - return 1; -} - -Guider guider; -if (guiderDevice is not null) -{ - guider = new Guider(guiderDevice, external); - - if (guider.Driver.TryGetActiveProfileName(out var activeProfileName)) - { - external.LogInfo($"Connected to {guider.Device.DeviceType} guider profile {activeProfileName}"); - } - else - { - external.LogInfo($"Connected to {guider.Device.DeviceType} guider at {guider.Device.DeviceId}"); - } -} -else -{ - external.LogError("No guider was specified, aborting."); - return 1; -} - -Camera camera; -if (cameraDevice is not null) -{ - camera = new Camera(cameraDevice, external); -} -else -{ - external.LogError("Could not connect to camera"); - return 1; -} - -Cover? cover; -if (coverDevice is not null) -{ - cover = new Cover(coverDevice, external); -} -else -{ - cover = null; -} - -Focuser? focuser; -if (focuserDevice is not null) -{ - focuser = new Focuser(focuserDevice, external); -} -else -{ - focuser = null; -} - -using var setup = new Setup( - mount, - guider, - new GuiderFocuser(), - new Telescope("Sim Scope", 250, camera, cover, focuser, new FocusDirection(false, true), null, null) -); - -var sessionConfiguration = new SessionConfiguration( - SetpointCCDTemperature: new SetpointTemp(10, SetpointTempKind.Normal), - CooldownRampInterval: TimeSpan.FromSeconds(20), - WarmupRampInterval: TimeSpan.FromSeconds(30), - MinHeightAboveHorizon: 15, - DitherPixel: 30d, - SettlePixel: 0.3d, - DitherEveryNthFrame: 3, - SettleTime: TimeSpan.FromSeconds(30), - GuidingTries: 3 -); - -// TODO: implement DI -var analyser = new ImageAnalyser(); -var plateSolver = new CombinedPlateSolver(new AstapPlateSolver(), new AstrometryNetPlateSolverMultiPlatform(), new AstrometryNetPlateSolverUnix()); -if (!await plateSolver.CheckSupportAsync(cts.Token)) -{ - external.LogError("No proper plate solver configured, aborting!"); -} - -var session = new Session(setup, sessionConfiguration, analyser, plateSolver, external, observations); - -session.Run(cts.Token); - -return 0; -*/ \ No newline at end of file +await host.WaitForShutdownAsync(); \ No newline at end of file diff --git a/src/TianWen.Lib.CLI/Properties/launchSettings.json b/src/TianWen.Lib.CLI/Properties/launchSettings.json index 3532edd..6acf168 100644 --- a/src/TianWen.Lib.CLI/Properties/launchSettings.json +++ b/src/TianWen.Lib.CLI/Properties/launchSettings.json @@ -6,7 +6,7 @@ }, "Local": { "commandName": "Project", - "commandLineArgs": "profile list" + "commandLineArgs": "device list" } } } \ No newline at end of file diff --git a/src/TianWen.Lib.Tests/AscomDeviceTests.cs b/src/TianWen.Lib.Tests/AscomDeviceTests.cs index 74c18c3..baf1b36 100644 --- a/src/TianWen.Lib.Tests/AscomDeviceTests.cs +++ b/src/TianWen.Lib.Tests/AscomDeviceTests.cs @@ -8,7 +8,6 @@ using TianWen.Lib.Devices; using TianWen.Lib.Devices.Ascom; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -20,7 +19,7 @@ public async Task TestWhenPlatformIsWindowsThatDeviceTypesAreReturned() var deviceIterator = new AscomDeviceIterator(); var types = deviceIterator.RegisteredDeviceTypes; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && await deviceIterator.CheckSupportAsync()) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && await deviceIterator.CheckSupportAsync(TestContext.Current.CancellationToken)) { types.ShouldNotBeEmpty(); } @@ -30,14 +29,14 @@ public async Task TestWhenPlatformIsWindowsThatDeviceTypesAreReturned() } } - [SkippableTheory] + [Theory] [InlineData(DeviceType.Camera)] [InlineData(DeviceType.CoverCalibrator)] [InlineData(DeviceType.Focuser)] [InlineData(DeviceType.Switch)] public async Task GivenSimulatorDeviceTypeVersionAndNameAreReturned(DeviceType type) { - Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Debugger.IsAttached); + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Debugger.IsAttached, "Skipped as this test is only run when on Windows and debugger is attached"); var external = new FakeExternal(testOutputHelper); var deviceIterator = new AscomDeviceIterator(); @@ -59,12 +58,13 @@ public async Task GivenSimulatorDeviceTypeVersionAndNameAreReturned(DeviceType t } } - [SkippableFact] + [Fact] public async Task GivenAConnectedAscomSimulatorTelescopeWhenConnectedThenTrackingRatesArePopulated() { - Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Debugger.IsAttached, "Skipped as this test is only run when on Windows and debugger is attached"); + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Debugger.IsAttached, "Skipped as this test is only run when on Windows and debugger is attached"); // given + var cancellationToken = TestContext.Current.CancellationToken; var external = new FakeExternal(testOutputHelper); var deviceIterator = new AscomDeviceIterator(); var allTelescopes = deviceIterator.RegisteredDevices(DeviceType.Telescope); @@ -75,17 +75,18 @@ public async Task GivenAConnectedAscomSimulatorTelescopeWhenConnectedThenTrackin { await using (driver) { - await driver.DisconnectAsync(); + await driver.DisconnectAsync(cancellationToken); } } } - [SkippableFact] + [Fact] public async Task GivenAConnectedAscomSimulatorCameraWhenImageReadyThenItCanBeDownloaded() { - Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Debugger.IsAttached, "Skipped as this test is only run when on Windows and debugger is attached"); + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Debugger.IsAttached, "Skipped as this test is only run when on Windows and debugger is attached"); // given + var cancellationToken = TestContext.Current.CancellationToken; var external = new FakeExternal(testOutputHelper); var deviceIterator = new AscomDeviceIterator(); var allCameras = deviceIterator.RegisteredDevices(DeviceType.Camera); @@ -96,7 +97,7 @@ public async Task GivenAConnectedAscomSimulatorCameraWhenImageReadyThenItCanBeDo { await using (driver) { - await driver.ConnectAsync(); + await driver.ConnectAsync(cancellationToken); var startExposure = driver.StartExposure(TimeSpan.FromSeconds(0.1)); Thread.Sleep((int)TimeSpan.FromSeconds(0.5).TotalMilliseconds); @@ -112,7 +113,7 @@ public async Task GivenAConnectedAscomSimulatorCameraWhenImageReadyThenItCanBeDo image.BitDepth.ShouldBe(driver.BitDepth.ShouldNotBeNull()); image.MaxValue.ShouldBeGreaterThan(0f); image.MaxValue.ShouldBe(expectedMax); - var stars = await image.FindStarsAsync(snrMin: 10); + var stars = await image.FindStarsAsync(snrMin: 10, cancellationToken: cancellationToken); stars.Count.ShouldBeGreaterThan(0); } } diff --git a/src/TianWen.Lib.Tests/FakeExternal.cs b/src/TianWen.Lib.Tests/FakeExternal.cs index 5f6f506..bad8e84 100644 --- a/src/TianWen.Lib.Tests/FakeExternal.cs +++ b/src/TianWen.Lib.Tests/FakeExternal.cs @@ -1,4 +1,4 @@ -using Meziantou.Extensions.Logging.Xunit; +using Meziantou.Extensions.Logging.Xunit.v3; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; using System; @@ -13,7 +13,7 @@ using TianWen.Lib.Connections; using TianWen.Lib.Devices; using TianWen.Lib.Imaging; -using Xunit.Abstractions; +using Xunit; namespace TianWen.Lib.Tests; @@ -35,9 +35,12 @@ public FakeExternal(ITestOutputHelper testOutputHelper, DirectoryInfo? root = nu .CreateSubdirectory(Guid.NewGuid().ToString("D")); _outputFolder = _profileRoot.CreateSubdirectory("output"); - AppLogger = new XUnitLoggerProvider(testOutputHelper, false).CreateLogger("Test"); + + AppLogger = CreateLogger(testOutputHelper); } + public static ILogger CreateLogger(ITestOutputHelper testOutputHelper) => new XUnitLoggerProvider(testOutputHelper, false).CreateLogger("Test"); + public DirectoryInfo ProfileFolder => _profileRoot; public DirectoryInfo OutputFolder => _outputFolder; diff --git a/src/TianWen.Lib.Tests/FindBestFocusTests.cs b/src/TianWen.Lib.Tests/FindBestFocusTests.cs index 05b804f..af8a488 100644 --- a/src/TianWen.Lib.Tests/FindBestFocusTests.cs +++ b/src/TianWen.Lib.Tests/FindBestFocusTests.cs @@ -4,14 +4,12 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using TianWen.Lib.Astrometry.Focus; using TianWen.Lib.Stat; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -52,7 +50,9 @@ record FocusPoint(double Position, double Value, double Error); [InlineData("2025-12-18--02-26-57--9d0e769c-5847-470c-a25e-0e1de367e31e.json")] public async Task ReachSameResultFromSuccessfulNINARun(string file) { - var ninaResult = await JsonSerializer.DeserializeAsync(SharedTestData.OpenEmbeddedFileStream(file).ShouldNotBeNull(), _stringEnums); + var cancellationToken = TestContext.Current.CancellationToken; + + var ninaResult = await JsonSerializer.DeserializeAsync(SharedTestData.OpenEmbeddedFileStream(file).ShouldNotBeNull(), _stringEnums, cancellationToken); ninaResult.ShouldNotBeNull(); ninaResult.Fitting.ShouldBe(NinaFittingMethod.Hyperbolic); ninaResult.CalculatedFocusPoint.ShouldNotBeNull(); @@ -88,6 +88,7 @@ public async Task ReachSameResultFromSuccessfulNINARun(string file) public async Task HyperboleIsFoundFromActualImageRun(SampleKind kind, AggregationMethod aggregationMethod, int focusStart, int focusEndIncl, int focusStepSize, int sampleCount, int filterNo, float snrMin, int maxIterations, int expectedSolutionAfterSteps, int expectedMinStarCount) { // given + var cancellationToken = TestContext.Current.CancellationToken; var sampleMap = new MetricSampleMap(kind, aggregationMethod); // when @@ -97,9 +98,9 @@ public async Task HyperboleIsFoundFromActualImageRun(SampleKind kind, Aggregatio { var sampleName = $"fp{fp}-cs{cs}-ms{sampleCount}-fw{filterNo}"; var sw = Stopwatch.StartNew(); - var image = await SharedTestData.ExtractGZippedFitsImageAsync(sampleName); + var image = await SharedTestData.ExtractGZippedFitsImageAsync(sampleName, cancellationToken: cancellationToken); var extractImageElapsed = sw.ElapsedMilliseconds; - var stars = await image.FindStarsAsync(snrMin: snrMin); + var stars = await image.FindStarsAsync(snrMin: snrMin, cancellationToken: cancellationToken); var findStarsElapsed = sw.ElapsedMilliseconds - extractImageElapsed; var median = stars.MapReduceStarProperty(sampleMap.Kind, AggregationMethod.Median); var calcMedianElapsed = sw.ElapsedMilliseconds - findStarsElapsed; diff --git a/src/TianWen.Lib.Tests/FindStarsFromCameraImageTests.cs b/src/TianWen.Lib.Tests/FindStarsFromCameraImageTests.cs index f3a6534..86a7c1b 100644 --- a/src/TianWen.Lib.Tests/FindStarsFromCameraImageTests.cs +++ b/src/TianWen.Lib.Tests/FindStarsFromCameraImageTests.cs @@ -4,7 +4,6 @@ using TianWen.Lib.Devices; using TianWen.Lib.Imaging; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -21,6 +20,8 @@ public async Task GivenCameraImageDataWhenConvertingToImageThenStarsCanBeFound(i const int Height = 960; const BitDepth BitDepth = BitDepth.Int16; const int BlackLevel = 1; + + var cancellationToken = TestContext.Current.CancellationToken; var expTime = TimeSpan.FromSeconds(42); var fileName = $"image_data_snr-{snr_min}_stars-{expectedStars}"; var int16WxHData = await SharedTestData.ExtractGZippedImageData(fileName, Width, Height); @@ -29,7 +30,7 @@ public async Task GivenCameraImageDataWhenConvertingToImageThenStarsCanBeFound(i // when var imageData = Float32HxWImageData.FromWxHImageData(int16WxHData); var image = imageData.ToImage(BitDepth, BlackLevel, imageMeta); - var stars = await image.FindStarsAsync(snrMin: snr_min); + var stars = await image.FindStarsAsync(snrMin: snr_min, cancellationToken: cancellationToken); // then image.ShouldNotBeNull(); diff --git a/src/TianWen.Lib.Tests/FindStarsFromFitsFileTests.cs b/src/TianWen.Lib.Tests/FindStarsFromFitsFileTests.cs index 875bc75..9fb2d90 100644 --- a/src/TianWen.Lib.Tests/FindStarsFromFitsFileTests.cs +++ b/src/TianWen.Lib.Tests/FindStarsFromFitsFileTests.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Threading.Tasks; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -17,11 +16,11 @@ public class FindStarsFromFitsFileTests(ITestOutputHelper testOutputHelper) : Im public async Task GivenImageFileAndMinSNRWhenFindingStarsThenTheyAreFound(string name, float snrMin, int expectedStars, int? maxStars = null) { // given - var image = await SharedTestData.ExtractGZippedFitsImageAsync(name); + var image = await SharedTestData.ExtractGZippedFitsImageAsync(name, cancellationToken: TestContext.Current.CancellationToken); // when var sw = Stopwatch.StartNew(); - var actualStars = await image.FindStarsAsync(snrMin, maxStars ?? 500); + var actualStars = await image.FindStarsAsync(snrMin, maxStars ?? 500, cancellationToken: TestContext.Current.CancellationToken); _testOutputHelper.WriteLine("Testing image {0} took {1} ms", name, sw.ElapsedMilliseconds); // then diff --git a/src/TianWen.Lib.Tests/ImageAnalyserTests.cs b/src/TianWen.Lib.Tests/ImageAnalyserTests.cs index 8bf4dff..4eb775c 100644 --- a/src/TianWen.Lib.Tests/ImageAnalyserTests.cs +++ b/src/TianWen.Lib.Tests/ImageAnalyserTests.cs @@ -1,4 +1,4 @@ -using Xunit.Abstractions; +using Xunit; namespace TianWen.Lib.Tests; diff --git a/src/TianWen.Lib.Tests/MeadeLX200BasedMountTests.cs b/src/TianWen.Lib.Tests/MeadeLX200BasedMountTests.cs index 5168e7e..0a31b2c 100644 --- a/src/TianWen.Lib.Tests/MeadeLX200BasedMountTests.cs +++ b/src/TianWen.Lib.Tests/MeadeLX200BasedMountTests.cs @@ -6,7 +6,6 @@ using TianWen.Lib.Devices; using TianWen.Lib.Devices.Fake; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -18,17 +17,18 @@ public class MeadeLX200BasedMountTests(ITestOutputHelper outputHelper) public async Task GivenMountWhenConnectingItOpensSerialPort(double siteLat, double siteLong) { // given + var cancellationToken = TestContext.Current.CancellationToken; var device = new FakeDevice(DeviceType.Mount, 1, new NameValueCollection { ["latitude"] = Convert.ToString(siteLat), ["longitude"] = Convert.ToString(siteLong) }); var fakeExternal = new FakeExternal(outputHelper, null, null, null); await using var mount = new FakeMeadeLX200ProtocolMountDriver(device, fakeExternal); // when - await mount.ConnectAsync(); + await mount.ConnectAsync(cancellationToken); // then mount.Connected.ShouldBe(true); - mount.Alignment.ShouldBe(AlignmentMode.GermanPolar); - mount.Tracking.ShouldBe(false); + (await mount.GetAlignmentAsync(cancellationToken)).ShouldBe(AlignmentMode.GermanPolar); + (await mount.IsTrackingAsync(cancellationToken)).ShouldBe(false); } [Theory] @@ -37,6 +37,7 @@ public async Task GivenMountWhenConnectingItOpensSerialPort(double siteLat, doub public async Task GivenMountWhenConnectingAndDisconnectingThenSerialPortIsClosed(double siteLat, double siteLong) { // given + var cancellationToken = TestContext.Current.CancellationToken; var device = new FakeDevice(DeviceType.Mount, 1, new NameValueCollection { ["latitude"] = Convert.ToString(siteLat), ["longitude"] = Convert.ToString(siteLong) }); var fakeExternal = new FakeExternal(outputHelper, null, null, null); @@ -57,23 +58,23 @@ public async Task GivenMountWhenConnectingAndDisconnectingThenSerialPortIsClosed }; // when - await mount.ConnectAsync(); + await mount.ConnectAsync(cancellationToken); // then mount.Connected.ShouldBe(true); receivedConnect.ShouldBe(1); receivedDisconnect.ShouldBe(0); - Should.NotThrow(() => mount.SiderealTime); + await Should.NotThrowAsync(async () => await mount.GetSiderealTimeAsync(cancellationToken)); // after - await mount.DisconnectAsync(); + await mount.DisconnectAsync(cancellationToken); // then mount.Connected.ShouldBe(false); receivedConnect.ShouldBe(1); receivedDisconnect.ShouldBe(1); - Should.Throw(() => mount.SiderealTime, typeof(InvalidOperationException)); + await Should.ThrowAsync(async () => await mount.GetSiderealTimeAsync(cancellationToken), typeof(InvalidOperationException)); } [Theory] @@ -83,6 +84,7 @@ public async Task GivenMountWhenConnectingAndDisconnectingThenSerialPortIsClosed public async Task GivenTargetWhenSlewingItSlewsToTarget(double siteLat, double siteLong, double targetRa, double targetDec, string? utc) { // given + var cancellationToken = TestContext.Current.CancellationToken; var device = new FakeDevice(DeviceType.Mount, 1, new NameValueCollection { ["latitude"] = Convert.ToString(siteLat), ["longitude"] = Convert.ToString(siteLong) }); var fakeExternal = new FakeExternal(outputHelper, null, utc is not null ? DateTimeOffset.Parse(utc) : null, null); @@ -91,11 +93,11 @@ public async Task GivenTargetWhenSlewingItSlewsToTarget(double siteLat, double s var timeStamp = fakeExternal.TimeProvider.GetTimestamp(); // when - await mount.ConnectAsync(); - mount.Tracking = true; - await mount.BeginSlewRaDecAsync(targetRa, targetDec); - mount.IsSlewing.ShouldBe(true); - while (mount.IsSlewing) + await mount.ConnectAsync(cancellationToken); + await mount.SetTrackingAsync(true, cancellationToken); + await mount.BeginSlewRaDecAsync(targetRa, targetDec, cancellationToken); + (await mount.IsSlewingAsync(cancellationToken)).ShouldBe(true); + while (await mount.IsSlewingAsync(cancellationToken)) { // this will advance the fake timer and not actually sleep fakeExternal.Sleep(TimeSpan.FromSeconds(1)); @@ -104,9 +106,9 @@ public async Task GivenTargetWhenSlewingItSlewsToTarget(double siteLat, double s // then var timePassed = fakeExternal.TimeProvider.GetElapsedTime(timeStamp); timePassed.ShouldBeGreaterThan(TimeSpan.FromSeconds(2)); - mount.IsSlewing.ShouldBe(false); - mount.Tracking.ShouldBe(true); + (await mount.IsSlewingAsync(cancellationToken)).ShouldBe(false); + (await mount.IsTrackingAsync(cancellationToken)).ShouldBe(true); mount.Connected.ShouldBe(true); - mount.Alignment.ShouldBe(AlignmentMode.GermanPolar); + (await mount.GetAlignmentAsync(cancellationToken)).ShouldBe(AlignmentMode.GermanPolar); } } \ No newline at end of file diff --git a/src/TianWen.Lib.Tests/PlateSolverRoundTripTests.cs b/src/TianWen.Lib.Tests/PlateSolverRoundTripTests.cs index 93e4c61..4443c0f 100644 --- a/src/TianWen.Lib.Tests/PlateSolverRoundTripTests.cs +++ b/src/TianWen.Lib.Tests/PlateSolverRoundTripTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using TianWen.Lib.Imaging; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -16,9 +15,10 @@ public class PlateSolverRoundTripTests(ITestOutputHelper testOutputHelper) : Ima public async Task GivenFileNameWhenWritingImageAndReadingBackThenItIsIdentical(string name, float snrMin) { // given - var image = await SharedTestData.ExtractGZippedFitsImageAsync(name); + var cancellationToken = TestContext.Current.CancellationToken; + var image = await SharedTestData.ExtractGZippedFitsImageAsync(name, cancellationToken: cancellationToken); var fullPath = Path.Combine(Path.GetTempPath(), $"roundtrip_{Guid.NewGuid():D}.fits"); - var expectedStars = await image.FindStarsAsync(snrMin: snrMin); + var expectedStars = await image.FindStarsAsync(snrMin: snrMin, cancellationToken: cancellationToken); try { @@ -35,7 +35,7 @@ public async Task GivenFileNameWhenWritingImageAndReadingBackThenItIsIdentical(s readoutImage.MaxValue.ShouldBe(image.MaxValue); readoutImage.ImageMeta.ExposureStartTime.ShouldBe(image.ImageMeta.ExposureStartTime); readoutImage.ImageMeta.ExposureDuration.ShouldBe(image.ImageMeta.ExposureDuration); - var starsFromImage = await image.FindStarsAsync(snrMin: snrMin); + var starsFromImage = await image.FindStarsAsync(snrMin: snrMin, cancellationToken: cancellationToken); starsFromImage.ShouldBe(expectedStars, ignoreOrder: true); } diff --git a/src/TianWen.Lib.Tests/PlateSolverTests.cs b/src/TianWen.Lib.Tests/PlateSolverTests.cs index 36bbada..89f7c78 100644 --- a/src/TianWen.Lib.Tests/PlateSolverTests.cs +++ b/src/TianWen.Lib.Tests/PlateSolverTests.cs @@ -1,17 +1,16 @@ -using TianWen.Lib.Astrometry.PlateSolve; -using Shouldly; +using Shouldly; using System; using System.Diagnostics; -using System.IO; using System.Threading; using System.Threading.Tasks; +using TianWen.Lib.Astrometry.PlateSolve; using Xunit; namespace TianWen.Lib.Tests; public class PlateSolverTests { - [SkippableTheory] + [Theory] [InlineData("PlateSolveTestFile", typeof(AstrometryNetPlateSolverUnix))] [InlineData("PlateSolveTestFile", typeof(AstrometryNetPlateSolverMultiPlatform))] [InlineData("image_file-snr-20_stars-28_1280x960x16", typeof(AstrometryNetPlateSolverMultiPlatform))] @@ -19,23 +18,23 @@ public class PlateSolverTests public async Task GivenStarFieldTestFileWhenBlindPlateSolvingThenItIsSolved(string name, Type solverType, double accuracy = 0.01) { // given - var extractedFitsFile = await SharedTestData.ExtractGZippedFitsFileAsync(name); - var cts = new CancellationTokenSource(Debugger.IsAttached ? TimeSpan.FromHours(10) : TimeSpan.FromSeconds(10)); + var cancellationToken = TestContext.Current.CancellationToken; + var extractedFitsFile = await SharedTestData.ExtractGZippedFitsFileAsync(name, cancellationToken); var solver = (Activator.CreateInstance(solverType) as IPlateSolver).ShouldNotBeNull(); var platform = Environment.OSVersion.Platform; - Skip.If(solverType.IsAssignableTo(typeof(AstrometryNetPlateSolver)) + Assert.SkipWhen(solverType.IsAssignableTo(typeof(AstrometryNetPlateSolver)) && platform == PlatformID.Win32NT && !Debugger.IsAttached, $"Is multi-platform and running on Windows without debugger (Windows is skipped by default as WSL has a long cold start time)"); - Skip.IfNot(await solver.CheckSupportAsync(), $"Platform {platform} is not supported!"); + Assert.SkipUnless(await solver.CheckSupportAsync(TestContext.Current.CancellationToken), $"Platform {platform} is not supported!"); if (SharedTestData.TestFileImageDimAndCoords.TryGetValue(name, out var dimAndCoords)) { // when - var solution = await solver.SolveFileAsync(extractedFitsFile, dimAndCoords.ImageDim, cancellationToken: cts.Token); + var solution = await solver.SolveFileAsync(extractedFitsFile, dimAndCoords.ImageDim, cancellationToken: cancellationToken); // then solution.HasValue.ShouldBe(true); @@ -49,7 +48,7 @@ public async Task GivenStarFieldTestFileWhenBlindPlateSolvingThenItIsSolved(stri } } - [SkippableTheory] + [Theory] [InlineData("PlateSolveTestFile", typeof(AstrometryNetPlateSolverMultiPlatform))] [InlineData("PlateSolveTestFile", typeof(AstrometryNetPlateSolverUnix))] [InlineData("image_file-snr-20_stars-28_1280x960x16", typeof(AstrometryNetPlateSolverMultiPlatform))] @@ -57,17 +56,18 @@ public async Task GivenStarFieldTestFileWhenBlindPlateSolvingThenItIsSolved(stri public async Task GivenStarFieldTestFileAndSearchOriginWhenPlateSolvingThenItIsSolved(string name, Type solverType, double accuracy = 0.01) { // given - var extractedFitsFile = await SharedTestData.ExtractGZippedFitsFileAsync(name); + var cancellationToken = TestContext.Current.CancellationToken; + var extractedFitsFile = await SharedTestData.ExtractGZippedFitsFileAsync(name, cancellationToken); var cts = new CancellationTokenSource(Debugger.IsAttached ? TimeSpan.FromHours(10) : TimeSpan.FromSeconds(10)); var solver = (Activator.CreateInstance(solverType) as IPlateSolver).ShouldNotBeNull(); var platform = Environment.OSVersion.Platform; - Skip.If(solverType.IsAssignableTo(typeof(AstrometryNetPlateSolver)) + Assert.SkipWhen(solverType.IsAssignableTo(typeof(AstrometryNetPlateSolver)) && platform == PlatformID.Win32NT && !Debugger.IsAttached, $"Is multi-platform and running on Windows without debugger (Windows is skipped by default as WSL has a long cold start time)"); - Skip.IfNot(await solver.CheckSupportAsync(), $"Platform {platform} is not supported!"); + Assert.SkipUnless(await solver.CheckSupportAsync(cancellationToken), $"Platform {platform} is not supported!"); if (SharedTestData.TestFileImageDimAndCoords.TryGetValue(name, out var dimAndCoords)) { diff --git a/src/TianWen.Lib.Tests/ProfileTests.cs b/src/TianWen.Lib.Tests/ProfileTests.cs index 5693658..668bd40 100644 --- a/src/TianWen.Lib.Tests/ProfileTests.cs +++ b/src/TianWen.Lib.Tests/ProfileTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using TianWen.Lib.Devices; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -24,6 +23,7 @@ public void GivenGuidAndProfileNameAProfileUriIsCreated(string guid, string name public async Task GivenProfileWhenSavedAndLoadedThenItIsIdentical(string guid, string name) { // given + var cancellationToken = TestContext.Current.CancellationToken; var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("D"))); try { @@ -34,12 +34,12 @@ public async Task GivenProfileWhenSavedAndLoadedThenItIsIdentical(string guid, s await profile.SaveAsync(external); // when - await profileIterator.DiscoverAsync(); + await profileIterator.DiscoverAsync(cancellationToken); var enumeratedProfiles = profileIterator.RegisteredDevices(DeviceType.Profile); // then profileIterator.RegisteredDeviceTypes.ShouldBe([DeviceType.Profile]); - (await profileIterator.CheckSupportAsync()).ShouldBeTrue(); + (await profileIterator.CheckSupportAsync(cancellationToken)).ShouldBeTrue(); enumeratedProfiles.ShouldHaveSingleItem().ShouldNotBeNull().DeviceUri.ShouldBe(profile.DeviceUri); } diff --git a/src/TianWen.Lib.Tests/SerialConnectionTests.cs b/src/TianWen.Lib.Tests/SerialConnectionTests.cs new file mode 100644 index 0000000..afa3dc6 --- /dev/null +++ b/src/TianWen.Lib.Tests/SerialConnectionTests.cs @@ -0,0 +1,50 @@ +using Nerdbank.Streams; +using System.Text; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace TianWen.Lib.Tests; + +public class SerialConnectionTests(ITestOutputHelper testOutputHelper) +{ + private (StreamSerialConnection C1, StreamSerialConnection C2) CreatePair() + { + var logger = FakeExternal.CreateLogger(testOutputHelper); + + var (stream1, stream2) = FullDuplexStream.CreatePair(); + + var ssc1 = new StreamSerialConnection(stream1, Encoding.Latin1, "SSC1", logger); + var ssc2 = new StreamSerialConnection(stream2, Encoding.Latin1, "SSC2", logger); + + return (ssc1, ssc2); + } + + [Fact] + public async ValueTask TestRoundtripTerminatedMessage() + { + var terminators = "#\0"u8.ToArray(); + + var cancellationToken = TestContext.Current.CancellationToken; + var (ssc1, ssc2) = CreatePair(); + + (await ssc1.TryWriteAsync(":test1#"u8.ToArray(), cancellationToken)).ShouldBe(true); + + // terminator is not part of the response + (await ssc2.TryReadTerminatedAsync(terminators, cancellationToken)).ShouldBe(":test1"); + } + + [Fact] + public async ValueTask TestRoundtripReadExactlyMessage() + { + var cancellationToken = TestContext.Current.CancellationToken; + var (ssc1, ssc2) = CreatePair(); + + (await ssc1.TryWriteAsync("1234567890abcdef#"u8.ToArray(), cancellationToken)).ShouldBe(true); + + (await ssc2.TryReadExactlyAsync(4, cancellationToken)).ShouldBe("1234"); + (await ssc2.TryReadExactlyAsync(4, cancellationToken)).ShouldBe("5678"); + (await ssc2.TryReadExactlyAsync(2, cancellationToken)).ShouldBe("90"); + (await ssc2.TryReadTerminatedAsync("#\0"u8.ToArray(), cancellationToken)).ShouldBe("abcdef"); + } +} diff --git a/src/TianWen.Lib.Tests/SharedTestData.cs b/src/TianWen.Lib.Tests/SharedTestData.cs index def7b6d..15ccdfa 100644 --- a/src/TianWen.Lib.Tests/SharedTestData.cs +++ b/src/TianWen.Lib.Tests/SharedTestData.cs @@ -7,6 +7,7 @@ using System.IO.Compression; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using TianWen.Lib.Imaging; @@ -22,7 +23,7 @@ public static class SharedTestData private static readonly ConcurrentDictionary _imageCache = []; private static readonly Assembly _assembly = typeof(SharedTestData).Assembly; - internal static async Task ExtractGZippedFitsImageAsync(string name, bool isReadOnly = true) + internal static async Task ExtractGZippedFitsImageAsync(string name, bool isReadOnly = true, CancellationToken cancellationToken = default) { if (isReadOnly) { @@ -31,12 +32,15 @@ internal static async Task ExtractGZippedFitsImageAsync(string name, bool return image; } - var imageFile = await WriteEphemeralUseTempFileAsync($"{name}.tianwen-image", async tempFile => - { - image = ReadImageFromEmbeddedResourceStream(name); - using var outStream = File.OpenWrite(tempFile); - await image.WriteStreamAsync(outStream); - }); + var imageFile = await WriteEphemeralUseTempFileAsync($"{name}.tianwen-image", + async (tempFile, cancellationToken) => + { + image = ReadImageFromEmbeddedResourceStream(name); + using var outStream = File.OpenWrite(tempFile); + await image.WriteStreamAsync(outStream, cancellationToken); + }, + cancellationToken + ); if (image is null) { @@ -77,7 +81,7 @@ private static Image ReadImageFromEmbeddedResourceStream(string name) ["image_file-snr-20_stars-28_1280x960x16"] = (new ImageDim(5.6f, 1280, 960), new WCS(337.264d / 15.0d, -22.918d)) }; - internal static async Task ExtractGZippedFitsFileAsync(string name) + internal static async Task ExtractGZippedFitsFileAsync(string name, CancellationToken cancellationToken = default) { if (OpenGZippedFitsFileStream(name) is not { } inStream) { @@ -85,19 +89,22 @@ internal static async Task ExtractGZippedFitsFileAsync(string name) } var fileName = $"{name}_{inStream.Length}.fits"; - return await WriteEphemeralUseTempFileAsync(fileName, async (tempFile) => - { - using var outStream = new FileStream(tempFile, new FileStreamOptions + return await WriteEphemeralUseTempFileAsync(fileName, + async (tempFile, cancellationToken) => { - Options = FileOptions.Asynchronous, - Access = FileAccess.Write, - Mode = FileMode.Create, - Share = FileShare.None - }); - using var gzipStream = new GZipStream(inStream, CompressionMode.Decompress, false); - var length = inStream.Length; - await gzipStream.CopyToAsync(outStream, 1024 * 10); - }); + using var outStream = new FileStream(tempFile, new FileStreamOptions + { + Options = FileOptions.Asynchronous, + Access = FileAccess.Write, + Mode = FileMode.Create, + Share = FileShare.None + }); + using var gzipStream = new GZipStream(inStream, CompressionMode.Decompress, false); + var length = inStream.Length; + await gzipStream.CopyToAsync(outStream, 1024 * 10, cancellationToken); + }, + cancellationToken + ); } internal static string CreateTempTestOutputDir() @@ -111,7 +118,7 @@ internal static string CreateTempTestOutputDir() return dir.FullName; } - private static async Task WriteEphemeralUseTempFileAsync(string fileName, Func fileOperation) + private static async Task WriteEphemeralUseTempFileAsync(string fileName, Func fileOperation, CancellationToken cancellationToken = default) { var tempOutputDir = CreateTempTestOutputDir(); @@ -125,7 +132,7 @@ private static async Task WriteEphemeralUseTempFileAsync(string fileName { var tempFile = $"{fullPath}_{Guid.NewGuid():D}.tmp"; - await fileOperation(tempFile); + await fileOperation(tempFile, cancellationToken); if (!File.Exists(fullPath)) { diff --git a/src/TianWen.Lib.Tests/StarStatisticsTests.cs b/src/TianWen.Lib.Tests/StarStatisticsTests.cs index c69874c..7dc0e32 100644 --- a/src/TianWen.Lib.Tests/StarStatisticsTests.cs +++ b/src/TianWen.Lib.Tests/StarStatisticsTests.cs @@ -1,7 +1,6 @@ using Shouldly; using System.Threading.Tasks; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; @@ -20,9 +19,12 @@ public class StarStatisticsTests(ITestOutputHelper testOutputHelper) : ImageAnal [InlineData(PHD2SimGuider, 30, 10, 2)] public async Task GivenFitsFileWhenAnalysingThenMedianHFDAndFWHMIsCalculated(string name, float snrMin, int maxRetries, int expectedStars, params int[] sampleStar) { + // given + var cancellationToken = TestContext.Current.CancellationToken; + // when - var image = await SharedTestData.ExtractGZippedFitsImageAsync(name); - var result = await image.FindStarsAsync(snrMin: snrMin, maxRetries: maxRetries); + var image = await SharedTestData.ExtractGZippedFitsImageAsync(name, cancellationToken: cancellationToken); + var result = await image.FindStarsAsync(snrMin: snrMin, maxRetries: maxRetries, cancellationToken: cancellationToken); // then result.ShouldNotBeEmpty(); diff --git a/src/TianWen.Lib.Tests/StreamSerialConnection.cs b/src/TianWen.Lib.Tests/StreamSerialConnection.cs new file mode 100644 index 0000000..05f792a --- /dev/null +++ b/src/TianWen.Lib.Tests/StreamSerialConnection.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using System.IO; +using System.Text; +using TianWen.Lib.Connections; + +namespace TianWen.Lib.Tests; + +/// +/// Simple serial class allows testing of the main serial code without actually having to open a terminal. +/// +/// +/// +/// +/// +internal class StreamSerialConnection(Stream stream, Encoding encoding, string name, ILogger logger) + : SerialConnectionBase(encoding, logger) +{ + private bool _isOpen; + + public override bool IsOpen => _isOpen; + + public override string DisplayName => name; + + protected override Stream OpenStream() + { + _isOpen = true; + + return stream; + } +} \ No newline at end of file diff --git a/src/TianWen.Lib.Tests/TestDataSanityTests.cs b/src/TianWen.Lib.Tests/TestDataSanityTests.cs index 8acd735..2f58c0c 100644 --- a/src/TianWen.Lib.Tests/TestDataSanityTests.cs +++ b/src/TianWen.Lib.Tests/TestDataSanityTests.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using TianWen.Lib.Imaging; using Xunit; -using Xunit.Abstractions; namespace TianWen.Lib.Tests; diff --git a/src/TianWen.Lib.Tests/TianWen.Lib.Tests.csproj b/src/TianWen.Lib.Tests/TianWen.Lib.Tests.csproj index d8c17f2..f8c7372 100644 --- a/src/TianWen.Lib.Tests/TianWen.Lib.Tests.csproj +++ b/src/TianWen.Lib.Tests/TianWen.Lib.Tests.csproj @@ -27,17 +27,17 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -46,7 +46,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/src/TianWen.Lib/ArrayPoolHelper.cs b/src/TianWen.Lib/ArrayPoolHelper.cs new file mode 100644 index 0000000..e13109a --- /dev/null +++ b/src/TianWen.Lib/ArrayPoolHelper.cs @@ -0,0 +1,66 @@ +using System; +using System.Buffers; +using System.Runtime.CompilerServices; + +namespace TianWen.Lib; + +public static class ArrayPoolHelper +{ + public static SharedObject Rent(int minimumLength) => new SharedObject(minimumLength); + + public readonly struct SharedObject(int minimumLength) : IDisposable + { + private readonly T[] _value = ArrayPool.Shared.Rent(minimumLength); + private readonly int _length = minimumLength; + + public int Length => _length; + + public T this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + get => _value[index]; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + set => _value[index] = value; + } + + public T this[Index index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + get => index.IsFromEnd ? _value[_length - index.Value] : _value[index]; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + set + { + if (index.IsFromEnd) + { + _value[_length - index.Value] = value; + } + else + { + _value[index] = value; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public Span AsSpan(int start) => _value.AsSpan(start); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public Span AsSpan(int start, int length) => _value.AsSpan(start, length); + + public void Dispose() => ArrayPool.Shared.Return(_value); + + public static implicit operator Memory(SharedObject sharedObject) + => sharedObject._value.AsMemory(0, sharedObject._length); + + public static implicit operator ReadOnlyMemory(SharedObject sharedObject) + => sharedObject._value.AsMemory(0, sharedObject._length); + + public static implicit operator Span(SharedObject sharedObject) + => sharedObject._value.AsSpan(0, sharedObject._length); + + public static implicit operator ReadOnlySpan(SharedObject sharedObject) + => sharedObject._value.AsSpan(0, sharedObject._length); + } +} \ No newline at end of file diff --git a/src/TianWen.Lib/Connections/ISerialConnection.cs b/src/TianWen.Lib/Connections/ISerialConnection.cs index f974934..038c39e 100644 --- a/src/TianWen.Lib/Connections/ISerialConnection.cs +++ b/src/TianWen.Lib/Connections/ISerialConnection.cs @@ -1,6 +1,7 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace TianWen.Lib.Connections; @@ -14,9 +15,26 @@ public interface ISerialConnection : IDisposable bool TryClose(); - bool TryWrite(ReadOnlySpan data); + Task WaitAsync(CancellationToken cancellationToken); - bool TryReadTerminated([NotNullWhen(true)] out ReadOnlySpan message, ReadOnlySpan terminators); + int Release(); - bool TryReadExactly(int count, [NotNullWhen(true)] out ReadOnlySpan message); + ValueTask TryWriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken); + + ValueTask TryWriteAsync(string data, CancellationToken cancellationToken) => TryWriteAsync(Encoding.GetBytes(data), cancellationToken); + + ValueTask TryReadTerminatedAsync(ReadOnlyMemory terminators, CancellationToken cancellationToken); + + ValueTask TryReadExactlyAsync(int count, CancellationToken cancellationToken); + + /// + /// + /// + /// + /// + /// + /// bytes read + ValueTask TryReadTerminatedRawAsync(Memory message, ReadOnlyMemory terminators, CancellationToken cancellationToken); + + ValueTask TryReadExactlyRawAsync(Memory message, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/TianWen.Lib/Connections/SerialConnection.cs b/src/TianWen.Lib/Connections/SerialConnection.cs index f08a3f8..7bd4e87 100644 --- a/src/TianWen.Lib/Connections/SerialConnection.cs +++ b/src/TianWen.Lib/Connections/SerialConnection.cs @@ -1,15 +1,14 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Ports; -using System.Linq; using System.Text; namespace TianWen.Lib.Connections; -internal sealed class SerialConnection : ISerialConnection +internal sealed class SerialConnection(string portName, int baud, Encoding encoding, ILogger logger, TimeSpan? ioTimeout = null) + : SerialConnectionBase(encoding, logger) { public static IReadOnlyList EnumerateSerialPorts() { @@ -31,113 +30,34 @@ public static string CleanupPortName(string portName) return portNameWithoutPrefix.StartsWith("tty", StringComparison.Ordinal) ? $"/dev/{portNameWithoutPrefix}" : portNameWithoutPrefix; } - private readonly SerialPort _port; - private readonly Stream _stream; - private readonly ILogger _logger; + private readonly SerialPort _port = new SerialPort(CleanupPortName(portName), baud); - public SerialConnection(string portName, int baud, ILogger logger, Encoding encoding, TimeSpan? ioTimeout = null) + protected override Stream OpenStream() { - _port = new SerialPort(CleanupPortName(portName), baud); _port.Open(); - + var stream = _port.BaseStream; var timeoutMs = (int)Math.Round((ioTimeout ?? TimeSpan.FromMilliseconds(500)).TotalMilliseconds); - _stream = _port.BaseStream; - _stream.ReadTimeout = timeoutMs; - _stream.WriteTimeout = timeoutMs; - - _logger = logger; - Encoding = encoding; + stream.ReadTimeout = timeoutMs; + stream.WriteTimeout = timeoutMs; + return stream; } - public bool IsOpen => _port.IsOpen; + public override bool IsOpen => _port.IsOpen; - /// - /// Encoding used for decoding byte messages (used for display/logging only) - /// - public Encoding Encoding { get; } + public override string DisplayName => throw new NotImplementedException(); /// /// Closes the serial port if it is open /// /// true if the prot is closed - public bool TryClose() + public override bool TryClose() { if (_port.IsOpen) { _port.Close(); - } - return !_port.IsOpen; - } - public bool TryWrite(ReadOnlySpan message) - { - try - { - _stream.Write(message); -#if DEBUG - _logger.LogDebug("--> {Message}", Encoding.GetString(message)); -#endif - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while sending message {Message} to serial device on port {Port}", - Encoding.GetString(message), _port.PortName); - - return false; - } - - return true; - } - - public bool TryReadTerminated([NotNullWhen(true)] out ReadOnlySpan message, ReadOnlySpan terminators) - { - Span buffer = stackalloc byte[100]; - try - { - int bytesRead = 0; - int bytesReadLast; - do - { - bytesReadLast = _stream.ReadAtLeast(buffer[bytesRead..], 1, true); - bytesRead += bytesReadLast; - } while (!terminators.Contains(buffer[bytesRead - bytesReadLast])); - - message = buffer[0..(bytesRead - bytesReadLast - 1)].ToArray(); -#if DEBUG - _logger.LogDebug("<-- (terminated by any of {Terminators}): {Response}", Encoding.GetString(terminators), Encoding.GetString(message)); -#endif - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while reading response from serial device on port {Port}", _port.PortName); - - message = null; - return false; - } - } - - public bool TryReadExactly(int count, [NotNullWhen(true)] out ReadOnlySpan message) - { - Span buffer = count > 100 ? new byte[count] : stackalloc byte[count]; - try - { - _stream.ReadExactly(buffer); - - message = buffer.ToArray(); -#if DEBUG - _logger.LogDebug("<-- (exactly {Count}): {Response}", count, Encoding.GetString(message)); -#endif - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while reading response from serial device on port {Port}", _port.PortName); - - message = null; - return false; + return base.TryClose(); } + return !_port.IsOpen; } - - public void Dispose() => _ = TryClose(); } \ No newline at end of file diff --git a/src/TianWen.Lib/Connections/SerialConnectionBase.cs b/src/TianWen.Lib/Connections/SerialConnectionBase.cs new file mode 100644 index 0000000..b883e51 --- /dev/null +++ b/src/TianWen.Lib/Connections/SerialConnectionBase.cs @@ -0,0 +1,179 @@ +using CommunityToolkit.HighPerformance; +using Microsoft.Extensions.Logging; +using System; +using System.Buffers; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace TianWen.Lib.Connections; + +internal abstract class SerialConnectionBase : ISerialConnection +{ + private readonly SemaphoreSlim _semaphore; + private readonly Stream _stream; + private readonly ILogger _logger; + + public SerialConnectionBase(Encoding encoding, ILogger logger) + { + _stream = OpenStream(); + _semaphore = new SemaphoreSlim(1, 1); + + _logger = logger; + Encoding = encoding; + } + + protected abstract Stream OpenStream(); + + public abstract bool IsOpen { get; } + + public abstract string DisplayName { get; } + + /// + /// Encoding used for decoding byte messages (used for display/logging only) + /// + public Encoding Encoding { get; } + + public Task WaitAsync(CancellationToken cancellationToken) => _semaphore.WaitAsync(cancellationToken); + + public int Release() => _semaphore.Release(); + + /// + /// Closes the serial port if it is open + /// + /// true if the prot is closed + public virtual bool TryClose() + { + _semaphore.Dispose(); + return true; + } + + public async ValueTask TryWriteAsync(ReadOnlyMemory message, CancellationToken cancellationToken) + { + try + { + await _stream.WriteAsync(message, cancellationToken); +#if DEBUG + _logger.LogTrace("--> {Message}", Encoding.GetString(message.Span).ReplaceNonPrintableWithHex()); +#endif + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while sending message {Message} to serial device on serial port {Port}", + Encoding.GetString(message.Span), DisplayName); + + return false; + } + + return true; + } + + public async ValueTask TryReadTerminatedAsync(ReadOnlyMemory terminators, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(100); + try + { + var bytesRead = await TryReadTerminatedRawAsync(buffer, terminators, cancellationToken); + if (bytesRead >= 0) + { + var message = Encoding.GetString(buffer.AsSpan(0, bytesRead)); + + return message; + } + else + { + return null; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async ValueTask TryReadTerminatedRawAsync(Memory message, ReadOnlyMemory terminators, CancellationToken cancellationToken) + { + int bytesRead = 0; + int terminatorIndex; + try + { + int bytesReadLast; + do + { + bytesReadLast = await _stream.ReadAtLeastAsync(message[bytesRead..], 1, true, cancellationToken); + terminatorIndex = message.Span[bytesRead..bytesReadLast].IndexOfAny(terminators.Span); + if (terminatorIndex < 0) + { + bytesRead += bytesReadLast; + } + else + { + bytesRead += terminatorIndex; + break; + } + } while (bytesRead < message.Length); + +#if DEBUG + // output log including the terminator + _logger.LogTrace("<-- {Response}", Encoding.GetString(message.Span[0..(bytesRead+1)])); +#endif + if (terminatorIndex < 0) + { + _logger.LogWarning("Terminator (any of {Terminators}) not found in message from serial device on serial port {Port}", + Encoding.GetString(terminators.Span).ReplaceNonPrintableWithHex(), + DisplayName); + return -1; + } + + // return length without the terminator + return bytesRead; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while reading response from serial device on serial port {Port}", DisplayName); + + return -1; + } + } + + public async ValueTask TryReadExactlyAsync(int count, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(count); + try + { + if (await TryReadExactlyRawAsync(buffer.AsMemory(0, count), cancellationToken)) + { + return Encoding.GetString(buffer.AsSpan(0, count)); + } + else + { + return null; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async ValueTask TryReadExactlyRawAsync(Memory message, CancellationToken cancellationToken) + { + try + { + await _stream.ReadExactlyAsync(message, cancellationToken); +#if DEBUG + _logger.LogTrace("<-- {Response} ({Length})", Encoding.GetString(message.Span), message.Length); +#endif + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while reading response from serial device on serial port {Port}", DisplayName); + + return false; + } + } + + public void Dispose() => _ = TryClose(); +} diff --git a/src/TianWen.Lib/Devices/Ascom/AscomTelescopeDriver.cs b/src/TianWen.Lib/Devices/Ascom/AscomTelescopeDriver.cs index ec263b0..d9108cc 100644 --- a/src/TianWen.Lib/Devices/Ascom/AscomTelescopeDriver.cs +++ b/src/TianWen.Lib/Devices/Ascom/AscomTelescopeDriver.cs @@ -47,94 +47,100 @@ private void AscomTelescopeDriver_DeviceConnectedEvent(object? sender, DeviceCon } } - public Task BeginSlewRaDecAsync(double ra, double dec, CancellationToken cancellationToken = default) + public ValueTask BeginSlewRaDecAsync(double ra, double dec, CancellationToken cancellationToken = default) { if (_comObject.CanSlewAsync is bool canSlewAsync && canSlewAsync) { _comObject.SlewToCoordinatesAsync(ra, dec); - return Task.CompletedTask; + return ValueTask.CompletedTask; } else { - throw new InvalidOperationException($"Failed to execute {nameof(AbortSlew)} connected={Connected} initialized={_comObject is not null}"); + throw new InvalidOperationException($"Failed to execute {nameof(AbortSlewAsync)} connected={Connected} initialized={_comObject is not null}"); } } public IReadOnlyList TrackingSpeeds => _trackingSpeeds; - public TrackingSpeed TrackingSpeed + public ValueTask GetTrackingSpeedAsync(CancellationToken cancellationToken) { - get => (TrackingSpeed)_comObject.TrackingRate; - set => _comObject.TrackingRate = (AscomTrackingSpeed)value; - } + if (Connected) + { + return ValueTask.FromResult((TrackingSpeed)_comObject.TrackingRate); + } + else + { + throw new InvalidOperationException($"Failed to execute {nameof(GetTrackingSpeedAsync)} connected={Connected} initialized={_comObject is not null}"); + } + } + + public ValueTask SetTrackingSpeedAsync(TrackingSpeed value, CancellationToken cancellationToken) + { + _comObject.TrackingRate = (AscomTrackingSpeed)value; - public bool AtHome => _comObject.AtHome is bool atHome && atHome; + return ValueTask.CompletedTask; + } - public bool AtPark => _comObject.AtPark is bool atPark && atPark; + public ValueTask AtHomeAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.AtHome is bool atHome && atHome); - public bool IsSlewing => _comObject.Slewing is bool slewing && slewing; + public ValueTask AtParkAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.AtPark is bool atPark && atPark); - public double SiderealTime => _comObject.SiderealTime is double siderealTime ? siderealTime : throw new InvalidOperationException($"Failed to retrieve {nameof(SiderealTime)} from device connected={Connected} initialized={_comObject is not null}"); + public ValueTask IsSlewingAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.Slewing is bool slewing && slewing); + + public ValueTask GetSiderealTimeAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.SiderealTime is double siderealTime ? siderealTime : throw new InvalidOperationException($"Failed to retrieve sidereal time from device connected={Connected} initialized={_comObject is not null}")); public bool TimeIsSetByUs { get; private set; } - public DateTime? UTCDate + public ValueTask TryGetUTCDateFromMountAsync(CancellationToken cancellationToken) { - get + try { - try - { - return Connected && _comObject.UTCDate is DateTime utcDate ? utcDate : default; - } - catch - { - return default; - } + return ValueTask.FromResult(Connected && _comObject.UTCDate is DateTime utcDate ? utcDate : null as DateTime?); + } + catch + { + return default; } + } - set + public ValueTask SetUTCDateAsync(DateTime value, CancellationToken cancellationToken) + { + if (!Connected) { - if (!Connected) - { - throw new InvalidOperationException("Mount is not connected"); - } - else if (value is { } utcDate) - { - try - { - _comObject.UTCDate = utcDate; - TimeIsSetByUs = true; - } - catch - { - TimeIsSetByUs = false; - } - } - else - { - TimeIsSetByUs = false; - } + throw new InvalidOperationException("Mount is not connected"); + } + try + { + _comObject.UTCDate = value; + TimeIsSetByUs = true; + } + catch + { + TimeIsSetByUs = false; } + + return ValueTask.CompletedTask; } - public bool Tracking + public ValueTask IsTrackingAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(_comObject.Tracking is bool tracking && tracking); + + public ValueTask SetTrackingAsync(bool value, CancellationToken cancellationToken) { - get => _comObject.Tracking is bool tracking && tracking; - set + if (Connected) { - if (Connected ) - { - if (_comObject.CanSetTracking is false) - { - throw new InvalidOperationException("Driver does not support setting tracking"); - } - _comObject.Tracking = value; - } - else + if (_comObject.CanSetTracking is false) { - throw new InvalidOperationException($"Failed to set {nameof(Tracking)} to {value} connected={Connected} initialized={_comObject is not null}"); + throw new InvalidOperationException("Driver does not support setting tracking"); } + _comObject.Tracking = value; + + return ValueTask.CompletedTask; + } + else + { + throw new InvalidOperationException($"Failed to set tracking to {value} connected={Connected} initialized={_comObject is not null}"); } } @@ -162,51 +168,62 @@ public bool Tracking public bool CanSetGuideRates { get; private set; } - public PointingState SideOfPier + public ValueTask GetSideOfPierAsync(CancellationToken cancellationToken) + => ValueTask.FromResult((PointingState)_comObject.SideOfPier); + + + public ValueTask SetSideOfPierAsync(PointingState value, CancellationToken cancellationToken) { - get => (PointingState)_comObject.SideOfPier; - set + if (CanSetSideOfPier) { - if (CanSetSideOfPier) - { - _comObject.SideOfPier = (ASCOM.Common.DeviceInterfaces.PointingState)value; - } - else - { - throw new InvalidOperationException("Cannot set side of pier to: " + value); - } + _comObject.SideOfPier = (ASCOM.Common.DeviceInterfaces.PointingState)value; + + return ValueTask.CompletedTask; + } + else + { + throw new InvalidOperationException("Cannot set side of pier to: " + value); } } - public PointingState DestinationSideOfPier(double ra, double dec) => (PointingState)_comObject.DestinationSideOfPier(ra, dec); + public ValueTask DestinationSideOfPierAsync(double ra, double dec, CancellationToken cancellationToken) + => ValueTask.FromResult((PointingState)_comObject.DestinationSideOfPier(ra, dec)); public EquatorialCoordinateType EquatorialSystem => (EquatorialCoordinateType)_comObject.EquatorialSystem; - public AlignmentMode Alignment => (AlignmentMode)_comObject.AlignmentMode; + public ValueTask GetAlignmentAsync(CancellationToken cancellationToken) => ValueTask.FromResult((AlignmentMode)_comObject.AlignmentMode); - public double RightAscension => _comObject.RightAscension; + public ValueTask GetRightAscensionAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.RightAscension); - public double Declination => _comObject.Declination; + public ValueTask GetDeclinationAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.Declination); - public double SiteElevation + public ValueTask GetSiteElevationAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.SiteElevation); + + public ValueTask SetSiteElevationAsync(double value, CancellationToken cancellationToken) { - get => _comObject.SiteElevation; - set => _comObject.SiteElevation = value; + _comObject.SiteElevation = value; + return ValueTask.CompletedTask; } - public double SiteLatitude + public ValueTask GetSiteLatitudeAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.SiteLatitude); + + public ValueTask SetSiteLatitudeAsync(double value, CancellationToken cancellationToken) { - get => _comObject.SiteLatitude; - set => _comObject.SiteLatitude = value; + _comObject.SiteLatitude = value; + return ValueTask.CompletedTask; } - public double SiteLongitude + public ValueTask GetSiteLongitudeAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.SiteLongitude); + + public ValueTask SetSiteLongitudeAsync(double value, CancellationToken cancellationToken) { - get => _comObject.SiteLongitude; - set => _comObject.SiteLongitude = value; + _comObject.SiteLongitude = value; + return ValueTask.CompletedTask; } - public bool IsPulseGuiding => _comObject.IsPulseGuiding; + public ValueTask IsPulseGuidingAsync(CancellationToken cancellationToken) => ValueTask.FromResult(_comObject.IsPulseGuiding); + + public double RightAscensionRate { @@ -232,74 +249,97 @@ public double GuideRateDeclination set => _comObject.GuideRateDeclination = value; } - public void Park() + public ValueTask ParkAsync(CancellationToken cancellationToken) { if (Connected && CanPark ) { _comObject.Park(); + + return ValueTask.CompletedTask; } else { - throw new InvalidOperationException($"Failed to execute {nameof(Park)} connected={Connected} initialized={_comObject is not null}"); + throw new InvalidOperationException($"Failed to execute {nameof(ParkAsync)} connected={Connected} initialized={_comObject is not null}"); } } - public void Unpark() + public ValueTask UnparkAsync(CancellationToken cancellationToken) { if (Connected && CanUnpark ) { _comObject.Unpark(); + + return ValueTask.CompletedTask; } else { - throw new InvalidOperationException($"Failed to execute {nameof(Unpark)} connected={Connected} initialized={_comObject is not null}"); + throw new InvalidOperationException($"Failed to execute {nameof(UnparkAsync)} connected={Connected} initialized={_comObject is not null}"); } } - public void PulseGuide(GuideDirection direction, TimeSpan duration) + public ValueTask PulseGuideAsync(GuideDirection direction, TimeSpan duration, CancellationToken cancellationToken) { if (Connected && CanPulseGuide ) { _comObject.PulseGuide((AscomGuideDirection)direction, (int)duration.TotalMilliseconds); + + return ValueTask.CompletedTask; } else { - throw new InvalidOperationException($"Failed to execute {nameof(PulseGuide)} connected={Connected} initialized={_comObject is not null}"); + throw new InvalidOperationException($"Failed to execute {nameof(PulseGuideAsync)} connected={Connected} initialized={_comObject is not null}"); } } - public void SyncRaDec(double ra, double dec) + public async ValueTask SyncRaDecAsync(double ra, double dec, CancellationToken cancellationToken) { // prevent syncs on other side of meridian (most mounts do not support that). - if (Connected && CanSync && Tracking && !AtPark && DestinationSideOfPier(ra, dec) == SideOfPier ) + if (Connected + && CanSync + && await IsTrackingAsync(cancellationToken) + && !await AtParkAsync(cancellationToken) + && await DestinationSideOfPierAsync(ra, dec, cancellationToken) == await GetSideOfPierAsync(cancellationToken)) { _comObject.SyncToCoordinates(ra, dec); } else { - throw new InvalidOperationException($"Failed to execute {nameof(SyncRaDec)} connected={Connected} initialized={_comObject is not null}"); + throw new InvalidOperationException($"Failed to execute {nameof(SyncRaDecAsync)} connected={Connected} initialized={_comObject is not null}"); } } - public void AbortSlew() + public ValueTask AbortSlewAsync(CancellationToken cancellationToken) { if (Connected ) { _comObject.AbortSlew(); + + return ValueTask.CompletedTask; } else { - throw new InvalidOperationException($"Failed to execute {nameof(AbortSlew)} connected={Connected} initialized={_comObject is not null}"); + throw new InvalidOperationException($"Failed to execute {nameof(AbortSlewAsync)} connected={Connected} initialized={_comObject is not null}"); } } public bool CanMoveAxis(TelescopeAxis axis) => _comObject.CanMoveAxis((AscomTelescopeAxis)axis); + // TODO: implement axis rates public IReadOnlyList AxisRates(TelescopeAxis axis) { throw new NotImplementedException(); } - public void MoveAxis(TelescopeAxis axis, double rate) + public ValueTask MoveAxisAsync(TelescopeAxis axis, double rate, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask GetTargetRightAscensionAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask GetTargetDeclinationAsync(CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/src/TianWen.Lib/Devices/External.cs b/src/TianWen.Lib/Devices/External.cs index d1f6228..470da61 100644 --- a/src/TianWen.Lib/Devices/External.cs +++ b/src/TianWen.Lib/Devices/External.cs @@ -30,7 +30,7 @@ private static DirectoryInfo CreateSpecialSubFolder(Environment.SpecialFolder sp public IReadOnlyList EnumerateSerialPorts() => SerialConnection.EnumerateSerialPorts(); public ISerialConnection OpenSerialDevice(string address, int baud, Encoding encoding, TimeSpan? ioTimeout = null) - => new SerialConnection(address, baud, AppLogger, encoding, ioTimeout); + => new SerialConnection(address, baud, encoding, AppLogger, ioTimeout); public void Sleep(TimeSpan duration) => Thread.Sleep(duration); diff --git a/src/TianWen.Lib/Devices/Fake/FakeDevice.cs b/src/TianWen.Lib/Devices/Fake/FakeDevice.cs index 5715c13..ffdbf0e 100644 --- a/src/TianWen.Lib/Devices/Fake/FakeDevice.cs +++ b/src/TianWen.Lib/Devices/Fake/FakeDevice.cs @@ -14,7 +14,7 @@ public record FakeDevice(Uri DeviceUri) : DeviceBase(DeviceUri) /// /// Fake device id (starting from 1) public FakeDevice(DeviceType deviceType, int deviceId, NameValueCollection? values = null) - : this(new Uri($"{deviceType}://{typeof(FakeDevice).Name}/{deviceType}{deviceId}{(values is { Count: > 0 } ? "?" + values.ToQueryString() : "")}#Fake {deviceType.PascalCaseStringToName()} {deviceId}")) + : this(new Uri($"{deviceType}://{typeof(FakeDevice).Name}/Fake{deviceType}{deviceId}{(values is { Count: > 0 } ? "?" + values.ToQueryString() : "")}#Fake {deviceType.PascalCaseStringToName()} {deviceId}")) { // calls primary constructor } @@ -31,7 +31,7 @@ public FakeDevice(DeviceType deviceType, int deviceId, NameValueCollection? valu public override ISerialConnection? ConnectSerialDevice(IExternal external, int baud = 9600, Encoding? encoding = null, TimeSpan? ioTimeout = null) => DeviceType switch { - DeviceType.Mount => new FakeMeadeLX200SerialDevice(true, encoding ?? Encoding.Latin1, external.TimeProvider, SiteLatitude, SiteLongitude), + DeviceType.Mount => new FakeMeadeLX200SerialDevice(external.AppLogger, encoding ?? Encoding.Latin1, external.TimeProvider, SiteLatitude, SiteLongitude, true), _ => null }; diff --git a/src/TianWen.Lib/Devices/Fake/FakeDeviceSource.cs b/src/TianWen.Lib/Devices/Fake/FakeDeviceSource.cs index 21eebb4..a92b596 100644 --- a/src/TianWen.Lib/Devices/Fake/FakeDeviceSource.cs +++ b/src/TianWen.Lib/Devices/Fake/FakeDeviceSource.cs @@ -8,11 +8,10 @@ internal class FakeDeviceSource : IDeviceSource { public IEnumerable RegisteredDeviceTypes => [DeviceType.Mount, DeviceType.Camera, DeviceType.Focuser, DeviceType.FilterWheel]; - const int FakeDeviceCount = 9; - public IEnumerable RegisteredDevices(DeviceType deviceType) { - for (var i = 1; i <= FakeDeviceCount; i++) + var count = deviceType is DeviceType.Mount ? 1 : 2; + for (var i = 1; i <= count; i++) { yield return new FakeDevice(deviceType, i); } diff --git a/src/TianWen.Lib/Devices/Fake/FakeMeadeLX200SerialDevice.cs b/src/TianWen.Lib/Devices/Fake/FakeMeadeLX200SerialDevice.cs index 8dafe46..0178547 100644 --- a/src/TianWen.Lib/Devices/Fake/FakeMeadeLX200SerialDevice.cs +++ b/src/TianWen.Lib/Devices/Fake/FakeMeadeLX200SerialDevice.cs @@ -1,13 +1,14 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using System; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using System.Threading; +using System.Threading.Tasks; using TianWen.Lib.Astrometry.SOFA; -using static TianWen.Lib.Astrometry.CoordinateUtils; -using static TianWen.Lib.Astrometry.Constants; using TianWen.Lib.Connections; +using static TianWen.Lib.Astrometry.Constants; +using static TianWen.Lib.Astrometry.CoordinateUtils; namespace TianWen.Lib.Devices.Fake; @@ -17,21 +18,23 @@ internal class FakeMeadeLX200SerialDevice: ISerialConnection private readonly Transform _transform; private readonly int _alignmentStars = 0; private double _slewRate = 1.5d; // degrees per second - private bool _isTracking = false; - private bool _isSlewing = false; - private bool _highPrecision = false; - private int _trackingFrequency = 601; // TODO simulate tracking and tracking rate + private volatile bool _isTracking = false; + private volatile bool _isSlewing = false; + private volatile bool _highPrecision = false; + private volatile int _trackingFrequency = 601; // TODO simulate tracking and tracking rate private double _raAngle; private double _targetRa; private double _targetDec; private ITimer? _slewTimer; + private readonly ILogger _logger; // I/O properties private readonly StringBuilder _responseBuffer = new StringBuilder(); private int _responsePointer = 0; - private readonly Lock _lockObj = new(); + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + private readonly Lock _lockObj = new Lock(); - public FakeMeadeLX200SerialDevice(bool isOpen, Encoding encoding, TimeProvider timeProvider, double siteLatitude, double siteLongitude) + public FakeMeadeLX200SerialDevice(ILogger logger, Encoding encoding, TimeProvider timeProvider, double siteLatitude, double siteLongitude, bool isOpen) { _transform = new Transform(timeProvider) { @@ -46,6 +49,7 @@ public FakeMeadeLX200SerialDevice(bool isOpen, Encoding encoding, TimeProvider t // should be 0 _raAngle = CalcAngle24h(_transform.RATopocentric); + _logger = logger; IsOpen = isOpen; Encoding = encoding; } @@ -54,69 +58,92 @@ public FakeMeadeLX200SerialDevice(bool isOpen, Encoding encoding, TimeProvider t public Encoding Encoding { get; private set; } + public Task WaitAsync(CancellationToken cancellationToken) => _semaphore.WaitAsync(cancellationToken); + + public int Release() => _semaphore.Release(); + public void Dispose() => TryClose(); public bool TryClose() { - lock (_lockObj) - { - IsOpen = false; - _responseBuffer.Clear(); - _responsePointer = 0; + IsOpen = false; + _responseBuffer.Clear(); + _responsePointer = 0; + + return true; + } - return true; + public async ValueTask TryReadExactlyRawAsync(Memory message, CancellationToken cancellationToken) + { + var messageStr = await TryReadExactlyAsync(message.Length, cancellationToken); + if (messageStr is null) + { + return false; } + + return Encoding.GetBytes(messageStr, message.Span) == message.Length; } - public bool TryReadExactly(int count, [NotNullWhen(true)] out ReadOnlySpan message) + public ValueTask TryReadExactlyAsync(int count, CancellationToken cancellationToken) { - lock (_lockObj) + if (_responsePointer + count <= _responseBuffer.Length) { - if (_responsePointer + count <= _responseBuffer.Length) - { - var chars = new char[count]; - _responseBuffer.CopyTo(_responsePointer, chars, count); - _responsePointer += count; + var chars = new char[count]; + _responseBuffer.CopyTo(_responsePointer, chars, count); + _responsePointer += count; + ClearBufferIfEmpty(); - ClearBufferIfEmpty(); + var message = new string(chars); - message = Encoding.GetBytes(chars); - return true; - } +#if DEBUG + _logger.LogTrace("<-- {Response} ({Length})", message.ReplaceNonPrintableWithHex(), message.Length); +#endif - message = null; - return false; + return ValueTask.FromResult(message); } + + return ValueTask.FromResult(null); } - public bool TryReadTerminated([NotNullWhen(true)] out ReadOnlySpan message, ReadOnlySpan terminators) + public async ValueTask TryReadTerminatedRawAsync(Memory message, ReadOnlyMemory terminators, CancellationToken cancellationToken) { - lock (_lockObj) + var messageStr = await TryReadTerminatedAsync(terminators, cancellationToken); + if (messageStr is null) { - var chars = new char[_responseBuffer.Length - _responsePointer]; - var terminatorChars = Encoding.GetString(terminators); + return -1; + } + + return Encoding.GetBytes(messageStr, message.Span); + } + + public ValueTask TryReadTerminatedAsync(ReadOnlyMemory terminators, CancellationToken cancellationToken) + { + var chars = new char[_responseBuffer.Length - _responsePointer]; + var terminatorChars = Encoding.GetString(terminators.Span); - int i = 0; - while (_responsePointer < _responseBuffer.Length) + int i = 0; + while (_responsePointer < _responseBuffer.Length) + { + var @char = _responseBuffer[_responsePointer++]; + + if (terminatorChars.Contains(@char)) { - var @char = _responseBuffer[_responsePointer++]; + ClearBufferIfEmpty(); - if (terminatorChars.Contains(@char)) - { - ClearBufferIfEmpty(); + var message = new string(chars, 0, i); - message = Encoding.GetBytes(chars[0..i]); - return true; - } - else - { - chars[i++] = @char; - } +#if DEBUG + _logger.LogTrace("<-- {Response}", (message + @char).ReplaceNonPrintableWithHex()); +#endif + return ValueTask.FromResult(new string(chars, 0, i)); + } + else + { + chars[i++] = @char; } - - message = null; - return false; } + + return ValueTask.FromResult(null); } private void ClearBufferIfEmpty() @@ -128,93 +155,117 @@ private void ClearBufferIfEmpty() } } - public bool TryWrite(ReadOnlySpan data) + public ValueTask TryWriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { - var dataStr = Encoding.GetString(data); + var dataStr = Encoding.GetString(data.Span); - lock (_lockObj) +#if DEBUG + _logger.LogTrace("--> {Message}", dataStr.ReplaceNonPrintableWithHex()); +#endif + switch (dataStr) { - switch (dataStr) - { - case ":GVP#": - _responseBuffer.Append("Fake LX200 Mount#"); - return true; - - case ":GW#": - _responseBuffer.AppendFormat("{0}{1}{2:0}", - _alignmentMode switch { AlignmentMode.GermanPolar => 'G', _ => '?' }, - _isTracking ? 'T' : 'N', - _alignmentStars - ); - return true; - - case ":AL#": - _isTracking = false; - return true; - - case ":AP#": - _isTracking = true; - return true; - - case ":GVN#": - _responseBuffer.Append("A4s4#"); - return true; - - case ":GR#": + case ":GVP#": + _responseBuffer.Append("Fake LX200 Mount#"); + break; + + case ":GW#": + _responseBuffer.AppendFormat("{0}{1}{2:0}", + _alignmentMode switch { AlignmentMode.GermanPolar => 'G', _ => '?' }, + _isTracking ? 'T' : 'N', + _alignmentStars + ); + break; + + case ":AL#": + _isTracking = false; + break; + + case ":AP#": + _isTracking = true; + break; + + case ":GVN#": + _responseBuffer.Append("A4s4#"); + break; + + case ":GR#": + lock (_lockObj) + { RespondHMS(_transform.RATopocentric); - return true; + } + break; - case ":Gr#": + case ":Gr#": + lock (_lockObj) + { RespondHMS(_targetRa); - return true; + } + break; - case ":GD#": + case ":GD#": + lock (_lockObj) + { RespondDMS(_transform.DECTopocentric); - return true; + } + break; - case ":Gd#": + case ":Gd#": + lock (_lockObj) + { RespondDMS(_targetDec); - return true; + } + break; - case ":GS#": + case ":GS#": + lock (_lockObj) + { _responseBuffer.AppendFormat("{0}#", HoursToHMS(SiderealTime, withFrac: false)); - return true; + } + break; - case ":Gt#": + case ":Gt#": + lock (_lockObj) + { _responseBuffer.AppendFormat("{0}#", DegreesToDM(_transform.SiteLatitude)); - return true; + } + break; - case ":GT#": - var (trackingHz, tracking10thHz) = Math.DivRem(_trackingFrequency, 10); - _responseBuffer.AppendFormat("{0:00}.{1:0}#", trackingHz, tracking10thHz); - return true; + case ":GT#": + var (trackingHz, tracking10thHz) = Math.DivRem(_trackingFrequency, 10); + _responseBuffer.AppendFormat("{0:00}.{1:0}#", trackingHz, tracking10thHz); + break; - case ":U#": - _highPrecision = !_highPrecision; - return true; + case ":U#": + _highPrecision = !_highPrecision; + break; - case ":MS#": - _responseBuffer.Append(SlewToTarget()); - return true; + case ":MS#": + _responseBuffer.Append(SlewToTarget()); + break; - case ":D#": + case ":D#": + lock (_lockObj) + { _responseBuffer.Append(_isSlewing ? "\x7f#" : "#"); - return true; + } + break; - default: - if (dataStr.StartsWith(":Sr", StringComparison.Ordinal)) - { - _responseBuffer.Append(ParseTargetRa(dataStr) ? '1' : '0'); - return true; - } - else if (dataStr.StartsWith(":Sd", StringComparison.Ordinal)) - { - _responseBuffer.Append(ParseTargetDec(dataStr) ? '1' : '0'); - return true; - } - return false; - } + default: + if (dataStr.StartsWith(":Sr", StringComparison.Ordinal)) + { + _responseBuffer.Append(ParseTargetRa(dataStr) ? '1' : '0'); + } + else if (dataStr.StartsWith(":Sd", StringComparison.Ordinal)) + { + _responseBuffer.Append(ParseTargetDec(dataStr) ? '1' : '0'); + } + else + { + return ValueTask.FromResult(false); + } + break; } + return ValueTask.FromResult(true); void RespondHMS(double ra) => _responseBuffer.AppendFormat("{0}#", _highPrecision ? HoursToHMS(ra, withFrac: false) : HoursToHMT(ra)); @@ -358,6 +409,7 @@ private char SlewToTarget() var hourAngleAtSlewTime = ConditionRA(_raAngle); var period = TimeSpan.FromMilliseconds(100); + var state = new SlewSate(_transform.RATopocentric, _transform.DECTopocentric, _slewRate, hourAngleAtSlewTime, period); var slewTimer = timeProvider.CreateTimer(SlewTimerCallback, state, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); @@ -365,7 +417,7 @@ private char SlewToTarget() Interlocked.Exchange(ref _slewTimer, slewTimer)?.Dispose(); slewTimer.Change(period, period); - + return '0'; } @@ -375,45 +427,48 @@ private char SlewToTarget() /// state is of type private void SlewTimerCallback(object? state) { - if (state is SlewSate slewState) + if (state is SlewSate slewState && IsOpen && _isSlewing) { var slewRatePerPeriod = slewState.SlewRate * slewState.Period.TotalSeconds; + bool isRaReached; + bool isDecReached; lock (_lockObj) { _transform.RefreshDateTimeFromTimeProvider(); + var targetDec = _targetDec; + var targetRa = _targetRa; + var decTopo = _transform.DECTopocentric; + var ha24h = ConditionRA(_raAngle); // this is too simplistic, i.e. it does not respect the meridian - - var targetHourAngle = CalcAngle24h(_targetRa); + var targetHourAngle = CalcAngle24h(targetRa); var raDirPositive = targetHourAngle > slewState.HourAngleAtSlewTime; - var decDirPositive = _targetDec > slewState.DecAtSlewTime; + var decDirPositive = targetDec > slewState.DecAtSlewTime; var raSlewRate = (raDirPositive ? DEG2HOURS : -DEG2HOURS) * slewRatePerPeriod; var decSlewRate = (decDirPositive ? 1 : -1) * slewRatePerPeriod; - var ha24h = ConditionRA(_raAngle); var haNext = ha24h + raSlewRate; - var decNext = _transform.DECTopocentric + decSlewRate; + var decNext = decTopo + decSlewRate; double haDiff = haNext - targetHourAngle; - bool isRaReached = raDirPositive switch + isRaReached = raDirPositive switch { true => haNext >= targetHourAngle, false => haNext <= targetHourAngle }; - var isDecReached = decDirPositive switch + isDecReached = decDirPositive switch { - true => decNext >= _targetDec, - false => decNext <= _targetDec + true => decNext >= targetDec, + false => decNext <= targetDec }; var ra = CalcAngle24h(ConditionRA(haNext)); var dec = Math.Min(90, Math.Max(decNext, -90)); if (isRaReached && isDecReached) { - _transform.SetTopocentric(_targetRa, _targetDec); + _transform.SetTopocentric(targetRa, targetDec); _isSlewing = false; - Interlocked.Exchange(ref _slewTimer, null)?.Dispose(); } else if (isRaReached) { @@ -430,6 +485,11 @@ private void SlewTimerCallback(object? state) _raAngle += raSlewRate - (isRaReached ? haDiff : 0); } + + if (isRaReached && isDecReached) + { + Interlocked.Exchange(ref _slewTimer, null)?.Dispose(); + } } } diff --git a/src/TianWen.Lib/Devices/IExternal.cs b/src/TianWen.Lib/Devices/IExternal.cs index 445ba42..217af90 100644 --- a/src/TianWen.Lib/Devices/IExternal.cs +++ b/src/TianWen.Lib/Devices/IExternal.cs @@ -99,6 +99,50 @@ public async ValueTask CatchAsync(Func> as } } + /// + /// Asynchronously awaits , returning default if an exception occured. + /// + /// + /// + /// + /// + /// + public async Task CatchAsync(Func asyncFunc, CancellationToken cancellationToken) + { + try + { + await asyncFunc(cancellationToken).ConfigureAwait(false); + + return true; + } + catch (Exception ex) + { + AppLogger.LogError(ex, "Exception {Message} while executing: {Method}", ex.Message, asyncFunc.Method.Name); + return false; + } + } + + /// + /// Asynchronously awaits , returning default if an exception occured. + /// + /// + /// + /// + /// + /// + public async Task CatchAsync(Func> asyncFunc, CancellationToken cancellationToken, T @default = default) where T : struct + { + try + { + return await asyncFunc(cancellationToken); + } + catch (Exception ex) + { + AppLogger.LogError(ex, "Exception {Message} while executing: {Method}", ex.Message, asyncFunc.Method.Name); + return @default; + } + } + /// /// Uses to safely execute . /// Returns result or on failure, and logs errors using . diff --git a/src/TianWen.Lib/Devices/IMountDriver.cs b/src/TianWen.Lib/Devices/IMountDriver.cs index 7798533..433d3a5 100644 --- a/src/TianWen.Lib/Devices/IMountDriver.cs +++ b/src/TianWen.Lib/Devices/IMountDriver.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using TianWen.DAL; @@ -40,7 +39,7 @@ public interface IMountDriver : IDeviceDriver bool CanMoveAxis(TelescopeAxis axis); /// - /// Determine the rates at which the telescope may be moved about the specified axis by the method. + /// Determine the rates at which the telescope may be moved about the specified axis by the method. /// /// /// axis rates in degrees per second @@ -51,68 +50,72 @@ public interface IMountDriver : IDeviceDriver /// /// Which axis to move /// One of or 0 to stop/> - void MoveAxis(TelescopeAxis axis, double rate); + ValueTask MoveAxisAsync(TelescopeAxis axis, double rate, CancellationToken cancellationToken); - TrackingSpeed TrackingSpeed { get; set; } + ValueTask GetTrackingSpeedAsync(CancellationToken cancellationToken); + + ValueTask SetTrackingSpeedAsync(TrackingSpeed value, CancellationToken cancellationToken); IReadOnlyList TrackingSpeeds { get; } EquatorialCoordinateType EquatorialSystem { get; } - AlignmentMode Alignment { get; } + ValueTask GetAlignmentAsync(CancellationToken cancellationToken); + + ValueTask IsTrackingAsync(CancellationToken cancellationToken); - bool Tracking { get; set; } + ValueTask SetTrackingAsync(bool tracking, CancellationToken cancellationToken); - bool AtHome { get; } + ValueTask AtHomeAsync(CancellationToken cancellationToken); - bool AtPark { get; } + ValueTask AtParkAsync(CancellationToken cancellationToken); /// - /// Async parking of the mount, will cause to be . + /// Async parking of the mount, will cause to be . /// - void Park(); + ValueTask ParkAsync(CancellationToken cancellationToken); /// - /// Async unparking of the mount, will cause to be . + /// Async unparking of the mount, will cause to be . /// Will throw an exception if is . /// - void Unpark(); + ValueTask UnparkAsync(CancellationToken cancellationToken); /// /// Moves the mount in the specified angular direction for the specified time (). - /// The directions are in the Equatorial coordinate system only, regardless of the mount’s . The distance moved depends on the and , + /// The directions are in the Equatorial coordinate system only, regardless of the mount’s . The distance moved depends on the and , /// as well as . /// /// equatorial direction /// Duration (will be rounded to nearest millisecond internally) - void PulseGuide(GuideDirection direction, TimeSpan duration); + ValueTask PulseGuideAsync(GuideDirection direction, TimeSpan duration, CancellationToken cancellationToken); /// - /// True if slewing as a result of or . + /// True if slewing as a result of or . /// - bool IsSlewing { get; } + ValueTask IsSlewingAsync(CancellationToken cancellationToken); /// - /// True when a pulse guide command is still on-going ( + /// True when a pulse guide command is still on-going ( /// - bool IsPulseGuiding { get; } + ValueTask IsPulseGuidingAsync(CancellationToken cancellationToken); /// - /// Read or set a secular rate of change to the mount's (seconds of RA per sidereal second). + /// Read or set a secular rate of change to the mount's (seconds of RA per sidereal second). /// https://ascom-standards.org/newdocs/trkoffset-faq.html#trkoffset-faq /// double RightAscensionRate { get; set; } /// - /// Read or set a secular rate of change to the mount's in arc seconds per UTC (SI) second. + /// Read or set a secular rate of change to the mount's in arc seconds per UTC (SI) second. /// https://ascom-standards.org/newdocs/trkoffset-faq.html#trkoffset-faq /// double DeclinationRate { get; set; } /// - /// The current rate of change of (deg/sec) for guiding, typically via . + /// The current rate of change of (deg/sec) for guiding, typically via . /// - /// This is the rate for both hardware/relay guiding and for + /// This is the rate for both hardware/relay guiding and for /// The mount may not support separate right ascension and declination guide rates. If so, setting either rate must set the other to the same value. /// This value must be set to a default upon startup. /// @@ -120,9 +123,9 @@ public interface IMountDriver : IDeviceDriver double GuideRateRightAscension { get; set; } /// - /// The current rate of change of (deg/sec) for guiding, typically via . + /// The current rate of change of (deg/sec) for guiding, typically via . /// - /// This is the rate for both hardware/relay guiding and for + /// This is the rate for both hardware/relay guiding and for /// The mount may not support separate right ascension and declination guide rates. If so, setting either rate must set the other to the same value. /// This value must be set to a default upon startup. /// @@ -132,24 +135,24 @@ public interface IMountDriver : IDeviceDriver /// /// Stops all movement due to slew (might revert to previous tracking mode). /// - void AbortSlew(); + ValueTask AbortSlewAsync(CancellationToken cancellationToken); /// /// Slews to given equatorial coordinates (RA, Dec) in the mounts native epoch, . /// /// RA in hours (0..24) /// Declination in degrees (-90..90) - Task BeginSlewRaDecAsync(double ra, double dec, CancellationToken cancellationToken = default); + ValueTask BeginSlewRaDecAsync(double ra, double dec, CancellationToken cancellationToken); /// /// Slews to given equatorial coordinates (HA, Dec) in the mounts native epoch, . - /// Uses current to convert to RA. - /// Succeeds if and succeeds. + /// Uses current to convert to RA. + /// Succeeds if and succeeds. /// - /// HA in hours (-12..12), as returned by + /// HA in hours (-12..12), as returned by /// Declination in degrees (-90..90) /// Completed task if slewing was started successfully - Task BeginSlewHourAngleDecAsync(double ha, double dec, CancellationToken cancellationToken = default) + async ValueTask BeginSlewHourAngleDecAsync(double ha, double dec, CancellationToken cancellationToken) { if (!Connected) { @@ -166,7 +169,7 @@ Task BeginSlewHourAngleDecAsync(double ha, double dec, CancellationToken cancell throw new ArgumentException("Declination must be in [-90..90]", nameof(dec)); } - return BeginSlewRaDecAsync(ConditionRA(SiderealTime - ha - 12), dec, cancellationToken); + await BeginSlewRaDecAsync(ConditionRA(await GetSiderealTimeAsync(cancellationToken) - ha - 12), dec, cancellationToken); } /// @@ -175,14 +178,14 @@ Task BeginSlewHourAngleDecAsync(double ha, double dec, CancellationToken cancell /// /// RA in hours (0..24) /// Declination in degrees (-90..90) - void SyncRaDec(double ra, double dec); + ValueTask SyncRaDecAsync(double ra, double dec, CancellationToken cancellationToken); /// - /// Calls by first transforming J2000 coordinates to native ones using . + /// Calls by first transforming J2000 coordinates to native ones using . /// /// RA in hours (0..24) /// Declination in degrees (-90..90) - void SyncRaDecJ2000(double ra, double dec) + async ValueTask SyncRaDecJ2000Async(double ra, double dec, CancellationToken cancellationToken) { if (!Connected) { @@ -194,14 +197,14 @@ void SyncRaDecJ2000(double ra, double dec) throw new InvalidOperationException("Device does not support syncing"); } - if (!TryGetTransform(out var transform)) + if (await TryGetTransformAsync(cancellationToken) is not { } transform) { throw new InvalidOperationException("Failed intialize coordinate transform function"); } - if (TryTransformJ2000ToMountNative(transform, ra, dec, updateTime: false, out var raMount, out var decMount, out _, out _)) + if (await TryTransformJ2000ToMountNativeAsync(transform, ra, dec, updateTime: false, cancellationToken) is { } nativeCoords) { - SyncRaDec(raMount, decMount); + await SyncRaDecAsync(nativeCoords.RaMount, nativeCoords.DecMount, cancellationToken); } else { @@ -209,41 +212,36 @@ void SyncRaDecJ2000(double ra, double dec) } } + /// + /// The UTC date/time of the telescope's internal clock. + /// Must be initalised via from system time if no internal clock is supported. + /// . + /// + ValueTask TryGetUTCDateFromMountAsync(CancellationToken cancellationToken); + /// /// The UTC date/time of the telescope's internal clock. /// Must be initalised from system time if no internal clock is supported. /// . /// - DateTime? UTCDate { get; set; } + ValueTask SetUTCDateAsync(DateTime dateTime, CancellationToken cancellationToken); /// - /// Returns true iff was updated succcessfully when setting. + /// Returns true iff time was updated via . + /// Will be equal to if true. /// bool TimeIsSetByUs { get; } - bool TryGetUTCDate(out DateTime dateTime) - { - try - { - if (Connected && UTCDate is DateTime utc) - { - dateTime = utc; - return true; - } - } - catch - { - // ignore - } + /// + /// Get side of pier as an indicator of pointing state/meridian flip indicator. + /// + ValueTask GetSideOfPierAsync(CancellationToken cancellationToken); - dateTime = DateTime.MinValue; - return false; - } /// - /// Side of pier as an indicator of pointing state/meridian flip indicator. + /// Force a flip of the mount, if is supported. /// - PointingState SideOfPier { get; set; } + ValueTask SetSideOfPierAsync(PointingState pointingState, CancellationToken cancellationToken); /// /// Predict side of pier for German equatorial mounts. @@ -251,125 +249,168 @@ bool TryGetUTCDate(out DateTime dateTime) /// The destination right ascension(hours) /// The destination declination (degrees, positive North) /// - PointingState DestinationSideOfPier(double ra, double dec); + ValueTask DestinationSideOfPierAsync(double ra, double dec, CancellationToken cancellationToken); /// - /// Uses and equatorial coordinates as of now (, ) - /// To calculate the that the telescope should be on if one where to slew there now. + /// Uses and equatorial coordinates as of now (, ) + /// To calculate the that the telescope should be on if one where to slew there now. /// - PointingState ExpectedSideOfPier => Connected ? DestinationSideOfPier(RightAscension, Declination) : PointingState.Unknown; + async ValueTask GetExpectedSideOfPierAsync(CancellationToken cancellationToken) + { + if (Connected) + { + return await DestinationSideOfPierAsync(await GetRightAscensionAsync(cancellationToken), await GetDeclinationAsync(cancellationToken), cancellationToken); + } + else + { + return PointingState.Unknown; + } + } /// - /// The current hour angle, using and , (-12,12). + /// The current hour angle, using and , (-12,12). /// - double HourAngle => Connected ? ConditionHA(SiderealTime - RightAscension) : double.NaN; + async ValueTask GetHourAngleAsync(CancellationToken cancellationToken) + { + if (Connected) + { + return ConditionHA(await GetSiderealTimeAsync(cancellationToken) - await GetRightAscensionAsync(cancellationToken)); + } + else + { + return double.NaN; + } + } /// /// The local apparent sidereal time from the telescope's internal clock (hours, sidereal). /// - double SiderealTime { get; } + ValueTask GetSiderealTimeAsync(CancellationToken cancellationToken); /// - /// The right ascension (hours) of the telescope's current equatorial coordinates, in the coordinate system given by the property. + /// Gets the right ascension (hours) of the telescope's current equatorial coordinates, in the coordinate system given by the property. /// - double RightAscension { get; } + ValueTask GetRightAscensionAsync(CancellationToken cancellationToken); /// /// The declination (degrees) of the telescope's current equatorial coordinates, in the coordinate system given by the property. /// - double Declination { get; } + ValueTask GetDeclinationAsync(CancellationToken cancellationToken); + + /// + /// Gets the right ascension (hours) of the telescope's intended right ascension, in the coordinate system given by the property. + /// + ValueTask GetTargetRightAscensionAsync(CancellationToken cancellationToken); + + /// + /// Gets declination (degrees) of the telescope's intended declination, in the coordinate system given by the property. + /// + ValueTask GetTargetDeclinationAsync(CancellationToken cancellationToken); /// /// The elevation above mean sea level (meters) of the site at which the telescope is located. /// - double SiteElevation { get; set; } + ValueTask GetSiteElevationAsync(CancellationToken cancellationToken); + + /// + /// Set elevation + /// + /// + /// + ValueTask SetSiteElevationAsync(double elevation, CancellationToken cancellationToken); /// /// The geodetic(map) latitude (degrees, positive North, WGS84) of the site at which the telescope is located. /// - double SiteLatitude { get; set; } + ValueTask GetSiteLatitudeAsync(CancellationToken cancellationToken); + + /// + /// Sets the latitude for the site. + /// + /// Token to monitor for cancellation requests. + /// + ValueTask SetSiteLatitudeAsync(double latitude, CancellationToken cancellationToken); /// /// The longitude (degrees, positive East, WGS84) of the site at which the telescope is located. /// - double SiteLongitude { get; set; } + ValueTask GetSiteLongitudeAsync(CancellationToken cancellationToken); + + ValueTask SetSiteLongitudeAsync(double longitude, CancellationToken cancellationToken); /// - /// Initialises using standard pressure and atmosphere. Please adjust if available. + /// Initialises a using standard pressure and atmosphere. Please adjust if available. /// - /// - /// - bool TryGetTransform([NotNullWhen(true)] out Transform? transform) + /// Initialized transform or null if not connected/date time could not be established. + async ValueTask TryGetTransformAsync(CancellationToken cancellationToken) { - if (Connected && TryGetUTCDate(out var utc)) + if (Connected && await TryGetUTCDateFromMountAsync(cancellationToken) is { } utc) { - transform = new Transform(External.TimeProvider) + return new Transform(External.TimeProvider) { - SiteElevation = SiteElevation, - SiteLatitude = SiteLatitude, - SiteLongitude = SiteLongitude, + SiteElevation = await GetSiteElevationAsync(cancellationToken), + SiteLatitude = await GetSiteLatitudeAsync(cancellationToken), + SiteLongitude = await GetSiteLongitudeAsync(cancellationToken), SitePressure = 1010, // TODO standard atmosphere SiteTemperature = 10, // TODO check either online or if compatible devices connected DateTime = utc, Refraction = true // TODO assumes that driver does not support/do refraction }; - - return true; } - transform = null; - return false; + return null; } /// /// Not reentrant if using a shared . /// - /// /// - /// /// /// - /// true if transform was successful. - bool TryTransformJ2000ToMountNative(Transform transform, double raJ2000, double decJ2000, bool updateTime, out double raMount, out double decMount, out double az, out double alt) + /// transformed coordinates on success + async ValueTask<(double RaMount, double DecMount, double Az, double Alt)?> TryTransformJ2000ToMountNativeAsync(Transform transform, double raJ2000, double decJ2000, bool updateTime, CancellationToken cancellationToken) { - if (Connected && updateTime && TryGetUTCDate(out var utc)) + if (Connected && updateTime && await TryGetUTCDateFromMountAsync(cancellationToken) is { } utc) { transform.DateTime = utc; } else if (updateTime || !Connected) { - raMount = double.NaN; - decMount = double.NaN; - az = double.NaN; - alt = double.NaN; - return false; + return null; } transform.SetJ2000(raJ2000, decJ2000); transform.Refresh(); - (raMount, decMount) = EquatorialSystem switch + var (raMount, decMount) = EquatorialSystem switch { EquatorialCoordinateType.J2000 => (transform.RAJ2000, transform.DecJ2000), EquatorialCoordinateType.Topocentric => (transform.RAApparent, transform.DECApparent), _ => (double.NaN, double.NaN) }; - az = transform.AzimuthTopocentric; - alt = transform.ElevationTopocentric; + var az = transform.AzimuthTopocentric; + var alt = transform.ElevationTopocentric; - return !double.IsNaN(raMount) && !double.IsNaN(decMount) && !double.IsNaN(az) && !double.IsNaN(alt); + if (!double.IsNaN(raMount) && !double.IsNaN(decMount) && !double.IsNaN(az) && !double.IsNaN(alt)) + { + return (raMount, decMount, az, alt); + } + else + { + return null; + } } - public bool IsOnSamePierSide(double hourAngleAtSlewTime) + public async Task IsOnSamePierSideAsync(double hourAngleAtSlewTime, CancellationToken cancellationToken) { - var pierSide = External.Catch(() => SideOfPier, PointingState.Unknown); - var currentHourAngle = External.Catch(() => HourAngle, double.NaN); - return pierSide == ExpectedSideOfPier + var pierSide = await External.CatchAsync(GetSideOfPierAsync, cancellationToken, PointingState.Unknown); + var currentHourAngle = await External.CatchAsync(GetHourAngleAsync, cancellationToken, double.NaN); + return pierSide == await External.CatchAsync(GetExpectedSideOfPierAsync, cancellationToken, PointingState.Unknown) && !double.IsNaN(currentHourAngle) && (pierSide != PointingState.Unknown || Math.Sign(hourAngleAtSlewTime) == Math.Sign(currentHourAngle)); } - public Task BeginSlewToZenithAsync(TimeSpan distMeridian, CancellationToken cancellationToken = default) + public async ValueTask BeginSlewToZenithAsync(TimeSpan distMeridian, CancellationToken cancellationToken) { if (!Connected) { @@ -381,7 +422,7 @@ public Task BeginSlewToZenithAsync(TimeSpan distMeridian, CancellationToken canc throw new InvalidOperationException("Device does not support slewing"); } - return BeginSlewHourAngleDecAsync((TimeSpan.FromHours(12) - distMeridian).TotalHours, SiteLatitude, cancellationToken); + await BeginSlewHourAngleDecAsync((TimeSpan.FromHours(12) - distMeridian).TotalHours, await GetSiteLatitudeAsync(cancellationToken), cancellationToken); } /// @@ -397,11 +438,10 @@ public async Task BeginSlewToTargetAsync(Target target, int minAbove var az = double.NaN; var alt = double.NaN; var dsop = PointingState.Unknown; - if (!TryGetTransform(out var transform) - || !TryTransformJ2000ToMountNative(transform, target.RA, target.Dec, updateTime: false, out var raMount, out var decMount, out az, out alt) - || double.IsNaN(alt) - || alt < minAboveHorizonDegrees - || (dsop = DestinationSideOfPier(raMount, decMount)) == PointingState.Unknown + if (await TryGetTransformAsync(cancellationToken) is not { } transform + || await TryTransformJ2000ToMountNativeAsync(transform, target.RA, target.Dec, updateTime: false, cancellationToken) is not { } nativeCoords + || nativeCoords.Alt < minAboveHorizonDegrees + || (dsop = await DestinationSideOfPierAsync(nativeCoords.RaMount, nativeCoords.DecMount, cancellationToken)) == PointingState.Unknown ) { @@ -420,35 +460,35 @@ public async Task BeginSlewToTargetAsync(Target target, int minAbove } } - var hourAngle = HourAngle; - await BeginSlewRaDecAsync(raMount, decMount, cancellationToken); + var hourAngle = await GetHourAngleAsync(cancellationToken); + await BeginSlewRaDecAsync(nativeCoords.RaMount, nativeCoords.DecMount, cancellationToken); return new SlewResult(SlewPostCondition.Slewing, hourAngle); } public async ValueTask WaitForSlewCompleteAsync(CancellationToken cancellationToken) { - var period = TimeSpan.FromMilliseconds(250); + var period = TimeSpan.FromMilliseconds(251); var maxSlewTime = TimeSpan.FromSeconds(MAX_FAILSAFE); - if (!TryGetUTCDate(out var slewStartTime)) + if (await TryGetUTCDateFromMountAsync(cancellationToken) is not { } slewStartTime) { return false; } while (!cancellationToken.IsCancellationRequested - && IsSlewing - && TryGetUTCDate(out var now) + && await IsSlewingAsync(cancellationToken) + && await TryGetUTCDateFromMountAsync(cancellationToken) is { } now && now - slewStartTime < maxSlewTime ) { await External.SleepAsync(period, cancellationToken); } - var isStillSlewing = IsSlewing; + var isStillSlewing = await IsSlewingAsync(cancellationToken); if (isStillSlewing && cancellationToken.IsCancellationRequested) { - AbortSlew(); + await AbortSlewAsync(cancellationToken); return false; } @@ -456,20 +496,25 @@ public async ValueTask WaitForSlewCompleteAsync(CancellationToken cancella return !isStillSlewing; } - public bool EnsureTracking(TrackingSpeed speed = TrackingSpeed.Sidereal) + public async ValueTask EnsureTrackingAsync(TrackingSpeed speed = TrackingSpeed.Sidereal, CancellationToken cancellationToken = default) { + if (speed is TrackingSpeed.None) + { + throw new ArgumentException("Tracking speed cannot be None", nameof(speed)); + } + if (!Connected) { return false; } - if (CanSetTracking && (TrackingSpeed != speed || !Tracking)) + if (CanSetTracking && ((await GetTrackingSpeedAsync(cancellationToken)) != speed || !await IsTrackingAsync(cancellationToken))) { - TrackingSpeed = speed; - Tracking = true; + await SetTrackingSpeedAsync(speed, cancellationToken); + await SetTrackingAsync(true, cancellationToken); } - return Tracking; + return await IsTrackingAsync(cancellationToken); } } diff --git a/src/TianWen.Lib/Devices/Meade/MeadeDeviceSource.cs b/src/TianWen.Lib/Devices/Meade/MeadeDeviceSource.cs index b41c066..e4b6480 100644 --- a/src/TianWen.Lib/Devices/Meade/MeadeDeviceSource.cs +++ b/src/TianWen.Lib/Devices/Meade/MeadeDeviceSource.cs @@ -9,52 +9,61 @@ namespace TianWen.Lib.Devices.Meade; internal class MeadeDeviceSource(IExternal external) : IDeviceSource { - public ValueTask CheckSupportAsync(CancellationToken cancellationToken) => ValueTask.FromResult(true); - - /// - /// TODO: Move code from RegisteredDevices and await async on serial port. - /// - /// - /// - public ValueTask DiscoverAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + private Dictionary>? _cachedDevices; - public IEnumerable RegisteredDeviceTypes => [DeviceType.Mount]; + public ValueTask CheckSupportAsync(CancellationToken cancellationToken) => ValueTask.FromResult(true); - public IEnumerable RegisteredDevices(DeviceType deviceType) + private static readonly ReadOnlyMemory HashTerminator = "#"u8.ToArray(); + public async ValueTask DiscoverAsync(CancellationToken cancellationToken = default) { + var devices = new Dictionary>(); + foreach (var portName in external.EnumerateSerialPorts()) { - MeadeDevice? device; try { using var serialDevice = external.OpenSerialDevice(portName, 9600, Encoding.ASCII, TimeSpan.FromMilliseconds(100)); - if (serialDevice.TryWrite(":GVP#"u8) && serialDevice.TryReadTerminated(out var productName, "#"u8) - && productName.StartsWithAny("LX"u8, "Autostar"u8, "Audiostar"u8) - && serialDevice.TryWrite(":GVN#"u8) && serialDevice.TryReadTerminated(out var productNumber, "#"u8) + string? productName; + string? productNumber; + if (await serialDevice.TryWriteAsync(":GVP#", cancellationToken) + && (productName = await serialDevice.TryReadTerminatedAsync(HashTerminator, cancellationToken)) is { } + && (productName.StartsWith("LX") || productName.StartsWith("Autostar") || productName.StartsWith("Audiostar")) + && await serialDevice.TryWriteAsync(":GVN#", cancellationToken) + && (productNumber = await serialDevice.TryReadTerminatedAsync(HashTerminator, cancellationToken)) is { } ) { - var productNameStr = serialDevice.Encoding.GetString(productName); - var productVersionStr = serialDevice.Encoding.GetString(productNumber); - var deviceId = $"{productNameStr}_{productVersionStr}"; + var deviceId = $"{productName}_{productNumber}"; - device = new MeadeDevice(deviceType, deviceId, $"{productNameStr} ({productVersionStr})", portName); - } - else - { - device = null; + var device = new MeadeDevice(DeviceType.Mount, deviceId, $"{productName} ({productNumber})", portName); + + if (devices.TryGetValue(DeviceType.Mount, out var deviceList)) + { + deviceList.Add(device); + } + else + { + devices[DeviceType.Mount] = new List { device }; + } } } catch (Exception ex) { external.AppLogger.LogWarning(ex, "Failed to query device {PortName}", portName); - device = null; } + } - if (device is not null) - { - yield return device; - } + Interlocked.Exchange(ref _cachedDevices, devices); + } + + public IEnumerable RegisteredDeviceTypes => [DeviceType.Mount]; + + public IEnumerable RegisteredDevices(DeviceType deviceType) + { + if (_cachedDevices != null && _cachedDevices.TryGetValue(deviceType, out var devices)) + { + return devices; } + return []; } } \ No newline at end of file diff --git a/src/TianWen.Lib/Devices/MeadeLX200ProtocolMountDriverBase.cs b/src/TianWen.Lib/Devices/MeadeLX200ProtocolMountDriverBase.cs index df4dfb8..608ddde 100644 --- a/src/TianWen.Lib/Devices/MeadeLX200ProtocolMountDriverBase.cs +++ b/src/TianWen.Lib/Devices/MeadeLX200ProtocolMountDriverBase.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Logging; using System; +using System.Buffers; using System.Buffers.Text; using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Threading; @@ -30,15 +30,8 @@ internal abstract class MeadeLX200ProtocolMountDriverBase(TDevice devic { private static readonly Encoding _encoding = Encoding.Latin1; - const int MOVING_STATE_NORMAL = 0; - const int MOVING_STATE_PARKED = 1; - const int MOVING_STATE_PULSE_GUIDING = 2; - const int MOVING_STATE_SLEWING = 3; - const double DEFAULT_GUIDE_RATE = SIDEREAL_RATE * 2d / 3d / 3600d; - private ITimer? _slewTimer; - private int _movingState = MOVING_STATE_NORMAL; private bool? _isSouthernHemisphere; private string _telescopeName = "Unknown"; private string _telescopeFW = "Unknown"; @@ -74,238 +67,242 @@ internal abstract class MeadeLX200ProtocolMountDriverBase(TDevice devic public IReadOnlyList AxisRates(TelescopeAxis axis) => []; - public void MoveAxis(TelescopeAxis axis, double rate) + public ValueTask MoveAxisAsync(TelescopeAxis axis, double rate, CancellationToken cancellationToken) { throw new InvalidOperationException("Moving axis directly is not supported"); } - public TrackingSpeed TrackingSpeed + private static readonly ReadOnlyMemory GTCommand = "GT"u8.ToArray(); + public async ValueTask GetTrackingSpeedAsync(CancellationToken cancellationToken) { - get + var response = await SendAndReceiveAsync(GTCommand, cancellationToken); + if (double.TryParse(response, CultureInfo.InvariantCulture, out var trackingHz)) { - SendAndReceive("GT"u8, out var response); - if (double.TryParse(response, CultureInfo.InvariantCulture, out var trackingHz)) - { - return trackingHz switch - { - >= 59.9 and <= 60.1 => TrackingSpeed.Sidereal, - >= 57.3 and <= 58.9 => TrackingSpeed.Lunar, - _ => TrackingSpeed.None - }; - } - else + return trackingHz switch { - throw new InvalidOperationException($"Failed to convert GT response {_encoding.GetString(response)} to a tracking frequency"); - } + >= 59.9 and <= 60.1 => TrackingSpeed.Sidereal, + >= 57.3 and <= 58.9 => TrackingSpeed.Lunar, + _ => TrackingSpeed.None + }; } + else + { + throw new InvalidOperationException($"Failed to convert GT response {response} to a tracking frequency"); + } + } - set + private readonly ReadOnlyMemory TQCommand = "TQ"u8.ToArray(); + private readonly ReadOnlyMemory TLCommand = "TL"u8.ToArray(); + public ValueTask SetTrackingSpeedAsync(TrackingSpeed value, CancellationToken cancellationToken) + { + var speed = value switch { - var speed = value switch - { - TrackingSpeed.Sidereal or TrackingSpeed.Solar => "TQ"u8, - TrackingSpeed.Lunar => "TL"u8, - _ => throw new ArgumentException($"Tracking speed {value} is not yet supported!", nameof(value)) - }; + TrackingSpeed.Sidereal or TrackingSpeed.Solar => TQCommand, + TrackingSpeed.Lunar => TLCommand, + _ => throw new ArgumentException($"Tracking speed {value} is not yet supported!", nameof(value)) + }; - Send(speed); - } + return SendWithoutResponseAsync(speed, cancellationToken); } public IReadOnlyList TrackingSpeeds => [TrackingSpeed.Sidereal, TrackingSpeed.Lunar]; public EquatorialCoordinateType EquatorialSystem => EquatorialCoordinateType.Topocentric; - public bool Tracking + public async ValueTask IsTrackingAsync(CancellationToken cancellationToken) { - get + var (_, tracking, _) = await AlignmentDetailsAsync(cancellationToken); + return tracking; + } + + private readonly ReadOnlyMemory APCommand = "AP"u8.ToArray(); + private readonly ReadOnlyMemory ALCommand = "AL"u8.ToArray(); + public async ValueTask SetTrackingAsync(bool tracking, CancellationToken cancellationToken) + { + if (await IsPulseGuidingAsync(cancellationToken)) { - var (_, tracking, _) = AlignmentDetails; - return tracking; + throw new InvalidOperationException($"Cannot set tracking={tracking} while pulse guiding"); } - set + if (await IsSlewingAsync(cancellationToken)) { - if (IsPulseGuiding) - { - throw new InvalidOperationException($"Cannot set tracking={value} while pulse guiding"); - } - - if (IsSlewing) - { - throw new InvalidOperationException($"Cannot set tracking={value} while slewing"); - } - - Send(value ? "AP"u8 : "AL"u8); + throw new InvalidOperationException($"Cannot set tracking={tracking} while slewing"); } + + await SendWithoutResponseAsync(tracking ? APCommand : ALCommand, cancellationToken); } - public AlignmentMode Alignment + public async ValueTask GetAlignmentAsync(CancellationToken cancellationToken) { - get - { - var (mode, _, _) = AlignmentDetails; - return mode; - } + var (mode, _, _) = await AlignmentDetailsAsync(cancellationToken); + return mode; } - private (AlignmentMode Mode, bool Tracking, int AlignmentStars) AlignmentDetails + private static readonly ReadOnlyMemory GWCommand = "GW"u8.ToArray(); + private async ValueTask<(AlignmentMode Mode, bool Tracking, int AlignmentStars)> AlignmentDetailsAsync(CancellationToken cancellationToken) { - get + // TODO LX800 fixed GW response not being terminated, account for that + var response = await SendAndReceiveExactlyAsync(GWCommand, 3, cancellationToken); + if (response is { Length: 3 }) { - // TODO LX800 fixed GW response not being terminated, account for that - SendAndReceive("GW"u8, out var response, count: 3); - if (response is { Length: 3 }) + var mode = response[0] switch { - var mode = response[0] switch - { - (byte)'A' => AlignmentMode.AltAz, - (byte)'P' => AlignmentMode.Polar, - (byte)'G' => AlignmentMode.GermanPolar, - var invalid => throw new InvalidOperationException($"Invalid alginment mode {invalid} returned") - }; + 'A' => AlignmentMode.AltAz, + 'P' => AlignmentMode.Polar, + 'G' => AlignmentMode.GermanPolar, + var invalid => throw new InvalidOperationException($"Invalid alginment mode {invalid} returned") + }; - var tracking = response[1] == (byte)'T'; + var tracking = response[1] == 'T'; - var alignmentStars = response[2] switch - { - (byte)'1' => 1, - (byte)'2' => 2, - _ => 0 - }; - - return (mode, tracking, alignmentStars); - } - else + var alignmentStars = response[2] switch { - throw new InvalidOperationException($"Failed to parse :GW# response {_encoding.GetString(response)}"); - } + '1' => 1, + '2' => 2, + _ => 0 + }; + + return (mode, tracking, alignmentStars); + } + else + { + throw new InvalidOperationException($"Failed to parse :GW# response {response}"); } } - public bool AtHome => false; - - public bool AtPark => CheckMovingState(MOVING_STATE_PARKED); - - public bool IsPulseGuiding => CheckMovingState(MOVING_STATE_PULSE_GUIDING); + public ValueTask AtHomeAsync(CancellationToken cancellationToken) => ValueTask.FromResult(false); - public bool IsSlewing => CheckMovingState(MOVING_STATE_SLEWING); + public ValueTask AtParkAsync(CancellationToken cancellationToken) => ValueTask.FromResult(false); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool CheckMovingState(int movingState) => Connected && Interlocked.CompareExchange(ref _movingState, movingState, movingState) == movingState; + public ValueTask IsPulseGuidingAsync(CancellationToken cancellationToken) => ValueTask.FromResult(false); + private static readonly ReadOnlyMemory DCommand = "D"u8.ToArray(); /// /// Uses :D# to check if mount is slewing (use this to update slewing state) /// - private bool IsSlewingFromMount + public async ValueTask IsSlewingAsync(CancellationToken cancellationToken) { - get - { - Send("D"u8); + using var response = ArrayPoolHelper.Rent(10); - if (TryReadTerminated(out var response)) - { - return response is { Length: >= 1 } && response[0] is (byte)'|' or 0x7f; - } - else - { - return false; - } + var bytesRead = await SendAndReceiveRawAsync(DCommand, response, cancellationToken); + var isSlewing = bytesRead is >= 1 && response[0] is (byte)'|' or 0x7f; + + return isSlewing; + } + + public async ValueTask TryGetUTCDateFromMountAsync(CancellationToken cancellationToken) + { + if (!Connected) + { + return null; } + + var localDate = await GetLocalDateAsync(cancellationToken); + var localTime = await GetLocalTimeAsync(cancellationToken); + var utcOffset = await GetUtcCorrectionAsync(cancellationToken); + return DateTime.SpecifyKind(localDate.Add(localTime).Add(utcOffset), DateTimeKind.Utc); } - public DateTime? UTCDate + public async ValueTask SetUTCDateAsync(DateTime value, CancellationToken cancellationToken) { - get => DateTime.SpecifyKind(LocalDate.Add(LocalTime).Add(UtcCorrection), DateTimeKind.Utc); + var utcOffset = await GetUtcCorrectionAsync(cancellationToken); + if (!(value is { Kind: DateTimeKind.Utc } utcDate) || _deviceInfo.SerialDevice is not { IsOpen: true } port) + { + return; + } - set + using var buffer = ArrayPoolHelper.Rent(2 + 8); + try { - var offset = UtcCorrection; - if (value is { Kind: DateTimeKind.Utc } utcDate) - { - Span buffer = stackalloc byte[2 + 8]; - var adjustedDateTime = utcDate - offset; + var adjustedDateTime = utcDate - utcOffset; - "SL"u8.CopyTo(buffer); + // acquire lock in this method directly as we might potentially send out two read commands + await port.WaitAsync(cancellationToken); - if (!adjustedDateTime.TryFormat(buffer[2..], out _, "HH:mm:ss", CultureInfo.InvariantCulture)) - { - throw new InvalidOperationException($"Failed to convert {value} to HH:mm:ss"); - } + "SL"u8.CopyTo(buffer); - SendAndReceive(buffer, out var slResponse); - if (slResponse.SequenceEqual("1"u8)) - { - throw new ArgumentException($"Failed to set date to {value}, command was {_encoding.GetString(buffer)} with response {_encoding.GetString(slResponse)}", nameof(value)); - } + if (!adjustedDateTime.TryFormat(buffer.AsSpan(2), out _, "HH:mm:ss", CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException($"Failed to convert {value} to HH:mm:ss"); + } - "SC"u8.CopyTo(buffer); + await SendAsync(port, buffer, cancellationToken); - if (!adjustedDateTime.TryFormat(buffer[2..], out _, "MM/dd/yy", CultureInfo.InvariantCulture)) - { - throw new InvalidOperationException($"Failed to convert {value} to MM/dd/yy"); - } + var slResponse = await port.TryReadTerminatedAsync(Terminators, cancellationToken); + if (slResponse is "1") + { + throw new ArgumentException($"Failed to set date to {value}, command was {_encoding.GetString(buffer)} with response {slResponse}", nameof(value)); + } - SendAndReceive(buffer, out var scResponse); - if (scResponse.SequenceEqual("1"u8)) - { - throw new ArgumentException($"Failed to set date to {value}, command was {_encoding.GetString(buffer)} with response {_encoding.GetString(scResponse)}", nameof(value)); - } + "SC"u8.CopyTo(buffer); - //throwing away these two strings which represent - //Updating Planetary Data# - // # - TimeIsSetByUs = TryReadTerminated(out _) && TryReadTerminated(out _); + if (!adjustedDateTime.TryFormat(buffer.AsSpan(2), out _, "MM/dd/yy", CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException($"Failed to convert {value} to MM/dd/yy"); } - } - } - private DateTime LocalDate - { - get - { - SendAndReceive("GC"u8, out var response); + await SendAsync(port, buffer, cancellationToken); - if (DateTime.TryParseExact(_encoding.GetString(response), "MM/dd/yy", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date)) + var scResponse = await port.TryReadTerminatedAsync(Terminators, cancellationToken); + if (scResponse is "1") { - return date; + throw new ArgumentException($"Failed to set date to {value}, command was {_encoding.GetString(buffer)} with response {scResponse}", nameof(value)); } - throw new InvalidOperationException($"Could not parse response {_encoding.GetString(response)} of GC (get local date)"); + //throwing away these two strings which represent + //Updating Planetary Data# + // # + TimeIsSetByUs = await port.TryReadTerminatedAsync(Terminators, cancellationToken) is not null + && await port.TryReadTerminatedAsync(Terminators, cancellationToken) is not null; + } + finally + { + port.Release(); } } - private TimeSpan LocalTime + private static readonly ReadOnlyMemory GCCommand = "GC"u8.ToArray(); + private async ValueTask GetLocalDateAsync(CancellationToken cancellationToken) { - get + var response = await SendAndReceiveAsync(GCCommand, cancellationToken); + + if (DateTime.TryParseExact(response, "MM/dd/yy", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date)) { - SendAndReceive("GL"u8, out var response); + return date; + } - if (Utf8Parser.TryParse(response, out TimeSpan time, out _)) - { - return time.Modulo24h(); - } + throw new InvalidOperationException($"Could not parse response {response} of GC (get local date)"); + } + + private static readonly ReadOnlyMemory GLCommand = "GL"u8.ToArray(); + private async ValueTask GetLocalTimeAsync(CancellationToken cancellationToken) + { + using var response = ArrayPoolHelper.Rent(10); - throw new InvalidOperationException($"Could not parse response {_encoding.GetString(response)} of GL (get local time)"); + var bytesRead = await SendAndReceiveRawAsync(GLCommand, response, cancellationToken); + + if (bytesRead > 0 && Utf8Parser.TryParse(response.AsSpan(0, bytesRead), out TimeSpan time, out _)) + { + return time.Modulo24h(); } + + throw new InvalidOperationException($"Could not parse response {_encoding.GetString(response)} of GL (get local time)"); } - private TimeSpan UtcCorrection + private static readonly ReadOnlyMemory GGCommand = "GG"u8.ToArray(); + private async ValueTask GetUtcCorrectionAsync(CancellationToken cancellationToken) { - get - { - // :GG# Get UTC offset time - // Returns: sHH# or sHH.H# - // The number of decimal hours to add to local time to convert it to UTC. If the number is a whole number the - // sHH# form is returned, otherwise the longer form is returned. - SendAndReceive("GG"u8, out var response); - if (double.TryParse(response, out var offsetHours)) - { - return TimeSpan.FromHours(offsetHours); - } - - throw new InvalidOperationException($"Could not parse response {_encoding.GetString(response)} of GG (get UTC offset)"); + // :GG# Get UTC offset time + // Returns: sHH# or sHH.H# + // The number of decimal hours to add to local time to convert it to UTC. If the number is a whole number the + // sHH# form is returned, otherwise the longer form is returned. + var response = await SendAndReceiveAsync(GGCommand, cancellationToken); + if (double.TryParse(response, out var offsetHours)) + { + return TimeSpan.FromHours(offsetHours); } + + throw new InvalidOperationException($"Could not parse response {response} of GG (get UTC offset)"); } public bool TimeIsSetByUs { get; private set; } @@ -321,10 +318,6 @@ private TimeSpan UtcCorrection /// Description /// /// - /// IsSlewing - /// The actual slewing state from the mount (not our local value) - /// - /// /// PointingState /// is the calculated side of pier from the mount position (if slewing), /// or the last known value after slewing (as the mount auto-flips) @@ -333,228 +326,229 @@ private TimeSpan UtcCorrection /// /// current RA /// Side of pier calculation result - private (bool IsSlewing, PointingState PointingState, double LST) CheckPointingState(double ra) + private async ValueTask<(PointingState PointingState, double LST)> CheckPointingStateAsync(double ra, CancellationToken cancellationToken) { if (!Connected) { - return (false, PointingState.Unknown, double.NaN); + return (PointingState.Unknown, double.NaN); } - var isSlewing = IsSlewingFromMount; + var isSlewing = await IsSlewingAsync(cancellationToken); - var (raSideOfPier, lst) = CalculateSideOfPier(ra); + var (raSideOfPier, lst) = await CalculateSideOfPierAsync(ra, cancellationToken); - return (isSlewing, isSlewing ? raSideOfPier : PointingState.Normal, lst); + return (isSlewing ? raSideOfPier : PointingState.Normal, lst); } - public PointingState SideOfPier + public async ValueTask GetSideOfPierAsync(CancellationToken cancellationToken) { - get - { - var (_, pointingState, _) = CheckPointingState(RightAscension); - - return pointingState; - } + var (pointingState, _) = await CheckPointingStateAsync(await GetRightAscensionAsync(cancellationToken), cancellationToken); - set => throw new InvalidOperationException("Setting side of pier is not supported"); + return pointingState; } - public double SiderealTime + public ValueTask SetSideOfPierAsync(PointingState pointingState, CancellationToken cancellationToken) + => throw new InvalidOperationException("Setting side of pier is not supported"); + + private static readonly ReadOnlyMemory GSCommand = "GS"u8.ToArray(); + public async ValueTask GetSiderealTimeAsync(CancellationToken cancellationToken) { - get - { - SendAndReceive("GS"u8, out var response); + using var response = ArrayPoolHelper.Rent(10); - if (Utf8Parser.TryParse(response, out TimeSpan time, out _)) - { - return time.Modulo24h().TotalHours; - } + var bytesRead = await SendAndReceiveRawAsync(GSCommand, response, cancellationToken); - throw new InvalidOperationException($"Could not parse response {_encoding.GetString(response)} of GS (get sidereal time)"); + if (bytesRead > 0 && Utf8Parser.TryParse(response.AsSpan(0, bytesRead), out TimeSpan time, out _)) + { + return time.Modulo24h().TotalHours; } + + throw new InvalidOperationException($"Could not parse response {_encoding.GetString(response)} of GS (get sidereal time)"); } - public double RightAscension + public async ValueTask GetRightAscensionAsync(CancellationToken cancellationToken) { - get - { - var (ra, _) = GetRightAscensionWithPrecision(target: false); - return ra; - } + var (ra, _) = await GetRightAscensionWithPrecisionAsync(target: false, cancellationToken); + return ra; } - public double Declination + public async ValueTask GetDeclinationAsync(CancellationToken cancellationToken) { - get - { - var (dec, _) = GetDeclinationWithPrecision(target: false); - return dec; - } + var (dec, _) = await GetDeclinationWithPrecisionAsync(target: false, cancellationToken); + return dec; + } + + public async ValueTask GetTargetRightAscensionAsync(CancellationToken cancellationToken) + { + var (ra, _) = await GetRightAscensionWithPrecisionAsync(target: true, cancellationToken); + return ra; } - public double TargetRightAscension + private async ValueTask SetTargetRightAscensionAsync(double value, CancellationToken cancellationToken) { - get + if (value >= 24) { - var (ra, _) = GetRightAscensionWithPrecision(target: true); - return ra; + throw new ArgumentException("Target right ascension cannot greater or equal 24h", nameof(value)); } - set + if (value < 0) { - if (value >= 24) - { - throw new ArgumentException("Target right ascension cannot greater or equal 24h", nameof(value)); - } + throw new ArgumentException("Target right ascension cannot be less than 0h", nameof(value)); + } - if (value < 0) - { - throw new ArgumentException("Target right ascension cannot be less than 0h", nameof(value)); - } + // :SrHH:MM.T# for low precision (24h) + // :SrHH:MM:SS# for high precision (24h) + var (ra, highPrecision) = await GetRightAscensionWithPrecisionAsync(target: false, cancellationToken); - // :SrHH:MM.T# for low precision (24h) - // :SrHH:MM:SS# for high precision (24h) - var (ra, highPrecision) = GetRightAscensionWithPrecision(target: false); + // convert decimal hours to HH:MM.T (classic LX200 RA Notation) if low precision. T is the decimal part of minutes which is converted into seconds + var targetHms = TimeSpan.FromHours(Math.Abs(value)).Round(highPrecision ? TimeSpanRoundingType.Second : TimeSpanRoundingType.TenthMinute).Modulo24h(); - // convert decimal hours to HH:MM.T (classic LX200 RA Notation) if low precision. T is the decimal part of minutes which is converted into seconds - var targetHms = TimeSpan.FromHours(Math.Abs(value)).Round(highPrecision ? TimeSpanRoundingType.Second : TimeSpanRoundingType.TenthMinute).Modulo24h(); + const int offset = 2; + using var buffer = ArrayPoolHelper.Rent(2 + 2 + 2 + 2 + 1 + (highPrecision ? 1 : 0)); - const int offset = 2; - Span buffer = stackalloc byte[2 + 2 + 2 + 2 + 1 + (highPrecision ? 1 : 0)]; - "Sr"u8.CopyTo(buffer); + "Sr"u8.CopyTo(buffer); - if (targetHms.Hours.TryFormat(buffer[offset..], out int hoursWritten, "00", CultureInfo.InvariantCulture) - && offset + hoursWritten + 1 is int minOffset && minOffset < buffer.Length - && targetHms.Minutes.TryFormat(buffer[minOffset..], out int minutesWritten, "00", CultureInfo.InvariantCulture) - ) - { - buffer[offset + hoursWritten] = (byte)':'; - } - else - { - throw new ArgumentException($"Failed to convert value {value} to HM", nameof(value)); - } + if (targetHms.Hours.TryFormat(buffer.AsSpan(offset), out int hoursWritten, "00", CultureInfo.InvariantCulture) + && offset + hoursWritten + 1 is int minOffset && minOffset < buffer.Length + && targetHms.Minutes.TryFormat(buffer.AsSpan(minOffset), out int minutesWritten, "00", CultureInfo.InvariantCulture) + ) + { + buffer[offset + hoursWritten] = (byte)':'; + } + else + { + throw new ArgumentException($"Failed to convert value {value} to HM", nameof(value)); + } - var secOffset = minOffset + minutesWritten + 1; - if (highPrecision) + var secOffset = minOffset + minutesWritten + 1; + if (highPrecision) + { + buffer[secOffset - 1] = (byte)':'; + if (!targetHms.Seconds.TryFormat(buffer.AsSpan(secOffset), out _, "00", CultureInfo.InvariantCulture)) { - buffer[secOffset - 1] = (byte)':'; - if (!targetHms.Seconds.TryFormat(buffer[secOffset..], out _, "00", CultureInfo.InvariantCulture)) - { - throw new ArgumentException($"Failed to convert {value} to high precision seconds", nameof(value)); - } + throw new ArgumentException($"Failed to convert {value} to high precision seconds", nameof(value)); } - else + } + else + { + buffer[secOffset - 1] = (byte)'.'; + if (!(targetHms.Seconds / 6).TryFormat(buffer.AsSpan(secOffset), out _, "0", CultureInfo.InvariantCulture)) { - buffer[secOffset - 1] = (byte)'.'; - if (!(targetHms.Seconds / 6).TryFormat(buffer[secOffset..], out _, "0", CultureInfo.InvariantCulture)) - { - throw new ArgumentException($"Failed to convert {value} to low precision tenth of minute", nameof(value)); - } + throw new ArgumentException($"Failed to convert {value} to low precision tenth of minute", nameof(value)); } + } - SendAndReceive(buffer, out var response, count: 1); - - if (!response.SequenceEqual("1"u8)) - { - throw new InvalidOperationException($"Failed to set target right ascension to {HoursToHMS(value)}, using command {_encoding.GetString(buffer)}, response={_encoding.GetString(response)}"); - } + var response = await SendAndReceiveExactlyAsync(buffer, 1, cancellationToken); + if (response != "1") + { + throw new InvalidOperationException($"Failed to set target right ascension to {HoursToHMS(value)}, using command {_encoding.GetString(buffer)}, response={response}"); + } #if TRACE - External.AppLogger.LogTrace("Set target right ascension to {TargetRightAscension}, current right ascension is {RightAscension}, high precision={HighPrecision}", - HoursToHMS(value), HoursToHMS(ra), highPrecision); + External.AppLogger.LogTrace("Set target right ascension to {TargetRightAscension}, current right ascension is {RightAscension}, high precision={HighPrecision}", + HoursToHMS(value), HoursToHMS(ra), highPrecision); #endif - } } - public double TargetDeclination + public async ValueTask GetTargetDeclinationAsync(CancellationToken cancellationToken) { - get + var (dec, _) = await GetDeclinationWithPrecisionAsync(target: true, cancellationToken); + return dec; + } + + private async ValueTask SetTargetDeclinationAsync(double targetDec, CancellationToken cancellationToken) + { + if (targetDec > 90) { - var (dec, _) = GetDeclinationWithPrecision(target: true); - return dec; + throw new ArgumentException("Target declination cannot be greater than 90 degrees.", nameof(targetDec)); } - set + if (targetDec < -90) { - if (value > 90) - { - throw new ArgumentException("Target declination cannot be greater than 90 degrees.", nameof(value)); - } + throw new ArgumentException("Target declination cannot be lower than -90 degrees.", nameof(targetDec)); + } - if (value < -90) - { - throw new ArgumentException("Target declination cannot be lower than -90 degrees.", nameof(value)); - } + // :SdsDD*MM# for low precision + // :SdsDD*MM:SS# for high precision + var (dec, highPrecision) = await GetDeclinationWithPrecisionAsync(target: false, cancellationToken); - // :SdsDD*MM# for low precision - // :SdsDD*MM:SS# for high precision - var (dec, highPrecision) = GetDeclinationWithPrecision(target: false); + var sign = Math.Sign(targetDec); + var signLength = sign is -1 ? 1 : 0; + var degOffset = 2 + signLength; + var minOffset = degOffset + 2 + 1; + var targetDms = TimeSpan.FromHours(Math.Abs(targetDec)) + .Round(highPrecision ? TimeSpanRoundingType.Second : TimeSpanRoundingType.Minute) + .EnsureMax(TimeSpan.FromHours(90)); - var sign = Math.Sign(value); - var signLength = sign is -1 ? 1 : 0; - var degOffset = 2 + signLength; - var minOffset = degOffset + 2 + 1; - var targetDms = TimeSpan.FromHours(Math.Abs(value)) - .Round(highPrecision ? TimeSpanRoundingType.Second : TimeSpanRoundingType.Minute) - .EnsureMax(TimeSpan.FromHours(90)); + using var buffer = ArrayPoolHelper.Rent(minOffset + 2 +(highPrecision ? 3 : 0)); - Span buffer = stackalloc byte[minOffset + 2 + (highPrecision ? 3 : 0)]; - "Sd"u8.CopyTo(buffer); + "Sd"u8.CopyTo(buffer); - if (sign is -1) - { - buffer[degOffset - 1] = (byte)'-'; - } + if (sign is -1) + { + buffer[degOffset - 1] = (byte)'-'; + } - buffer[minOffset - 1] = (byte)'*'; + buffer[minOffset - 1] = (byte)'*'; - if (targetDms.Hours.TryFormat(buffer[degOffset..], out _, "00", CultureInfo.InvariantCulture) - && targetDms.Minutes.TryFormat(buffer[minOffset..], out _, "00", CultureInfo.InvariantCulture) - ) + if (targetDms.Hours.TryFormat(buffer.AsSpan(degOffset), out _, "00", CultureInfo.InvariantCulture) + && targetDms.Minutes.TryFormat(buffer.AsSpan(minOffset), out _, "00", CultureInfo.InvariantCulture) + ) + { + if (highPrecision) { - if (highPrecision) - { - var secOffset = minOffset + 2 + 1; - buffer[secOffset - 1] = (byte)':'; + var secOffset = minOffset + 2 + 1; + buffer[secOffset - 1] = (byte)':'; - if (!targetDms.Seconds.TryFormat(buffer[secOffset..], out _, "00", CultureInfo.InvariantCulture)) - { - throw new ArgumentException($"Failed to convert value {value} to DMS (high precision)", nameof(value)); - } + if (!targetDms.Seconds.TryFormat(buffer.AsSpan(secOffset), out _, "00", CultureInfo.InvariantCulture)) + { + throw new ArgumentException($"Failed to convert value {targetDec} to DMS (high precision)", nameof(targetDec)); } } - else - { - throw new ArgumentException($"Failed to convert value {value} to DM", nameof(value)); - } + } + else + { + throw new ArgumentException($"Failed to convert value {targetDec} to DM", nameof(targetDec)); + } - SendAndReceive(buffer, out var response, count: 1); + var response = await SendAndReceiveExactlyAsync(buffer, 1, cancellationToken); - if (!response.SequenceEqual("1"u8)) - { - throw new InvalidOperationException($"Failed to set target declination to {DegreesToDMS(value)}, using command {_encoding.GetString(buffer)}, response={_encoding.GetString(response)}"); - } + if (response is not "1") + { + throw new InvalidOperationException($"Failed to set target declination to {DegreesToDMS(targetDec)}, using command {_encoding.GetString(buffer)}, response={response}"); + } #if TRACE - External.AppLogger.LogTrace("Set target declination to {TargetDeclination}, current declination is {Declination}, high precision={HighPrecision}", - DegreesToDMS(value), DegreesToDMS(dec), highPrecision); + External.AppLogger.LogTrace("Set target declination to {TargetDeclination}, current declination is {Declination}, high precision={HighPrecision}", + DegreesToDMS(targetDec), DegreesToDMS(dec), highPrecision); #endif - } } - private (double RightAscension, bool HighPrecision) GetRightAscensionWithPrecision(bool target) + private readonly ReadOnlyMemory GrCommand = "Gr"u8.ToArray(); + private readonly ReadOnlyMemory GRCommand = "GR"u8.ToArray(); + private async ValueTask<(double RightAscension, bool HighPrecision)> GetRightAscensionWithPrecisionAsync(bool target, CancellationToken cancellationToken) { - SendAndReceive(target ? "Gr"u8 : "GR"u8, out var response); + var response = await SendAndReceiveAsync(target ? GrCommand : GRCommand, cancellationToken); + + if (response is not { }) + { + throw new InvalidOperationException($"No response received for right ascension query target={target}"); + } var ra = HmsOrHmTToHours(response, out var highPrecision); return (ra, highPrecision); } - private (double Declination, bool HighPrecision) GetDeclinationWithPrecision(bool target) + private readonly ReadOnlyMemory GdCommand = "Gd"u8.ToArray(); + private readonly ReadOnlyMemory GDCommand = "GD"u8.ToArray(); + private async ValueTask<(double Declination, bool HighPrecision)> GetDeclinationWithPrecisionAsync(bool target, CancellationToken cancellationToken) { - SendAndReceive(target ? "Gd"u8 : "GD"u8, out var response); - var dec = DMSToDegree(_encoding.GetString(response).Replace('\xdf', ':')); + var response = await SendAndReceiveAsync(target ? GdCommand : GDCommand, cancellationToken); + + if (response is not { }) + { + throw new InvalidOperationException($"No response received for declination query target={target}"); + } + var dec = DMSToDegree(response.Replace('\xdf', ':')); return (dec, response.Length >= 7); } @@ -563,9 +557,8 @@ public double TargetDeclination /// /// convert a HH:MM.T (classic LX200 RA Notation) string to a double hours. T is the decimal part of minutes which is converted into seconds /// - private static double HmsOrHmTToHours(ReadOnlySpan hmValue, out bool highPrecision) + private static double HmsOrHmTToHours(string hm, out bool highPrecision) { - var hm = _encoding.GetString(hmValue); var token = hm.Split('.'); // is high precision @@ -604,124 +597,134 @@ public double GuideRateDeclination set => throw new InvalidOperationException("Setting declination guide rate is not apported"); } - public double SiteElevation { get; set; } = double.NaN; + public ValueTask GetSiteElevationAsync(CancellationToken cancellationToken) => ValueTask.FromResult(double.NaN); + + public ValueTask SetSiteElevationAsync(double elevation, CancellationToken cancellationToken) + { + // todo: store this somewhere + + return ValueTask.CompletedTask; + } - public double SiteLatitude + private readonly ReadOnlyMemory GtCommand = "Gt"u8.ToArray(); + public ValueTask GetSiteLatitudeAsync(CancellationToken cancellationToken) { - get => GetLatOrLong("Gt"u8); + return GetLatOrLongAsync(GtCommand, cancellationToken); + } - set + public async ValueTask SetSiteLatitudeAsync(double latitude, CancellationToken cancellationToken) + { + if (latitude > 90) { - if (value > 90) - { - throw new ArgumentException("Site latitude cannot be greater than 90 degrees.", nameof(value)); - } + throw new ArgumentException("Site latitude cannot be greater than 90 degrees.", nameof(latitude)); + } - if (value < -90) - { - throw new ArgumentException("Site latitude cannot be lower than -90 degrees.", nameof(value)); - } + if (latitude < -90) + { + throw new ArgumentException("Site latitude cannot be lower than -90 degrees.", nameof(latitude)); + } - var abs = Math.Abs(value); - var dms = TimeSpan.FromHours(abs).Round(TimeSpanRoundingType.Minute).EnsureMax(TimeSpan.FromHours(90)); + var abs = Math.Abs(latitude); + var dms = TimeSpan.FromHours(abs).Round(TimeSpanRoundingType.Minute).EnsureMax(TimeSpan.FromHours(90)); - var needsSign = value < 0; - const int cmdLength = 2; - var offset = cmdLength + (needsSign ? 1 : 0); + var needsSign = latitude < 0; + const int cmdLength = 2; + var offset = cmdLength + (needsSign ? 1 : 0); - Span buffer = stackalloc byte[offset + 1 + 2]; - "St"u8.CopyTo(buffer); + using var buffer = ArrayPoolHelper.Rent(offset + 1 + 2); - if (needsSign) - { - buffer[cmdLength] = (byte)'-'; - } + "St"u8.CopyTo(buffer); - if (dms.Hours.TryFormat(buffer[offset..], out var degWritten, format: "00", provider: CultureInfo.InvariantCulture) - && dms.Minutes.TryFormat(buffer[(offset + degWritten + 1)..], out _, format: "00", provider: CultureInfo.InvariantCulture) - ) - { - buffer[offset + degWritten] = (byte)'*'; + if (needsSign) + { + buffer[cmdLength] = (byte)'-'; + } - SendAndReceive(buffer, out var response); + if (dms.Hours.TryFormat(buffer.AsSpan(offset), out var degWritten, format: "00", provider: CultureInfo.InvariantCulture) + && dms.Minutes.TryFormat(buffer.AsSpan(offset + degWritten + 1), out _, format: "00", provider: CultureInfo.InvariantCulture) + ) + { + buffer[offset + degWritten] = (byte)'*'; - if (response.SequenceEqual("1"u8)) - { - External.AppLogger.LogInformation("Updated site latitude to {Degrees}", value); - } - else - { - throw new InvalidOperationException($"Cannot update site latitude to {value} due to connectivity issue/command invalid: {_encoding.GetString(response)}"); - } + var response = await SendAndReceiveAsync(buffer, cancellationToken); + + if (response is "1") + { + External.AppLogger.LogInformation("Updated site latitude to {Degrees}", latitude); } else { - throw new InvalidOperationException($"Cannot update site latitude to {value} due to formatting error"); + throw new InvalidOperationException($"Cannot update site latitude to {latitude} due to connectivity issue/command invalid: {response}"); } } + else + { + throw new InvalidOperationException($"Cannot update site latitude to {latitude} due to formatting error"); + } } - public double SiteLongitude + private static readonly ReadOnlyMemory GgCommand = "Gg"u8.ToArray(); + public async ValueTask GetSiteLongitudeAsync(CancellationToken cancellationToken) { - get => -1 * GetLatOrLong("Gg"u8); + return -1 * await GetLatOrLongAsync(GgCommand, cancellationToken); + } - set + public async ValueTask SetSiteLongitudeAsync(double value, CancellationToken cancellationToken) + { + if (value > 180) { - if (value > 180) - { - throw new ArgumentException("Site longitude cannot be greater than 180 degrees.", nameof(value)); - } + throw new ArgumentException("Site longitude cannot be greater than 180 degrees.", nameof(value)); + } - if (value < -180) - { - throw new ArgumentException("Site longitude cannot be lower than -180 degrees.", nameof(value)); - } + if (value < -180) + { + throw new ArgumentException("Site longitude cannot be lower than -180 degrees.", nameof(value)); + } - var abs = Math.Abs(value); - var dms = TimeSpan.FromHours(abs) - .Round(TimeSpanRoundingType.Minute) - .EnsureRange(TimeSpan.FromHours(-180), TimeSpan.FromHours(+180)); + var abs = Math.Abs(value); + var dms = TimeSpan.FromHours(abs) + .Round(TimeSpanRoundingType.Minute) + .EnsureRange(TimeSpan.FromHours(-180), TimeSpan.FromHours(+180)); - var adjustedDegrees = value > 0 ? 360 - dms.Hours : dms.Hours; + var adjustedDegrees = value > 0 ? 360 - dms.Hours : dms.Hours; - const int offset = 2; - Span buffer = stackalloc byte[offset + 3 + 1 + 2]; - "Sg"u8.CopyTo(buffer); + const int offset = 2; + using var buffer = ArrayPoolHelper.Rent(offset + 3 + 1 + 2); + "Sg"u8.CopyTo(buffer); - if (adjustedDegrees.TryFormat(buffer[offset..], out var degWritten, format: "000", provider: CultureInfo.InvariantCulture) - && dms.Minutes.TryFormat(buffer[(offset + degWritten + 1)..], out _, format: "00", provider: CultureInfo.InvariantCulture) - ) - { - buffer[offset + degWritten] = (byte)'*'; + if (adjustedDegrees.TryFormat(buffer.AsSpan(offset), out var degWritten, format: "000", provider: CultureInfo.InvariantCulture) + && dms.Minutes.TryFormat(buffer.AsSpan(offset + degWritten + 1), out _, format: "00", provider: CultureInfo.InvariantCulture) + ) + { + buffer[offset + degWritten] = (byte)'*'; - SendAndReceive(buffer, out var response); + var response = await SendAndReceiveAsync(buffer, cancellationToken); - if (response.SequenceEqual("1"u8)) - { - External.AppLogger.LogInformation("Updated site longitude to {Degrees}", value); - } - else - { - throw new InvalidOperationException($"Cannot update site longitude to {value} due to connectivity issue/command invalid: {_encoding.GetString(response)}"); - } + if (response is "1") + { + External.AppLogger.LogInformation("Updated site longitude to {Degrees}", value); } else { - throw new InvalidOperationException($"Cannot update site longitude to {value} due to formatting error"); + throw new InvalidOperationException($"Cannot update site longitude to {value} due to connectivity issue/command invalid: {response}"); } } + else + { + throw new InvalidOperationException($"Cannot update site longitude to {value} due to formatting error"); + } } - private double GetLatOrLong(ReadOnlySpan command) + private async ValueTask GetLatOrLongAsync(ReadOnlyMemory command, CancellationToken cancellationToken) { - SendAndReceive(command, out var response); - if (response is { Length: >= 5 }) + using var response = ArrayPoolHelper.Rent(10); + if (await SendAndReceiveRawAsync(command, response, cancellationToken) >= 5) { var isNegative = response[0] is (byte)'-'; var offset = isNegative ? 1 : 0; - if (Utf8Parser.TryParse(response[offset..], out int degrees, out var consumed) - && Utf8Parser.TryParse(response[(offset + consumed + 1)..], out int minutes, out _) + if (Utf8Parser.TryParse(response.AsSpan(offset), out int degrees, out var consumed) + && Utf8Parser.TryParse(response.AsSpan(offset + consumed + 1), out int minutes, out _) ) { var latOrLongNotAdjusted = (isNegative ? -1 : 1) * (degrees + minutes / 60d); @@ -730,24 +733,25 @@ private double GetLatOrLong(ReadOnlySpan command) } } - throw new InvalidOperationException($"Failed to parse response {_encoding.GetString(response)} of {_encoding.GetString(command)}"); + throw new InvalidOperationException($"Failed to parse response of {_encoding.GetString(command.Span)}"); } public override string? DriverInfo => $"{_telescopeName} ({_telescopeFW})"; public override string? Description => $"{_telescopeName} driver based on the LX200 serial protocol v2010.10, firmware: {_telescopeFW}"; - public PointingState DestinationSideOfPier(double ra, double dec) + public async ValueTask DestinationSideOfPierAsync(double ra, double dec, CancellationToken cancellationToken) { - var (sideOfPier, _) = CalculateSideOfPier(ra); + var (sideOfPier, _) = await CalculateSideOfPierAsync(ra, cancellationToken); return sideOfPier; } - private bool IsSouthernHemisphere => _isSouthernHemisphere ??= SiteLatitude < 0; + private async ValueTask IsSouthernHemisphereAsync(CancellationToken cancellationToken) + => _isSouthernHemisphere ??= await GetSiteLatitudeAsync(cancellationToken) < 0; - private (PointingState PointingState, double SiderealTime) CalculateSideOfPier(double ra) + private async ValueTask<(PointingState PointingState, double SiderealTime)> CalculateSideOfPierAsync(double ra, CancellationToken cancellationToken) { - var lst = SiderealTime; + var lst = await GetSiderealTimeAsync(cancellationToken); var pointingState = ConditionHA(lst - ra) switch { >= 0 => PointingState.Normal, @@ -758,25 +762,21 @@ public PointingState DestinationSideOfPier(double ra, double dec) return (pointingState, lst); } - public void Park() + private readonly ReadOnlyMemory ParkCommand = "hP"u8.ToArray(); + public async ValueTask ParkAsync(CancellationToken cancellationToken = default) { - Send("hP"u8); - - var previousState = Interlocked.Exchange(ref _movingState, MOVING_STATE_SLEWING); -#if TRACE - External.AppLogger.LogTrace("Parking mount, previous state: {PreviousMovingState}", MovingStateDisplayName(previousState)); -#endif - StartSlewTimer(MOVING_STATE_PARKED); + await SendWithoutResponseAsync(ParkCommand, cancellationToken); } - public void PulseGuide(GuideDirection direction, TimeSpan duration) + public async ValueTask PulseGuideAsync(GuideDirection direction, TimeSpan duration, CancellationToken cancellationToken = default) { if (duration <= TimeSpan.Zero) { throw new ArgumentException("Timespan must be greater than 0", nameof(duration)); } - Span buffer = stackalloc byte[7]; + using var buffer = ArrayPoolHelper.Rent(2 + 1 + 4); + "Mg"u8.CopyTo(buffer); buffer[2] = direction switch { @@ -788,21 +788,14 @@ public void PulseGuide(GuideDirection direction, TimeSpan duration) }; var ms = (int)Math.Round(duration.TotalMilliseconds); - if (!Tracking) + if (!await IsTrackingAsync(cancellationToken)) { throw new InvalidOperationException("Cannot pulse guide when tracking is off"); } - if (ms.TryFormat(buffer, out _, "0000", CultureInfo.InvariantCulture)) + if (ms.TryFormat(buffer.AsSpan(3), out _, "0000", CultureInfo.InvariantCulture)) { - Send(buffer); - - External.TimeProvider.CreateTimer( - _ => _ = Interlocked.CompareExchange(ref _movingState, MOVING_STATE_NORMAL, MOVING_STATE_PULSE_GUIDING), - null, - duration, - Timeout.InfiniteTimeSpan - ); + await SendWithoutResponseAsync(buffer, cancellationToken); } else { @@ -810,165 +803,115 @@ public void PulseGuide(GuideDirection direction, TimeSpan duration) } } + private static readonly ReadOnlyMemory SlewCommand = "MS"u8.ToArray(); /// /// Sets target coordinates to (,), using . /// /// /// /// - public Task BeginSlewRaDecAsync(double ra, double dec, CancellationToken cancellationToken = default) + public async ValueTask BeginSlewRaDecAsync(double ra, double dec, CancellationToken cancellationToken) { - if (IsPulseGuiding) + if (await IsPulseGuidingAsync(cancellationToken)) { throw new InvalidOperationException("Cannot slew while pulse-guiding"); } - if (IsSlewing) + if (await IsSlewingAsync(cancellationToken)) { throw new InvalidOperationException("Cannot slew while a slew is still ongoing"); } - if (AtPark) + if (await AtParkAsync(cancellationToken)) { throw new InvalidOperationException("Mount is parked"); } - TargetRightAscension = ra; - TargetDeclination = dec; - - SendAndReceive("MS"u8, out var response, count: 1); + await SetTargetRightAscensionAsync(ra, cancellationToken); + await SetTargetDeclinationAsync(dec, cancellationToken); - if (response.SequenceEqual("0"u8)) + // acquire lock in this method directly as we might potentially send out two read commands + if (_deviceInfo.SerialDevice is { IsOpen: true } port) { - var previousState = Interlocked.Exchange(ref _movingState, MOVING_STATE_SLEWING); -#if TRACE - External.AppLogger.LogTrace("Slewing to {RA},{Dec}, previous state: {PreviousMovingState}", HoursToHMS(ra), DegreesToDMS(dec), MovingStateDisplayName(previousState)); -#endif - StartSlewTimer(MOVING_STATE_NORMAL); - - return Task.CompletedTask; - } - else if (response.Length is 1 && byte.TryParse(response[0..1], out var reasonCode) && TryReadTerminated(out var reasonMessage)) - { - var reason = reasonCode switch + await port.WaitAsync(cancellationToken); + try { - 1 => "below horizon limit", - 2 => "above hight limit", - _ => $"unknown reason {reasonCode}: {_encoding.GetString(reasonMessage)}" - }; - - throw new InvalidOperationException($"Failed to slew to {HoursToHMS(ra)},{DegreesToDMS(dec)} due to {reason} message={reasonCode}{_encoding.GetString(reasonMessage)}"); - } - else - { - throw new InvalidOperationException($"Failed to slew to {HoursToHMS(ra)},{DegreesToDMS(dec)} due to an unrecognized response: {_encoding.GetString(response)}"); - } - } - - private void StartSlewTimer(int finalState) - { - // start timer deactivated to capture itself - var timer = External.TimeProvider.CreateTimer(SlewTimerCallback, finalState, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - Interlocked.Exchange(ref _slewTimer, timer)?.Dispose(); + await SendAsync(port, SlewCommand, cancellationToken); + var response = await port.TryReadExactlyAsync(1, cancellationToken); - // activate timer - timer.Change(TimeSpan.Zero, TimeSpan.FromMilliseconds(250)); - } - - private void SlewTimerCallback(object? state) - { - bool continueRunning; - var ra = RightAscension; - var dec = Declination; - double lst; - PointingState sideOfPier; - if (!double.IsNaN(ra) && !AtPark) - { - (continueRunning, sideOfPier, lst) = CheckPointingState(ra); - } - else - { - continueRunning = false; - lst = SiderealTime; - sideOfPier = PointingState.Unknown; - } - - if (continueRunning) - { + if (response is "0") + { #if TRACE - External.AppLogger.LogTrace("Still slewing hour angle={HourAngle} lst={LST} ra={Ra} dec={Dec} sop={SideOfPier}", - HoursToHMS(ConditionHA(lst - ra)), HoursToHMS(lst), HoursToHMS(ra), DegreesToDMS(dec), sideOfPier); + External.AppLogger.LogTrace("Slewing to {RA},{Dec}", HoursToHMS(ra), DegreesToDMS(dec)); #endif - } - else - { - if (_slewTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) is var changeResult and not true) - { - External.AppLogger.LogWarning("Failed to stop slewing timer has instance: {HasInstance}", changeResult is not null); - } - - if (state is int finalMovingState) - { - var previousState = Interlocked.CompareExchange(ref _movingState, finalMovingState, MOVING_STATE_SLEWING); - - if (previousState != MOVING_STATE_SLEWING && previousState != finalMovingState) + } + else if (response is { Length: 1 } + && byte.TryParse(response[0..1], out var reasonCode) + && (await port.TryReadTerminatedAsync(Terminators, cancellationToken) is { } reasonMessage) + ) { - External.AppLogger.LogWarning("Expected moving state to be slewing, but was: {PreviousMovingState}", MovingStateDisplayName(previousState)); + var reason = reasonCode switch + { + 1 => "below horizon limit", + 2 => "above hight limit", + _ => $"unknown reason {reasonCode}: {reasonMessage}" + }; + + throw new InvalidOperationException($"Failed to slew to {HoursToHMS(ra)},{DegreesToDMS(dec)} due to {reason} message={reasonCode}{reasonMessage}"); } else { -#if TRACE - External.AppLogger.LogTrace("Slew complete hour angle={HourAngle} lst={LST} ra={Ra} dec={Dec} sop={SideOfPier}", - HoursToHMS(ConditionHA(lst - ra)), HoursToHMS(lst), HoursToHMS(ra), DegreesToDMS(dec), sideOfPier); -#endif + throw new InvalidOperationException($"Failed to slew to {HoursToHMS(ra)},{DegreesToDMS(dec)} due to an unrecognized response: {response}"); } } + finally + { + port.Release(); + } + } + else + { + throw new InvalidOperationException("Serial port is not connected"); } } - private static string MovingStateDisplayName(int previousState) => previousState switch - { - MOVING_STATE_NORMAL => "normal", - MOVING_STATE_PULSE_GUIDING => "pulse guiding (abnormal)", - MOVING_STATE_SLEWING => "slewing (abnormal)", - _ => $"unknown ({previousState})" - }; - + private readonly ReadOnlyMemory QCommand = "Q"u8.ToArray(); /// /// Returns true if mount is not slewing or an ongoing slew was aborted successfully. /// Does not disable pulse guiding /// TODO: Verify :Q# stops pulse guiding as well /// /// - public void AbortSlew() + public async ValueTask AbortSlewAsync(CancellationToken cancellationToken) { - if (IsPulseGuiding) + if (await IsPulseGuidingAsync(cancellationToken)) { throw new InvalidOperationException("Cannot abort slewing while pulse guiding"); } - if (IsSlewing) + if (await IsSlewingAsync(cancellationToken)) { - Send("Q"u8); - StartSlewTimer(MOVING_STATE_NORMAL); + await SendWithoutResponseAsync(QCommand, cancellationToken); + // StartSlewTimer(MOVING_STATE_NORMAL); } } + private static readonly ReadOnlyMemory CMCommand = "CM"u8.ToArray(); /// /// Does not allow sync across the meridian /// /// /// /// - public void SyncRaDec(double ra, double dec) + public async ValueTask SyncRaDecAsync(double ra, double dec, CancellationToken cancellationToken) { - var pointingState = SideOfPier; + var pointingState = await GetSideOfPierAsync(cancellationToken); if (pointingState is PointingState.Unknown or PointingState.ThroughThePole) { throw new InvalidOperationException($"Cannot sync across meridian (current side of pier: {pointingState}) given {HoursToHMS(ra)},{DegreesToDMS(dec)}"); } - SendAndReceive("CM"u8, out var response); + var response = await SendAndReceiveAsync(CMCommand, cancellationToken); if (response is not { Length: > 0 }) { @@ -976,7 +919,7 @@ public void SyncRaDec(double ra, double dec) } } - public void Unpark() => throw new InvalidOperationException("Unparking is not supported"); + public ValueTask UnparkAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("Unparking is not supported"); protected override Task<(bool Success, int ConnectionId, MountDeviceInfo DeviceInfo)> DoConnectDeviceAsync(CancellationToken cancellationToken) { @@ -1008,58 +951,68 @@ public void SyncRaDec(double ra, double dec) } } - protected override Task DoDisconnectDeviceAsync(int connectionId, CancellationToken cancellationToken) + protected override async Task DoDisconnectDeviceAsync(int connectionId, CancellationToken cancellationToken) { if (connectionId == CONNECTION_ID_EXCLUSIVE) { if (_deviceInfo.SerialDevice is { IsOpen: true } port) { - return Task.FromResult(port.TryClose()); + await port.WaitAsync(cancellationToken); + return port.TryClose(); } else if (_deviceInfo.SerialDevice is { }) { - return Task.FromResult(true); + return true; } else { - return Task.FromResult(false); + return false; } } - return Task.FromResult(false); + return false; } - protected override ValueTask InitDeviceAsync(CancellationToken cancellationToken) + private static readonly ReadOnlyMemory GVPCommand = "GVP"u8.ToArray(); + private static readonly ReadOnlyMemory GVNCommand = "GVN"u8.ToArray(); + protected override async ValueTask InitDeviceAsync(CancellationToken cancellationToken) { try { - SendAndReceive("GVP"u8, out var gvpBytes); - _telescopeName = _encoding.GetString(gvpBytes); + var name = await SendAndReceiveAsync(GVPCommand, cancellationToken); - SendAndReceive("GVN"u8, out var gvnBytes); - _telescopeFW = _encoding.GetString(gvnBytes); + var fw = await SendAndReceiveAsync(GVNCommand, cancellationToken); - if (!TrySetHighPrecision()) + if (name is not { } || fw is not { }) + { + return false; + } + + _telescopeName = name; + _telescopeFW = fw; + + if (!await TrySetHighPrecisionAsync(cancellationToken)) { External.AppLogger.LogWarning("Failed to set high precision via :U#"); } - return ValueTask.FromResult(true); + return true; } catch (Exception e) { External.AppLogger.LogError(e, "Failed to initialize mount"); - return ValueTask.FromResult(false); + return false; } } - private bool TrySetHighPrecision() + private static readonly ReadOnlyMemory UCommand = "U"u8.ToArray(); + private async ValueTask TrySetHighPrecisionAsync(CancellationToken cancellationToken) { bool highPrecision; int tries = 0; do { - (_, highPrecision) = GetRightAscensionWithPrecision(target: false); + (_, highPrecision) = await GetRightAscensionWithPrecisionAsync(target: false, cancellationToken); if (highPrecision) { @@ -1067,7 +1020,7 @@ private bool TrySetHighPrecision() } else { - Send("U"u8); + await SendWithoutResponseAsync(UCommand, cancellationToken); } } while (!highPrecision && ++tries < 3); @@ -1075,65 +1028,122 @@ private bool TrySetHighPrecision() } #region Serial I/O - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void Send(ReadOnlySpan command) + private static readonly ReadOnlyMemory Terminators = "#\0"u8.ToArray(); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private async ValueTask SendAndReceiveAsync(ReadOnlyMemory command, CancellationToken cancellationToken) { - if (!Connected) + if (_deviceInfo.SerialDevice is { IsOpen: true } port) { - throw new InvalidOperationException("Mount is not connected"); - } + await port.WaitAsync(cancellationToken); + try + { + await SendAsync(port, command, cancellationToken); - if (!CanUnpark && !CanSetPark && AtPark) - { - throw new InvalidOperationException("Mount is parked, but it is not possible to unpark it"); + return await port.TryReadTerminatedAsync(Terminators, cancellationToken); + } + finally + { + port.Release(); + } } - if (_deviceInfo.SerialDevice is not { } port || !port.IsOpen) + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private async ValueTask SendAndReceiveRawAsync(ReadOnlyMemory command, Memory response, CancellationToken cancellationToken) + { + if (_deviceInfo.SerialDevice is { IsOpen: true } port) { - throw new InvalidOperationException("Serial port is closed"); + await port.WaitAsync(cancellationToken); + try + { + await SendAsync(port, command, cancellationToken); + + return await port.TryReadTerminatedRawAsync(response, Terminators, cancellationToken); + } + finally + { + port.Release(); + } } - Span raw = stackalloc byte[command.Length + 2]; - raw[0] = (byte)':'; - command.CopyTo(raw[1..]); - raw[^1] = (byte)'#'; + return -1; + } - if (!port.TryWrite(raw)) + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private async ValueTask SendAndReceiveExactlyAsync(ReadOnlyMemory command, int count, CancellationToken cancellationToken) + { + if (_deviceInfo.SerialDevice is { IsOpen: true } port) { - throw new InvalidOperationException($"Failed to send raw message {_encoding.GetString(raw)}"); + await port.WaitAsync(cancellationToken); + try + { + await SendAsync(port, command, cancellationToken); + + return await port.TryReadExactlyAsync(count, cancellationToken); + } + finally + { + port.Release(); + } } + + return null; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool TryReadTerminated(out ReadOnlySpan response) + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private async ValueTask SendWithoutResponseAsync(ReadOnlyMemory command, CancellationToken cancellationToken) { - if (_deviceInfo.SerialDevice is { } port) + if (_deviceInfo.SerialDevice is { IsOpen: true } port) { - return port.TryReadTerminated(out response, "#\0"u8); + await port.WaitAsync(cancellationToken); + try + { + await SendAsync(port, command, cancellationToken); + } + finally + { + port.Release(); + } + } + else + { + throw new InvalidOperationException("Mount is not connected"); } - - response = default; - return false; } - private bool TryReadExactly(int count, out ReadOnlySpan response) + /// + /// Assumes that port is checked for open state, and a lock is acquired. + /// + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private async ValueTask SendAsync(ISerialConnection port, ReadOnlyMemory command, CancellationToken cancellationToken) { - if (_deviceInfo.SerialDevice is { } port && port.TryReadExactly(count, out response)) + if (!Connected) { - return true; + throw new InvalidOperationException("Mount is not connected"); } - response = default; - return false; - } + if (!CanUnpark && !CanSetPark && await AtParkAsync(cancellationToken)) + { + throw new InvalidOperationException("Mount is parked, but it is not possible to unpark it"); + } - private void SendAndReceive(ReadOnlySpan command, out ReadOnlySpan response, int? count = null) - { - Send(command); + using var raw = ArrayPoolHelper.Rent(command.Length + 2); - if (!(count is { } ? TryReadExactly(count.Value, out response) : TryReadTerminated(out response))) + raw[0] = (byte)':'; + command.Span.CopyTo(raw.AsSpan(1)); + raw[^1] = (byte)'#'; + + if (!await port.TryWriteAsync(raw, cancellationToken)) { - throw new InvalidOperationException($"Failed to get response for message {_encoding.GetString(command)}"); + throw new InvalidOperationException($"Failed to send raw message {_encoding.GetString(raw)}"); } } #endregion diff --git a/src/TianWen.Lib/IAsyncSupportedCheck.cs b/src/TianWen.Lib/IAsyncSupportedCheck.cs index 37a8b0b..73d69d2 100644 --- a/src/TianWen.Lib/IAsyncSupportedCheck.cs +++ b/src/TianWen.Lib/IAsyncSupportedCheck.cs @@ -11,4 +11,4 @@ public interface IAsyncSupportedCheck /// /// true if the implementation is supported on this platform/installed on the system. ValueTask CheckSupportAsync(CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/src/TianWen.Lib/Sequencing/Session.cs b/src/TianWen.Lib/Sequencing/Session.cs index 88e8955..4af8116 100644 --- a/src/TianWen.Lib/Sequencing/Session.cs +++ b/src/TianWen.Lib/Sequencing/Session.cs @@ -75,7 +75,7 @@ public async Task RunAsync(CancellationToken cancellationToken) } finally { - await Finalise(); + await Finalise(cancellationToken).ConfigureAwait(false); } } @@ -90,7 +90,7 @@ internal async ValueTask InitialRoughFocusAsync(CancellationToken cancella var mount = Setup.Mount; var distMeridian = TimeSpan.FromMinutes(15); - if (!mount.Driver.EnsureTracking()) + if (!await mount.Driver.EnsureTrackingAsync(cancellationToken: cancellationToken)) { External.AppLogger.LogError("Failed to enable tracking of {Mount}.", mount); @@ -101,7 +101,7 @@ internal async ValueTask InitialRoughFocusAsync(CancellationToken cancella // coordinates not quite accurate at this point (we have not plate-solved yet) but good enough for this purpose. await mount.Driver.BeginSlewToZenithAsync(distMeridian, cancellationToken).ConfigureAwait(false); - var slewTime = MountUtcNow; + var slewTime = await GetMountUtcNowAsync(cancellationToken); if (!await mount.Driver.WaitForSlewCompleteAsync(cancellationToken).ConfigureAwait(false)) { @@ -154,7 +154,7 @@ internal async ValueTask InitialRoughFocusAsync(CancellationToken cancella { expTimesSec[i]++; - if (MountUtcNow - slewTime + TimeSpan.FromSeconds(count * 5 + expTimesSec[i]) < distMeridian) + if (await GetMountUtcNowAsync(cancellationToken) - slewTime + TimeSpan.FromSeconds(count * 5 + expTimesSec[i]) < distMeridian) { camDriver.StartExposure(TimeSpan.FromSeconds(expTimesSec[i])); } @@ -172,11 +172,11 @@ internal async ValueTask InitialRoughFocusAsync(CancellationToken cancella } // slew back to start position - if (MountUtcNow - slewTime > distMeridian) + if (await GetMountUtcNowAsync(cancellationToken) - slewTime > distMeridian) { await mount.Driver.BeginSlewToZenithAsync(distMeridian, cancellationToken).ConfigureAwait(false); - slewTime = MountUtcNow; + slewTime = await GetMountUtcNowAsync(cancellationToken); if (!await mount.Driver.WaitForSlewCompleteAsync(cancellationToken).ConfigureAwait(false)) { @@ -204,7 +204,13 @@ private async ValueTask GuiderFocusLoopAsync(TimeSpan timeoutAfter, Cancel var plateSolveTimeout = timeoutAfter > TimeSpan.FromSeconds(5) ? timeoutAfter - TimeSpan.FromSeconds(3) : timeoutAfter; - var wcs = await guider.Driver.PlateSolveGuiderImageAsync(PlateSolver, mount.Driver.RightAscension, mount.Driver.Declination, plateSolveTimeout, 10, cancellationToken); + var wcs = await guider.Driver.PlateSolveGuiderImageAsync(PlateSolver, + await mount.Driver.GetRightAscensionAsync(cancellationToken), + await mount.Driver.GetDeclinationAsync(cancellationToken), + plateSolveTimeout, + 10d, + cancellationToken + ); if (wcs is var (solvedRa, solvedDec)) { @@ -245,7 +251,7 @@ internal async ValueTask CalibrateGuiderAsync(CancellationToken cancellationToke } } - internal async ValueTask Finalise() + internal async ValueTask Finalise(CancellationToken cancellationToken) { External.AppLogger.LogInformation("Executing session run finaliser: Stop guiding, stop tracking, disconnect guider, close covers, cool to ambient temp, turn off cooler, park scope."); @@ -259,41 +265,40 @@ internal async ValueTask Finalise() { await guider.Driver.StopCaptureAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false); return !await guider.Driver.IsGuidingAsync(cancellationToken).ConfigureAwait(false); - }, CancellationToken.None).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); - var trackingStopped = Catch(() => mount.Driver.CanSetTracking && !(mount.Driver.Tracking = false)); + var trackingStopped = await CatchAsync(async cancellationToken => mount.Driver.CanSetTracking && !await mount.Driver.IsTrackingAsync(cancellationToken), cancellationToken).ConfigureAwait(false); if (trackingStopped) { - maybeCoversClosed ??= await CatchAsync(CloseCoversAsync, CancellationToken.None).ConfigureAwait(false); - maybeCooledCamerasToAmbient ??= await CatchAsync(TurnOffCameraCoolingAsync, CancellationToken.None).ConfigureAwait(false); + maybeCoversClosed ??= await CatchAsync(CloseCoversAsync, cancellationToken).ConfigureAwait(false); + maybeCooledCamerasToAmbient ??= await CatchAsync(TurnOffCameraCoolingAsync, cancellationToken).ConfigureAwait(false); } - var guiderDisconnected = await CatchAsync(guider.Driver.DisconnectAsync, CancellationToken.None).ConfigureAwait(false); + var guiderDisconnected = await CatchAsync(guider.Driver.DisconnectAsync, cancellationToken).ConfigureAwait(false); + bool parkInitiated = Catch(() => mount.Driver.CanPark) && await CatchAsync(mount.Driver.ParkAsync, cancellationToken).ConfigureAwait(false); - bool parkInitiated = Catch(() => mount.Driver.CanPark) && Catch(mount.Driver.Park); - - var parkCompleted = parkInitiated && Catch(() => + var parkCompleted = parkInitiated && await CatchAsync(async cancellationToken => { int i = 0; - while (!mount.Driver.AtPark && i++ < IDeviceDriver.MAX_FAILSAFE) + while (!await mount.Driver.AtParkAsync(cancellationToken) && i++ < IDeviceDriver.MAX_FAILSAFE) { - External.Sleep(TimeSpan.FromMilliseconds(100)); + await External.SleepAsync(TimeSpan.FromMilliseconds(100), cancellationToken); } - return mount.Driver.AtPark; - }); + return await mount.Driver.AtParkAsync(cancellationToken); + }, cancellationToken); if (parkCompleted) { - maybeCoversClosed ??= await CatchAsync(CloseCoversAsync, CancellationToken.None).ConfigureAwait(false); - maybeCooledCamerasToAmbient ??= await CatchAsync(TurnOffCameraCoolingAsync, CancellationToken.None).ConfigureAwait(false); + maybeCoversClosed ??= await CatchAsync(CloseCoversAsync, cancellationToken).ConfigureAwait(false); + maybeCooledCamerasToAmbient ??= await CatchAsync(TurnOffCameraCoolingAsync, cancellationToken).ConfigureAwait(false); } - var coversClosed = maybeCoversClosed ??= await CatchAsync(CloseCoversAsync, CancellationToken.None).ConfigureAwait(false); - var cooledCamerasToAmbient = maybeCooledCamerasToAmbient ??= await CatchAsync(TurnOffCameraCoolingAsync, CancellationToken.None).ConfigureAwait(false); + var coversClosed = maybeCoversClosed ??= await CatchAsync(CloseCoversAsync, cancellationToken).ConfigureAwait(false); + var cooledCamerasToAmbient = maybeCooledCamerasToAmbient ??= await CatchAsync(TurnOffCameraCoolingAsync, cancellationToken).ConfigureAwait(false); - var mountDisconnected = await CatchAsync(mount.Driver.DisconnectAsync, CancellationToken.None).ConfigureAwait(false); + var mountDisconnected = await CatchAsync(mount.Driver.DisconnectAsync, cancellationToken).ConfigureAwait(false); var shutdownReport = new Dictionary { @@ -349,9 +354,10 @@ internal async ValueTask Initialisation(CancellationToken cancellationToke var guider = Setup.Guider; await mount.Driver.ConnectAsync(cancellationToken).ConfigureAwait(false); - await guider.Driver.ConnectAsync(cancellationToken); + await guider.Driver.ConnectAsync(cancellationToken).ConfigureAwait(false); - if (mount.Driver.AtPark && (!mount.Driver.CanUnpark || !Catch(mount.Driver.Unpark))) + if (await mount.Driver.AtParkAsync(cancellationToken) + && (!mount.Driver.CanUnpark || !await CatchAsync(mount.Driver.UnparkAsync, cancellationToken).ConfigureAwait(false))) { External.AppLogger.LogError("Mount {Mount} is parked but cannot be unparked. Aborting.", mount); @@ -359,7 +365,7 @@ internal async ValueTask Initialisation(CancellationToken cancellationToke } // try set the time to our time if supported - mount.Driver.UTCDate = External.TimeProvider.GetUtcNow().UtcDateTime; + await mount.Driver.SetUTCDateAsync(External.TimeProvider.GetUtcNow().UtcDateTime, cancellationToken); for (var i = 0; i < Setup.Telescopes.Count; i++) { @@ -373,8 +379,8 @@ internal async ValueTask Initialisation(CancellationToken cancellationToke { camera.Driver.FocalLength = telescope.FocalLength; } - camera.Driver.Latitude ??= mount.Driver.SiteLatitude; - camera.Driver.Longitude ??= mount.Driver.SiteLongitude; + camera.Driver.Latitude ??= await mount.Driver.GetSiteLatitudeAsync(cancellationToken); + camera.Driver.Longitude ??= await mount.Driver.GetSiteLongitudeAsync(cancellationToken); } if (!await CoolCamerasToSensorTempAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false)) @@ -409,16 +415,16 @@ internal async ValueTask ObservationLoopAsync(CancellationToken cancellationToke { var guider = Setup.Guider; var mount = Setup.Mount; - var sessionStartTime = MountUtcNow; - var sessionEndTime = SessionEndTime(sessionStartTime); + var sessionStartTime = await GetMountUtcNowAsync(cancellationToken); + var sessionEndTime = await SessionEndTimeAsync(sessionStartTime, cancellationToken); Observation? observation; while ((observation = ActiveObservation) is not null - && MountUtcNow < sessionEndTime + && await GetMountUtcNowAsync(cancellationToken) < sessionEndTime && !cancellationToken.IsCancellationRequested ) { - if (!mount.Driver.EnsureTracking()) + if (!await mount.Driver.EnsureTrackingAsync(cancellationToken: cancellationToken)) { External.AppLogger.LogError("Failed to enable tracking of {Mount}.", mount); return; @@ -481,7 +487,7 @@ internal async ValueTask ObservationLoopAsync(CancellationToken cancellationToke continue; } - var imageLoopStart = MountUtcNow; + var imageLoopStart = await GetMountUtcNowAsync(cancellationToken); var imageLoopResult = await ImagingLoopAsync(observation, hourAngleAtSlewTime, cancellationToken).ConfigureAwait(false); if (imageLoopResult is ImageLoopNextAction.AdvanceToNextObservation) { @@ -495,7 +501,7 @@ internal async ValueTask ObservationLoopAsync(CancellationToken cancellationToke } else { - External.AppLogger.LogError("Imaging loop for {Observation} did not complete successfully, total runtime: {TotalRuntime:c}", observation, MountUtcNow - imageLoopStart); + External.AppLogger.LogError("Imaging loop for {Observation} did not complete successfully, total runtime: {TotalRuntime:c}", observation, await GetMountUtcNowAsync(cancellationToken) - imageLoopStart); break; } } // end observation loop @@ -543,7 +549,7 @@ internal async ValueTask ImagingLoopAsync(Observation obser while (!cancellationToken.IsCancellationRequested && mount.Driver.Connected - && Catch(() => mount.Driver.Tracking) + && await CatchAsync(mount.Driver.IsTrackingAsync, cancellationToken) ) { if (!await CatchAsync(guider.Driver.IsGuidingAsync, cancellationToken).ConfigureAwait(false)) @@ -646,7 +652,7 @@ await CatchAsync(guider.Driver.ConnectAsync, cancellationToken) && } var fetchImagesSuccessAll = imageFetchSuccess.AllSet(scopes); - if (!mount.Driver.IsOnSamePierSide(hourAngleAtSlewTime)) + if (!await mount.Driver.IsOnSamePierSideAsync(hourAngleAtSlewTime, cancellationToken)) { // write all images as the loop is ending here _ = await WriteQueuedImagesToFitsFilesAsync(); @@ -696,7 +702,7 @@ await CatchAsync(guider.Driver.ConnectAsync, cancellationToken) && async ValueTask WriteQueuedImagesToFitsFilesAsync() { - var writeQueueStart = MountUtcNow; + var writeQueueStart = await GetMountUtcNowAsync(cancellationToken); while (imageWriteQueue.TryDequeue(out var imageWrite)) { try @@ -710,7 +716,7 @@ async ValueTask WriteQueuedImagesToFitsFilesAsync() } } - return MountUtcNow - writeQueueStart; + return await GetMountUtcNowAsync(cancellationToken) - writeQueueStart; } } @@ -896,26 +902,19 @@ internal ValueTask WriteImageToFitsFileAsync(QueuedImageWrite imageWrite) internal T Catch(Func func, T @default = default) where T : struct => External.Catch(func, @default); internal ValueTask CatchAsync(Func asyncFunc, CancellationToken cancellationToken) => External.CatchAsync(asyncFunc, cancellationToken); + internal Task CatchAsync(Func asyncFunc, CancellationToken cancellationToken) + => External.CatchAsync(asyncFunc, cancellationToken); + internal ValueTask CatchAsync(Func> asyncFunc, CancellationToken cancellationToken, T @default = default) where T : struct => External.CatchAsync(asyncFunc, cancellationToken, @default); - - internal DateTime MountUtcNow - { - get - { - if (Setup.Mount.Driver.TryGetUTCDate(out var dateTime)) - { - return dateTime; - } - return External.TimeProvider.GetUtcNow().UtcDateTime; - } - } + internal async ValueTask GetMountUtcNowAsync(CancellationToken cancellationToken) + => await Setup.Mount.Driver.TryGetUTCDateFromMountAsync(cancellationToken) ?? External.TimeProvider.GetUtcNow().UtcDateTime; internal async ValueTask WaitUntilTenMinutesBeforeAmateurAstroTwilightEndsAsync(CancellationToken cancellationToken) { - if (!Setup.Mount.Driver.TryGetTransform(out var transform)) + if (await Setup.Mount.Driver.TryGetTransformAsync(cancellationToken) is not { } transform) { throw new InvalidOperationException("Failed to retrieve time transformation from mount"); } @@ -948,9 +947,9 @@ internal async ValueTask WaitUntilTenMinutesBeforeAmateurAstroTwilightEndsAsync( } } - internal DateTime SessionEndTime(DateTime startTime) + internal async ValueTask SessionEndTimeAsync(DateTime startTime, CancellationToken cancellationToken) { - if (!Setup.Mount.Driver.TryGetTransform(out var transform)) + if (await Setup.Mount.Driver.TryGetTransformAsync(cancellationToken) is not { } transform) { throw new InvalidOperationException("Failed to retrieve time transformation from mount"); } diff --git a/src/TianWen.Lib/SpanHelper.cs b/src/TianWen.Lib/SpanHelper.cs deleted file mode 100644 index b274e80..0000000 --- a/src/TianWen.Lib/SpanHelper.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace TianWen.Lib; - -public static class SpanHelper -{ - public static bool StartsWithAny(this ReadOnlySpan span, ReadOnlySpan startsWith1, ReadOnlySpan startsWith2) - => span.StartsWith(startsWith1) || span.StartsWith(startsWith2); - - public static bool StartsWithAny(this ReadOnlySpan span, ReadOnlySpan startsWith1, ReadOnlySpan startsWith2, ReadOnlySpan startsWith3) - => span.StartsWithAny(startsWith1, startsWith2) || span.StartsWith(startsWith3); -} diff --git a/src/TianWen.Lib/StringHelper.cs b/src/TianWen.Lib/StringHelper.cs new file mode 100644 index 0000000..9514d6e --- /dev/null +++ b/src/TianWen.Lib/StringHelper.cs @@ -0,0 +1,23 @@ +using System.Text; + +namespace TianWen.Lib; + +public static class StringHelper +{ + public static string? ReplaceNonPrintableWithHex(this string str) + { + var sb = new StringBuilder(str.Length + 4); + foreach (var c in str) + { + if (char.IsControl(c)) + { + sb.AppendFormat("<{0:X2}>", (int)c); + } + else + { + sb.Append(c); + } + } + return sb.ToString(); + } +}