From daafd80c820b53731d83df0c0550e17225a10449 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:50 +1000 Subject: [PATCH 01/33] feat: Add ADS-C contracts and reports Add ADS-C over Hoppie ACARS. The server requests a periodic contract on CPDLC logon and cancels it when the CPDLC connection ends. Each ATSU has a default reporting interval. The plugin receives the contracts, the ATSU settings, and the latest ADS-C report for each aircraft. Co-Authored-By: Claude Opus 5.5 --- docs/docs/server/index.md | 8 + example.env | 2 + source/CPDLCPlugin/AdscContractStore.cs | 76 +++++++ source/CPDLCPlugin/AdscReportStore.cs | 73 +++++++ source/CPDLCPlugin/AtsuSettingsStore.cs | 22 ++ .../AdscContractRemovedNotification.cs | 20 ++ .../AdscContractUpdatedNotification.cs | 20 ++ .../AdscReportReceivedNotification.cs | 26 +++ .../Messages/ConnectedNotification.cs | 17 ++ .../Messages/DisconnectedNotification.cs | 12 ++ source/CPDLCPlugin/Plugin.cs | 3 + .../Server/IDownlinkHandlerDelegate.cs | 3 + .../Server/MediatorMessageHandler.cs | 48 +++++ .../Server/SignalRConnectionManager.cs | 47 +++++ source/CPDLCServer.Contracts/AcarsMessages.cs | 25 +++ .../Clients/HoppieAcarsClientTests.cs | 189 ++++++++++++++++++ ...MessageReceivedNotificationHandlerTests.cs | 142 +++++++++++++ ...cReportReceivedNotificationHandlerTests.cs | 78 ++++++++ .../CancelAdscContractCommandHandlerTests.cs | 82 ++++++++ ...scContractOnConnectionEndedHandlerTests.cs | 119 +++++++++++ .../GetAtsuSettingsRequestHandlerTests.cs | 35 ++++ ...ractOnConnectionEstablishedHandlerTests.cs | 104 ++++++++++ ...uestAdscDemandReportCommandHandlerTests.cs | 78 ++++++++ ...PeriodicAdscContractCommandHandlerTests.cs | 125 ++++++++++++ .../Mocks/TestAcarsClient.cs | 23 +++ .../Mocks/TestAdscContractRepository.cs | 29 +++ .../Mocks/TestClientManager.cs | 39 ++++ source/CPDLCServer/Clients/ClientManager.cs | 95 ++++++++- .../CPDLCServer/Clients/HoppieAcarsClient.cs | 121 +++++++++-- .../Clients/HoppiesConfiguration.cs | 3 + source/CPDLCServer/Clients/IAcarsClient.cs | 4 + .../AdscContractChangedNotificationHandler.cs | 38 ++++ .../AdscContractRemovedNotificationHandler.cs | 35 ++++ .../AdscMessageReceivedNotificationHandler.cs | 61 ++++++ .../AdscReportReceivedNotificationHandler.cs | 42 ++++ .../CancelAdscContractCommandHandler.cs | 42 ++++ ...celAdscContractOnConnectionEndedHandler.cs | 46 +++++ .../GetAdscContractsRequestHandler.cs | 22 ++ .../Handlers/GetAtsuSettingsRequestHandler.cs | 19 ++ ...cContractOnConnectionEstablishedHandler.cs | 51 +++++ .../RequestAdscDemandReportCommandHandler.cs | 45 +++++ ...questPeriodicAdscContractCommandHandler.cs | 55 +++++ source/CPDLCServer/Hubs/ControllerHub.cs | 79 ++++++++ .../AdscContractChangedNotification.cs | 6 + .../AdscContractRemovedNotification.cs | 5 + .../AdscMessageReceivedNotification.cs | 10 + .../AdscReportReceivedNotification.cs | 11 + .../Messages/CancelAdscContractCommand.cs | 8 + .../Messages/GetAdscContractsRequest.cs | 8 + .../Messages/GetAtsuSettingsRequest.cs | 6 + .../RequestAdscDemandReportCommand.cs | 8 + .../RequestPeriodicAdscContractCommand.cs | 9 + source/CPDLCServer/Model/AdscContract.cs | 24 +++ .../Model/AdscContractConverter.cs | 27 +++ source/CPDLCServer/Model/AdscReport.cs | 8 + .../CPDLCServer/Model/ReceivedAdscMessage.cs | 16 ++ .../Persistence/IAdscContractRepository.cs | 13 ++ .../InMemoryAdscContractRepository.cs | 46 +++++ source/CPDLCServer/Program.cs | 1 + 59 files changed, 2386 insertions(+), 23 deletions(-) create mode 100644 source/CPDLCPlugin/AdscContractStore.cs create mode 100644 source/CPDLCPlugin/AdscReportStore.cs create mode 100644 source/CPDLCPlugin/AtsuSettingsStore.cs create mode 100644 source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs create mode 100644 source/CPDLCPlugin/Messages/AdscContractUpdatedNotification.cs create mode 100644 source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs create mode 100644 source/CPDLCServer.Tests/Handlers/AdscMessageReceivedNotificationHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/AdscReportReceivedNotificationHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/CancelAdscContractCommandHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/CancelAdscContractOnConnectionEndedHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/GetAtsuSettingsRequestHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/RequestAdscContractOnConnectionEstablishedHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/RequestAdscDemandReportCommandHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Handlers/RequestPeriodicAdscContractCommandHandlerTests.cs create mode 100644 source/CPDLCServer.Tests/Mocks/TestAdscContractRepository.cs create mode 100644 source/CPDLCServer/Handlers/AdscContractChangedNotificationHandler.cs create mode 100644 source/CPDLCServer/Handlers/AdscContractRemovedNotificationHandler.cs create mode 100644 source/CPDLCServer/Handlers/AdscMessageReceivedNotificationHandler.cs create mode 100644 source/CPDLCServer/Handlers/AdscReportReceivedNotificationHandler.cs create mode 100644 source/CPDLCServer/Handlers/CancelAdscContractCommandHandler.cs create mode 100644 source/CPDLCServer/Handlers/CancelAdscContractOnConnectionEndedHandler.cs create mode 100644 source/CPDLCServer/Handlers/GetAdscContractsRequestHandler.cs create mode 100644 source/CPDLCServer/Handlers/GetAtsuSettingsRequestHandler.cs create mode 100644 source/CPDLCServer/Handlers/RequestAdscContractOnConnectionEstablishedHandler.cs create mode 100644 source/CPDLCServer/Handlers/RequestAdscDemandReportCommandHandler.cs create mode 100644 source/CPDLCServer/Handlers/RequestPeriodicAdscContractCommandHandler.cs create mode 100644 source/CPDLCServer/Messages/AdscContractChangedNotification.cs create mode 100644 source/CPDLCServer/Messages/AdscContractRemovedNotification.cs create mode 100644 source/CPDLCServer/Messages/AdscMessageReceivedNotification.cs create mode 100644 source/CPDLCServer/Messages/AdscReportReceivedNotification.cs create mode 100644 source/CPDLCServer/Messages/CancelAdscContractCommand.cs create mode 100644 source/CPDLCServer/Messages/GetAdscContractsRequest.cs create mode 100644 source/CPDLCServer/Messages/GetAtsuSettingsRequest.cs create mode 100644 source/CPDLCServer/Messages/RequestAdscDemandReportCommand.cs create mode 100644 source/CPDLCServer/Messages/RequestPeriodicAdscContractCommand.cs create mode 100644 source/CPDLCServer/Model/AdscContract.cs create mode 100644 source/CPDLCServer/Model/AdscContractConverter.cs create mode 100644 source/CPDLCServer/Model/AdscReport.cs create mode 100644 source/CPDLCServer/Model/ReceivedAdscMessage.cs create mode 100644 source/CPDLCServer/Persistence/IAdscContractRepository.cs create mode 100644 source/CPDLCServer/Persistence/InMemoryAdscContractRepository.cs diff --git a/docs/docs/server/index.md b/docs/docs/server/index.md index 8a5c450..b7300cb 100644 --- a/docs/docs/server/index.md +++ b/docs/docs/server/index.md @@ -46,6 +46,8 @@ Acars__0__FlightSimulationNetwork=VATSIM Acars__0__StationIdentifier=YBBB Acars__0__Url=http://www.hoppie.nl/acars/system/connect.html Acars__0__AuthenticationCode=your-hoppie-logon-code +# Reporting interval in minutes of the ADS-C contract requested on CPDLC logon (default: 10) +Acars__0__DefaultAdscReportingInterval=10 # Handoff Configuration # How many minutes before the expected transfer time to send the NEXT DATA AUTHORITY message (default: 10) @@ -78,6 +80,12 @@ Acars__1__AuthenticationCode=your-ymmm-hoppie-code `Hoppie` is currently the only supported ACARS network type. ::: +### ADS-C + +When an aircraft logs on with CPDLC, the server requests a periodic ADS-C contract with the aircraft. When the CPDLC connection ends, the server cancels the contract. + +Each ATSU has its own default reporting interval, set with `Acars____DefaultAdscReportingInterval`. Controllers can read this value, and they can request a different interval for an aircraft. + ## Running ### Docker diff --git a/example.env b/example.env index 3f70675..f669c00 100644 --- a/example.env +++ b/example.env @@ -13,6 +13,8 @@ Acars__0__FlightSimulationNetwork=VATSIM Acars__0__StationIdentifier=YBBB Acars__0__Url=http://www.hoppie.nl/acars/system/connect.html Acars__0__AuthenticationCode=your-authentication-code-here +# Reporting interval in minutes of the ADS-C contract requested on CPDLC logon (default: 10) +Acars__0__DefaultAdscReportingInterval=10 # Example: Second ACARS Configuration (uncomment to use) # Acars__1__Type=Hoppie diff --git a/source/CPDLCPlugin/AdscContractStore.cs b/source/CPDLCPlugin/AdscContractStore.cs new file mode 100644 index 0000000..d70d49d --- /dev/null +++ b/source/CPDLCPlugin/AdscContractStore.cs @@ -0,0 +1,76 @@ +using CPDLCServer.Contracts; + +namespace CPDLCPlugin; + +public class AdscContractStore +{ + readonly List _contracts = new(); + readonly SemaphoreSlim _semaphore = new(1, 1); + + public async Task Populate(AdscContractDto[] contracts, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + _contracts.Clear(); + _contracts.AddRange(contracts); + } + finally + { + _semaphore.Release(); + } + } + + public async Task Upsert(AdscContractDto contractDto, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + _contracts.RemoveAll(c => c.Callsign == contractDto.Callsign && c.StationId == contractDto.StationId); + _contracts.Add(contractDto); + } + finally + { + _semaphore.Release(); + } + } + + public async Task> All(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + return _contracts.ToArray(); + } + finally + { + _semaphore.Release(); + } + } + + public async Task Remove(string callsign, string stationId, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + return _contracts.RemoveAll(c => c.Callsign == callsign && c.StationId == stationId) > 0; + } + finally + { + _semaphore.Release(); + } + } + + public async Task Clear(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + _contracts.Clear(); + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/source/CPDLCPlugin/AdscReportStore.cs b/source/CPDLCPlugin/AdscReportStore.cs new file mode 100644 index 0000000..922711c --- /dev/null +++ b/source/CPDLCPlugin/AdscReportStore.cs @@ -0,0 +1,73 @@ +using CPDLCServer.Contracts; + +namespace CPDLCPlugin; + +/// +/// Holds the latest ADS-C report for each aircraft. +/// The reports stay after the contract ends, until the client disconnects. +/// +public class AdscReportStore +{ + readonly Dictionary _latestReports = new(); + readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + /// Stores the report, unless the store has a newer report for the same aircraft. + /// An aircraft can report to more than one ATSU, so reports can arrive out of order. + /// + public async Task Add(AdscReportDto report, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_latestReports.TryGetValue(report.Callsign, out var existing) && existing.Timestamp > report.Timestamp) + return; + + _latestReports[report.Callsign] = report; + } + finally + { + _semaphore.Release(); + } + } + + public async Task Find(string callsign, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + _latestReports.TryGetValue(callsign, out var report); + return report; + } + finally + { + _semaphore.Release(); + } + } + + public async Task> All(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + return _latestReports.Values.ToArray(); + } + finally + { + _semaphore.Release(); + } + } + + public async Task Clear(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + _latestReports.Clear(); + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/source/CPDLCPlugin/AtsuSettingsStore.cs b/source/CPDLCPlugin/AtsuSettingsStore.cs new file mode 100644 index 0000000..c1ec783 --- /dev/null +++ b/source/CPDLCPlugin/AtsuSettingsStore.cs @@ -0,0 +1,22 @@ +using CPDLCServer.Contracts; + +namespace CPDLCPlugin; + +/// +/// Holds the read-only settings of the ATSU this client is connected to. +/// The settings are null when the client is not connected. +/// +public class AtsuSettingsStore +{ + public AtsuSettingsDto? Settings { get; private set; } + + public void Set(AtsuSettingsDto settings) + { + Settings = settings; + } + + public void Clear() + { + Settings = null; + } +} diff --git a/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs b/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs new file mode 100644 index 0000000..0dcc87d --- /dev/null +++ b/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs @@ -0,0 +1,20 @@ +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record AdscContractRemovedNotification(string Callsign, string StationId) : INotification; + +public class AdscContractRemovedNotificationHandler(AdscContractStore adscContractStore, ILogger logger) + : INotificationHandler +{ + public async Task Handle(AdscContractRemovedNotification notification, CancellationToken cancellationToken) + { + logger.Information("ADS-C contract for {Callsign} on {StationId} removed", notification.Callsign, notification.StationId); + + if (!await adscContractStore.Remove(notification.Callsign, notification.StationId, cancellationToken)) + { + logger.Warning("ADS-C contract for {Callsign} was not tracked on {StationId}", notification.Callsign, notification.StationId); + } + } +} diff --git a/source/CPDLCPlugin/Messages/AdscContractUpdatedNotification.cs b/source/CPDLCPlugin/Messages/AdscContractUpdatedNotification.cs new file mode 100644 index 0000000..d369a3b --- /dev/null +++ b/source/CPDLCPlugin/Messages/AdscContractUpdatedNotification.cs @@ -0,0 +1,20 @@ +using CPDLCServer.Contracts; +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record AdscContractUpdatedNotification(AdscContractDto AdscContractDto) : INotification; + +public class AdscContractUpdatedNotificationHandler(AdscContractStore adscContractStore, ILogger logger) + : INotificationHandler +{ + public async Task Handle(AdscContractUpdatedNotification notification, CancellationToken cancellationToken) + { + logger.Information("Upserting ADS-C contract for {Callsign} on {StationId}", + notification.AdscContractDto.Callsign, + notification.AdscContractDto.StationId); + + await adscContractStore.Upsert(notification.AdscContractDto, cancellationToken); + } +} diff --git a/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs b/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs new file mode 100644 index 0000000..be47014 --- /dev/null +++ b/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs @@ -0,0 +1,26 @@ +using CPDLCServer.Contracts; +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record AdscReportReceivedNotification(AdscReportDto AdscReportDto) : INotification; + +public class AdscReportReceivedNotificationHandler(AdscReportStore adscReportStore, ILogger logger) + : INotificationHandler +{ + public async Task Handle(AdscReportReceivedNotification notification, CancellationToken cancellationToken) + { + var report = notification.AdscReportDto; + logger.Information( + "ADS-C report from {Callsign} via {StationId}: {Latitude} {Longitude} {Altitude} at {Timestamp}", + report.Callsign, + report.StationId, + report.Latitude, + report.Longitude, + report.Altitude, + report.Timestamp); + + await adscReportStore.Add(report, cancellationToken); + } +} diff --git a/source/CPDLCPlugin/Messages/ConnectedNotification.cs b/source/CPDLCPlugin/Messages/ConnectedNotification.cs index 139e491..8aa4674 100644 --- a/source/CPDLCPlugin/Messages/ConnectedNotification.cs +++ b/source/CPDLCPlugin/Messages/ConnectedNotification.cs @@ -11,6 +11,8 @@ public class ConnectedNotificationHandler( DialogueStore dialogueStore, AircraftConnectionStore aircraftConnectionStore, AcarsStationStore acarsStationStore, + AdscContractStore adscContractStore, + AtsuSettingsStore atsuSettingsStore, ILogger logger) : INotificationHandler { @@ -23,6 +25,15 @@ public async Task Handle(ConnectedNotification notification, CancellationToken c return; } + // Load ATSU settings + logger.Verbose("Loading ATSU settings"); + var atsuSettings = await plugin.ConnectionManager.GetAtsuSettings(cancellationToken); + atsuSettingsStore.Set(atsuSettings); + logger.Information( + "Loaded ATSU settings for {StationId} (Default ADS-C reporting interval: {DefaultAdscReportingInterval})", + atsuSettings.StationId, + atsuSettings.DefaultAdscReportingInterval); + // Load dialogues logger.Verbose("Loading all dialogues"); var dialogues = await plugin.ConnectionManager.GetAllDialogues(cancellationToken); @@ -41,6 +52,12 @@ public async Task Handle(ConnectedNotification notification, CancellationToken c await acarsStationStore.Populate(acarsConnectedCallsigns, cancellationToken); logger.Information("Loaded {Count} ACARS connected callsign(s)", acarsConnectedCallsigns.Length); + // Load ADS-C contracts + logger.Verbose("Loading all ADS-C contracts"); + var adscContracts = await plugin.ConnectionManager.GetAdscContracts(cancellationToken); + await adscContractStore.Populate(adscContracts, cancellationToken); + logger.Information("Loaded {ContractCount} ADS-C contract(s)", adscContracts.Length); + // Relay notification to UI WeakReferenceMessenger.Default.Send(notification); } diff --git a/source/CPDLCPlugin/Messages/DisconnectedNotification.cs b/source/CPDLCPlugin/Messages/DisconnectedNotification.cs index 644e4a6..96b586c 100644 --- a/source/CPDLCPlugin/Messages/DisconnectedNotification.cs +++ b/source/CPDLCPlugin/Messages/DisconnectedNotification.cs @@ -10,6 +10,9 @@ public class DisconnectedNotificationBridge( DialogueStore dialogueStore, AircraftConnectionStore aircraftConnectionStore, AcarsStationStore acarsStationStore, + AdscContractStore adscContractStore, + AdscReportStore adscReportStore, + AtsuSettingsStore atsuSettingsStore, ILogger logger) : INotificationHandler { public async Task Handle(DisconnectedNotification notification, CancellationToken cancellationToken) @@ -23,6 +26,15 @@ public async Task Handle(DisconnectedNotification notification, CancellationToke logger.Information("Clearing ACARS station store"); await acarsStationStore.Clear(cancellationToken); + logger.Information("Clearing ADS-C contract store"); + await adscContractStore.Clear(cancellationToken); + + logger.Information("Clearing ADS-C report store"); + await adscReportStore.Clear(cancellationToken); + + logger.Information("Clearing ATSU settings"); + atsuSettingsStore.Clear(); + WeakReferenceMessenger.Default.Send(notification); } } diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs index 6bc66ac..633e7c0 100644 --- a/source/CPDLCPlugin/Plugin.cs +++ b/source/CPDLCPlugin/Plugin.cs @@ -96,6 +96,9 @@ void ConfigureServices(PluginConfiguration pluginConfiguration) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/source/CPDLCPlugin/Server/IDownlinkHandlerDelegate.cs b/source/CPDLCPlugin/Server/IDownlinkHandlerDelegate.cs index 91fae34..8e9b134 100644 --- a/source/CPDLCPlugin/Server/IDownlinkHandlerDelegate.cs +++ b/source/CPDLCPlugin/Server/IDownlinkHandlerDelegate.cs @@ -8,5 +8,8 @@ public interface IDownlinkHandlerDelegate Task AircraftConnectionUpdated(AircraftConnectionDto aircraftConnectionDto, CancellationToken cancellationToken); Task AircraftConnectionRemoved(string callsign, string stationId, CancellationToken cancellationToken); Task AcarsStationsUpdated(string[] callsigns, CancellationToken cancellationToken); + Task AdscContractUpdated(AdscContractDto adscContractDto, CancellationToken cancellationToken); + Task AdscContractRemoved(string callsign, string stationId, CancellationToken cancellationToken); + Task AdscReportReceived(AdscReportDto adscReportDto, CancellationToken cancellationToken); void Error(Exception exception); } diff --git a/source/CPDLCPlugin/Server/MediatorMessageHandler.cs b/source/CPDLCPlugin/Server/MediatorMessageHandler.cs index 6d28050..3cb9099 100644 --- a/source/CPDLCPlugin/Server/MediatorMessageHandler.cs +++ b/source/CPDLCPlugin/Server/MediatorMessageHandler.cs @@ -72,6 +72,54 @@ public async Task AcarsStationsUpdated(string[] callsigns, CancellationToken can } } + public async Task AdscContractUpdated(AdscContractDto adscContractDto, CancellationToken cancellationToken) + { + try + { + await mediator.Publish(new AdscContractUpdatedNotification(adscContractDto), cancellationToken); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested + } + catch (Exception ex) + { + Plugin.AddError(ex, "Failed to handle AdscContractUpdated notification"); + } + } + + public async Task AdscContractRemoved(string callsign, string stationId, CancellationToken cancellationToken) + { + try + { + await mediator.Publish(new AdscContractRemovedNotification(callsign, stationId), cancellationToken); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested + } + catch (Exception ex) + { + Plugin.AddError(ex, "Failed to handle AdscContractRemoved notification"); + } + } + + public async Task AdscReportReceived(AdscReportDto adscReportDto, CancellationToken cancellationToken) + { + try + { + await mediator.Publish(new AdscReportReceivedNotification(adscReportDto), cancellationToken); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested + } + catch (Exception ex) + { + Plugin.AddError(ex, "Failed to handle AdscReportReceived notification"); + } + } + public void Error(Exception error) { Plugin.AddError(error, "Error from SignalR connection"); diff --git a/source/CPDLCPlugin/Server/SignalRConnectionManager.cs b/source/CPDLCPlugin/Server/SignalRConnectionManager.cs index 42e94dc..fd6f3fb 100644 --- a/source/CPDLCPlugin/Server/SignalRConnectionManager.cs +++ b/source/CPDLCPlugin/Server/SignalRConnectionManager.cs @@ -122,6 +122,12 @@ void RegisterHandlers() WithCancellationToken(downlinkHandlerDelegate.AircraftConnectionRemoved)(callsign, stationId)); _connection.On("AcarsStationsUpdated", callsigns => WithCancellationToken(downlinkHandlerDelegate.AcarsStationsUpdated)(callsigns)); + _connection.On("AdscContractUpdated", adscContract => + WithCancellationToken(downlinkHandlerDelegate.AdscContractUpdated)(adscContract)); + _connection.On("AdscContractRemoved", (callsign, stationId) => + WithCancellationToken(downlinkHandlerDelegate.AdscContractRemoved)(callsign, stationId)); + _connection.On("AdscReportReceived", adscReport => + WithCancellationToken(downlinkHandlerDelegate.AdscReportReceived)(adscReport)); } Func WithCancellationToken(Func action) @@ -231,6 +237,47 @@ await connection.InvokeAsync( cancellationToken: cancellationToken); } + public async Task GetAtsuSettings(CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + return await connection.InvokeAsync( + "GetAtsuSettings", + cancellationToken); + } + + public async Task RequestPeriodicAdscContract( + string callsign, + TimeSpan reportingInterval, + CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + await connection.InvokeAsync( + "RequestPeriodicAdscContract", + callsign, + reportingInterval, + cancellationToken: cancellationToken); + } + + public async Task RequestAdscDemandReport(string callsign, CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + await connection.InvokeAsync("RequestAdscDemandReport", callsign, cancellationToken); + } + + public async Task CancelAdscContract(string callsign, CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + await connection.InvokeAsync("CancelAdscContract", callsign, cancellationToken); + } + + public async Task GetAdscContracts(CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + return await connection.InvokeAsync( + "GetAdscContracts", + cancellationToken); + } + public async Task GetAllDialogues(CancellationToken cancellationToken) { var connection = GetConnectedOrThrow(); diff --git a/source/CPDLCServer.Contracts/AcarsMessages.cs b/source/CPDLCServer.Contracts/AcarsMessages.cs index a196ecc..88ba0aa 100644 --- a/source/CPDLCServer.Contracts/AcarsMessages.cs +++ b/source/CPDLCServer.Contracts/AcarsMessages.cs @@ -37,6 +37,31 @@ public enum AlertType public record ControllerConnectionDto(string StationId, string Callsign, string VatsimCid); +// Read-only settings of the ATSU the controller is connected to +public record AtsuSettingsDto( + string StationId, + TimeSpan DefaultAdscReportingInterval); + +// ADS-C DTOs for SignalR API + +// ReportingInterval is null when only demand reports were requested +public record AdscContractDto( + string Callsign, + string StationId, + TimeSpan? ReportingInterval, + DateTimeOffset Requested); + +// Timestamp is the time of the position, as reported by the aircraft. +// Received is the time the server received the report. +public record AdscReportDto( + string Callsign, + string StationId, + DateTimeOffset Timestamp, + double Latitude, + double Longitude, + int Altitude, + DateTimeOffset Received); + // Dialogue DTOs for SignalR API public record DialogueDto( diff --git a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs index e9d092d..ebd6396 100644 --- a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs +++ b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs @@ -430,6 +430,195 @@ public async Task ListConnections_EmptyResponse_ReturnsEmptyArray() Assert.Empty(callsigns); } + [Fact] + public async Task RequestPeriodicAdscContract_FormatsPayloadCorrectly() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler); + + // Act + await client.RequestPeriodicAdscContract("UAL123", TimeSpan.FromMinutes(5), CancellationToken.None); + + // Assert + var formData = await ParseFormDataFromRequest(httpHandler.Requests.Single()); + Assert.Equal("ads-c", formData["type"]); + Assert.Equal("YBBB", formData["from"]); + Assert.Equal("UAL123", formData["to"]); + Assert.Equal("REQUEST+PERIODIC+300", formData["packet"]); + } + + [Fact] + public async Task RequestPeriodicAdscContract_WhenIntervalIsZero_Throws() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + var client = CreateClient(httpHandler); + + // Act & Assert + await Assert.ThrowsAsync(() => + client.RequestPeriodicAdscContract("UAL123", TimeSpan.Zero, CancellationToken.None)); + Assert.Empty(httpHandler.Requests); + } + + [Fact] + public async Task RequestAdscDemandReport_FormatsPayloadCorrectly() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler); + + // Act + await client.RequestAdscDemandReport("UAL123", CancellationToken.None); + + // Assert + var formData = await ParseFormDataFromRequest(httpHandler.Requests.Single()); + Assert.Equal("ads-c", formData["type"]); + Assert.Equal("UAL123", formData["to"]); + Assert.Equal("REQUEST+PERIODIC+0", formData["packet"]); + } + + [Fact] + public async Task CancelAdscContract_FormatsPayloadCorrectly() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler); + + // Act + await client.CancelAdscContract("UAL123", CancellationToken.None); + + // Assert + var formData = await ParseFormDataFromRequest(httpHandler.Requests.Single()); + Assert.Equal("ads-c", formData["type"]); + Assert.Equal("UAL123", formData["to"]); + Assert.Equal("REQUEST+CANCEL", formData["packet"]); + } + + [Fact] + public async Task Poll_AdscReport_ParsesBasicGroup() + { + // Arrange + var clock = new TestClock(); + clock.SetUtcNow(new DateTimeOffset(2026, 10, 13, 12, 0, 0, TimeSpan.Zero)); + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, "ok {UAL123 ads-c {REPORT UAL123 131155 -33.946100 151.177200 37000}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler, clock); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var message = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var reportMessage = Assert.IsType(message); + Assert.Equal("UAL123", reportMessage.Sender); + Assert.Equal(new DateTimeOffset(2026, 10, 13, 11, 55, 0, TimeSpan.Zero), reportMessage.Report.Timestamp); + Assert.Equal(-33.9461, reportMessage.Report.Latitude); + Assert.Equal(151.1772, reportMessage.Report.Longitude); + Assert.Equal(37000, reportMessage.Report.Altitude); + Assert.Equal(clock.UtcNow(), reportMessage.Received); + } + + [Fact] + public async Task Poll_AdscReportWithOptionalGroups_ParsesBasicGroup() + { + // Arrange + var clock = new TestClock(); + clock.SetUtcNow(new DateTimeOffset(2026, 10, 13, 12, 0, 0, TimeSpan.Zero)); + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, "ok {UAL123 ads-c {REPORT UAL123 131155 -33.946100 151.177200 37000 045 480 270/35 -56 LVL}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler, clock); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var message = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var reportMessage = Assert.IsType(message); + Assert.Equal(-33.9461, reportMessage.Report.Latitude); + Assert.Equal(151.1772, reportMessage.Report.Longitude); + Assert.Equal(37000, reportMessage.Report.Altitude); + } + + [Fact] + public async Task Poll_AdscReportFromPreviousMonth_ParsesTimestamp() + { + // Arrange + var clock = new TestClock(); + clock.SetUtcNow(new DateTimeOffset(2026, 11, 1, 0, 2, 0, TimeSpan.Zero)); + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, "ok {UAL123 ads-c {REPORT UAL123 312359 -33.946100 151.177200 37000}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler, clock); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var message = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var reportMessage = Assert.IsType(message); + Assert.Equal(new DateTimeOffset(2026, 10, 31, 23, 59, 0, TimeSpan.Zero), reportMessage.Report.Timestamp); + } + + [Fact] + public async Task Poll_AdscReject_ParsesCorrectly() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, "ok {UAL123 ads-c {REJECT}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var message = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var rejection = Assert.IsType(message); + Assert.Equal("UAL123", rejection.Sender); + } + + [Fact] + public async Task Poll_CpdlcAndAdscMessages_AreWrittenToSeparateReaders() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, "ok {UAL123 cpdlc {/data2/5//Y/REQUEST DESCENT}} {DAL456 ads-c {REJECT}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var downlink = await client.MessageReader.ReadAsync(cts.Token); + var adscMessage = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + Assert.Equal("UAL123", downlink.Sender); + Assert.Equal("DAL456", adscMessage.Sender); + Assert.False(client.MessageReader.TryRead(out _)); + Assert.False(client.AdscMessageReader.TryRead(out _)); + } + private async Task> ParseFormDataFromRequest(HttpRequestMessage request) { var formDataString = await request.Content!.ReadAsStringAsync(); diff --git a/source/CPDLCServer.Tests/Handlers/AdscMessageReceivedNotificationHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/AdscMessageReceivedNotificationHandlerTests.cs new file mode 100644 index 0000000..e7fbf7c --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/AdscMessageReceivedNotificationHandlerTests.cs @@ -0,0 +1,142 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class AdscMessageReceivedNotificationHandlerTests +{ + [Fact] + public async Task Handle_Report_PublishesReport() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(5), clock.UtcNow()); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new AdscMessageReceivedNotificationHandler(contractRepository, mediator, Logger.None); + + var report = new AdscReport(clock.UtcNow(), -33.9461, 151.1772, 37000); + var received = clock.UtcNow().AddSeconds(30); + var notification = new AdscMessageReceivedNotification( + "hoppies-ybbb", + "YBBB", + new ReceivedAdscReport("UAL123", report, received)); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + await mediator.Received(1).Publish( + Arg.Is(n => + n.Callsign == "UAL123" && + n.StationId == "YBBB" && + n.Report == report && + n.Received == received), + Arg.Any()); + } + + [Fact] + public async Task Handle_Report_WhenNoContract_IsIgnored() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var mediator = Substitute.For(); + var handler = new AdscMessageReceivedNotificationHandler(contractRepository, mediator, Logger.None); + + var report = new AdscReport(clock.UtcNow(), -33.9461, 151.1772, 37000); + var notification = new AdscMessageReceivedNotification( + "hoppies-ybbb", + "YBBB", + new ReceivedAdscReport("UAL123", report, clock.UtcNow())); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + Assert.Empty(await contractRepository.All(CancellationToken.None)); + Assert.Empty(mediator.ReceivedCalls()); + } + + [Fact] + public async Task Handle_Report_WhenContractIsWithOtherStation_IsIgnored() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YMMM", TimeSpan.FromMinutes(5), clock.UtcNow()); + await contractRepository.Add(new AdscContractKey("UAL123", "YMMM"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new AdscMessageReceivedNotificationHandler(contractRepository, mediator, Logger.None); + + var report = new AdscReport(clock.UtcNow(), -33.9461, 151.1772, 37000); + var notification = new AdscMessageReceivedNotification( + "hoppies-ybbb", + "YBBB", + new ReceivedAdscReport("UAL123", report, clock.UtcNow())); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + Assert.Empty(mediator.ReceivedCalls()); + } + + [Fact] + public async Task Handle_Rejection_RemovesContract() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(5), clock.UtcNow()); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new AdscMessageReceivedNotificationHandler(contractRepository, mediator, Logger.None); + + var notification = new AdscMessageReceivedNotification( + "hoppies-ybbb", + "YBBB", + new ReceivedAdscRejection("UAL123", clock.UtcNow())); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + Assert.Empty(await contractRepository.All(CancellationToken.None)); + + await mediator.Received(1).Publish( + Arg.Is(n => n.Callsign == "UAL123" && n.StationId == "YBBB"), + Arg.Any()); + } + + [Fact] + public async Task Handle_Rejection_WhenNoContract_IsIgnored() + { + // Arrange + var clock = new TestClock(); + var mediator = Substitute.For(); + var handler = new AdscMessageReceivedNotificationHandler( + new TestAdscContractRepository(), mediator, Logger.None); + + var notification = new AdscMessageReceivedNotification( + "hoppies-ybbb", + "YBBB", + new ReceivedAdscRejection("UAL123", clock.UtcNow())); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + Assert.Empty(mediator.ReceivedCalls()); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/AdscReportReceivedNotificationHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/AdscReportReceivedNotificationHandlerTests.cs new file mode 100644 index 0000000..cb15300 --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/AdscReportReceivedNotificationHandlerTests.cs @@ -0,0 +1,78 @@ +using CPDLCServer.Contracts; +using CPDLCServer.Handlers; +using CPDLCServer.Hubs; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Tests.Mocks; +using Microsoft.AspNetCore.SignalR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class AdscReportReceivedNotificationHandlerTests +{ + [Fact] + public async Task Handle_NotifiesAllConnectedControllers() + { + // Arrange + var controllerRepository = new TestControllerRepository(); + await controllerRepository.Add( + new ControllerInfo(Guid.NewGuid(), "ConnectionId-1", "YBBB", "BN-TSN_FSS", "1234567"), + CancellationToken.None); + await controllerRepository.Add( + new ControllerInfo(Guid.NewGuid(), "ConnectionId-2", "YMMM", "ML-ASP_CTR", "7654321"), + CancellationToken.None); + + var hubContext = Substitute.For>(); + var clientProxy = Substitute.For(); + hubContext.Clients.Clients(Arg.Any>()).Returns(clientProxy); + + var handler = new AdscReportReceivedNotificationHandler(controllerRepository, hubContext, Logger.None); + + var timestamp = new DateTimeOffset(2026, 10, 13, 11, 55, 0, TimeSpan.Zero); + var received = timestamp.AddSeconds(40); + var notification = new AdscReportReceivedNotification( + "UAL123", + "YBBB", + new AdscReport(timestamp, -33.9461, 151.1772, 37000), + received); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + hubContext.Clients.Received(1).Clients( + Arg.Is>(ids => + ids.Count == 2 && + ids.Contains("ConnectionId-1") && + ids.Contains("ConnectionId-2"))); + + var expectedDto = new AdscReportDto("UAL123", "YBBB", timestamp, -33.9461, 151.1772, 37000, received); + await clientProxy.Received(1).SendCoreAsync( + "AdscReportReceived", + Arg.Is(args => args.Length == 1 && expectedDto.Equals(args[0])), + Arg.Any()); + } + + [Fact] + public async Task Handle_DoesNotNotifyWhenNoControllersConnected() + { + // Arrange + var hubContext = Substitute.For>(); + var handler = new AdscReportReceivedNotificationHandler( + new TestControllerRepository(), hubContext, Logger.None); + + var notification = new AdscReportReceivedNotification( + "UAL123", + "YBBB", + new AdscReport(DateTimeOffset.UtcNow, -33.9461, 151.1772, 37000), + DateTimeOffset.UtcNow); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + hubContext.Clients.DidNotReceive().Clients(Arg.Any>()); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/CancelAdscContractCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/CancelAdscContractCommandHandlerTests.cs new file mode 100644 index 0000000..cb7a1eb --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/CancelAdscContractCommandHandlerTests.cs @@ -0,0 +1,82 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class CancelAdscContractCommandHandlerTests +{ + [Fact] + public async Task Handle_WhenContractExists_SendsCancelAndRemovesContract() + { + // Arrange + var clock = new TestClock(); + var clientManager = new TestClientManager(); + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(5), clock.UtcNow()); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new CancelAdscContractCommandHandler(clientManager, contractRepository, mediator, Logger.None); + + // Act + await handler.Handle(new CancelAdscContractCommand("YBBB", "UAL123"), CancellationToken.None); + + // Assert + var client = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Equal("UAL123", Assert.Single(client.CancelledAdscContracts)); + Assert.Empty(await contractRepository.All(CancellationToken.None)); + + await mediator.Received(1).Publish( + Arg.Is(n => n.Callsign == "UAL123" && n.StationId == "YBBB"), + Arg.Any()); + } + + [Fact] + public async Task Handle_WhenContractNotFound_SendsNothing() + { + // Arrange + var clientManager = new TestClientManager(); + var mediator = Substitute.For(); + var handler = new CancelAdscContractCommandHandler( + clientManager, new TestAdscContractRepository(), mediator, Logger.None); + + // Act + await handler.Handle(new CancelAdscContractCommand("YBBB", "UAL123"), CancellationToken.None); + + // Assert + var client = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Empty(client.CancelledAdscContracts); + + await mediator.DidNotReceive().Publish( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Handle_OnlyRemovesContractForStation() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var ybbbContract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(5), clock.UtcNow()); + var ymmmContract = new AdscContract("UAL123", "YMMM", TimeSpan.FromMinutes(5), clock.UtcNow()); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), ybbbContract, CancellationToken.None); + await contractRepository.Add(new AdscContractKey("UAL123", "YMMM"), ymmmContract, CancellationToken.None); + + var handler = new CancelAdscContractCommandHandler( + new TestClientManager(), contractRepository, Substitute.For(), Logger.None); + + // Act + await handler.Handle(new CancelAdscContractCommand("YBBB", "UAL123"), CancellationToken.None); + + // Assert + var remaining = Assert.Single(await contractRepository.All(CancellationToken.None)); + Assert.Same(ymmmContract, remaining); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/CancelAdscContractOnConnectionEndedHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/CancelAdscContractOnConnectionEndedHandlerTests.cs new file mode 100644 index 0000000..e007ea4 --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/CancelAdscContractOnConnectionEndedHandlerTests.cs @@ -0,0 +1,119 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class CancelAdscContractOnConnectionEndedHandlerTests +{ + [Fact] + public async Task Handle_ConnectionTerminated_CancelsContract() + { + // Arrange + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(5), DateTimeOffset.UtcNow); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new CancelAdscContractOnConnectionEndedHandler( + new TestClientManager(), contractRepository, mediator, Logger.None); + + // Act + await handler.Handle( + new AircraftConnectionTerminated("hoppies-ybbb", "YBBB", "UAL123"), + CancellationToken.None); + + // Assert + await mediator.Received(1).Send( + Arg.Is(c => c.StationId == "YBBB" && c.Callsign == "UAL123"), + Arg.Any()); + } + + [Fact] + public async Task Handle_ConnectionLost_CancelsContractForStationOfAcarsClient() + { + // Arrange + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YMMM", TimeSpan.FromMinutes(5), DateTimeOffset.UtcNow); + await contractRepository.Add(new AdscContractKey("UAL123", "YMMM"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new CancelAdscContractOnConnectionEndedHandler( + new TestClientManager(), contractRepository, mediator, Logger.None); + + // Act + await handler.Handle( + new AircraftConnectionLost("hoppies-ymmm", "UAL123"), + CancellationToken.None); + + // Assert + await mediator.Received(1).Send( + Arg.Is(c => c.StationId == "YMMM" && c.Callsign == "UAL123"), + Arg.Any()); + } + + [Fact] + public async Task Handle_WhenNoContract_DoesNotCancel() + { + // Arrange + var mediator = Substitute.For(); + var handler = new CancelAdscContractOnConnectionEndedHandler( + new TestClientManager(), new TestAdscContractRepository(), mediator, Logger.None); + + // Act + await handler.Handle( + new AircraftConnectionTerminated("hoppies-ybbb", "YBBB", "UAL123"), + CancellationToken.None); + + // Assert + Assert.Empty(mediator.ReceivedCalls()); + } + + [Fact] + public async Task Handle_WhenContractIsWithOtherStation_DoesNotCancel() + { + // Arrange + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YMMM", TimeSpan.FromMinutes(5), DateTimeOffset.UtcNow); + await contractRepository.Add(new AdscContractKey("UAL123", "YMMM"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new CancelAdscContractOnConnectionEndedHandler( + new TestClientManager(), contractRepository, mediator, Logger.None); + + // Act + await handler.Handle( + new AircraftConnectionTerminated("hoppies-ybbb", "YBBB", "UAL123"), + CancellationToken.None); + + // Assert + Assert.Empty(mediator.ReceivedCalls()); + } + + [Fact] + public async Task Handle_WhenCancelFails_DoesNotThrow() + { + // Arrange + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(5), DateTimeOffset.UtcNow); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + mediator + .Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new HttpRequestException("Hoppie is down"))); + + var handler = new CancelAdscContractOnConnectionEndedHandler( + new TestClientManager(), contractRepository, mediator, Logger.None); + + // Act & Assert - should not throw + await handler.Handle( + new AircraftConnectionTerminated("hoppies-ybbb", "YBBB", "UAL123"), + CancellationToken.None); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/GetAtsuSettingsRequestHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/GetAtsuSettingsRequestHandlerTests.cs new file mode 100644 index 0000000..33c3749 --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/GetAtsuSettingsRequestHandlerTests.cs @@ -0,0 +1,35 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Tests.Mocks; + +namespace CPDLCServer.Tests.Handlers; + +public class GetAtsuSettingsRequestHandlerTests +{ + [Fact] + public async Task Handle_ReturnsSettingsForStation() + { + // Arrange + var clientManager = new TestClientManager(); + clientManager.AddConfiguration(TestClientManager.CreateConfiguration("NZZO", 27)); + var handler = new GetAtsuSettingsRequestHandler(clientManager); + + // Act + var result = await handler.Handle(new GetAtsuSettingsRequest("NZZO"), CancellationToken.None); + + // Assert + Assert.Equal("NZZO", result.StationId); + Assert.Equal(TimeSpan.FromMinutes(27), result.DefaultAdscReportingInterval); + } + + [Fact] + public async Task Handle_WhenStationNotConfigured_Throws() + { + // Arrange + var handler = new GetAtsuSettingsRequestHandler(new TestClientManager()); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => + handler.Handle(new GetAtsuSettingsRequest("KZAK"), CancellationToken.None)); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/RequestAdscContractOnConnectionEstablishedHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/RequestAdscContractOnConnectionEstablishedHandlerTests.cs new file mode 100644 index 0000000..475683d --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/RequestAdscContractOnConnectionEstablishedHandlerTests.cs @@ -0,0 +1,104 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class RequestAdscContractOnConnectionEstablishedHandlerTests +{ + [Fact] + public async Task Handle_RequestsContractWithDefaultIntervalForStation() + { + // Arrange + var mediator = Substitute.For(); + var handler = new RequestAdscContractOnConnectionEstablishedHandler( + new TestClientManager(), new TestAdscContractRepository(), mediator, Logger.None); + + var notification = new AircraftConnectionEstablished( + "hoppies-ymmm", "YMMM", "UAL123", DataAuthorityState.NextDataAuthority); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert - TestClientManager sets the YMMM default to 20 minutes + await mediator.Received(1).Send( + Arg.Is(c => + c.StationId == "YMMM" && + c.Callsign == "UAL123" && + c.ReportingInterval == TimeSpan.FromMinutes(20)), + Arg.Any()); + } + + [Fact] + public async Task Handle_WhenPeriodicContractExists_DoesNotRequestContract() + { + // Arrange + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(5), DateTimeOffset.UtcNow); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new RequestAdscContractOnConnectionEstablishedHandler( + new TestClientManager(), contractRepository, mediator, Logger.None); + + var notification = new AircraftConnectionEstablished( + "hoppies-ybbb", "YBBB", "UAL123", DataAuthorityState.NextDataAuthority); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert + Assert.Empty(mediator.ReceivedCalls()); + } + + [Fact] + public async Task Handle_WhenOnlyDemandContractExists_RequestsContract() + { + // Arrange + var contractRepository = new TestAdscContractRepository(); + var contract = new AdscContract("UAL123", "YBBB", null, DateTimeOffset.UtcNow); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), contract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new RequestAdscContractOnConnectionEstablishedHandler( + new TestClientManager(), contractRepository, mediator, Logger.None); + + var notification = new AircraftConnectionEstablished( + "hoppies-ybbb", "YBBB", "UAL123", DataAuthorityState.NextDataAuthority); + + // Act + await handler.Handle(notification, CancellationToken.None); + + // Assert - TestClientManager sets the YBBB default to 10 minutes + await mediator.Received(1).Send( + Arg.Is(c => + c.StationId == "YBBB" && + c.Callsign == "UAL123" && + c.ReportingInterval == TimeSpan.FromMinutes(10)), + Arg.Any()); + } + + [Fact] + public async Task Handle_WhenRequestFails_DoesNotThrow() + { + // Arrange + var mediator = Substitute.For(); + mediator + .Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new HttpRequestException("Hoppie is down"))); + + var handler = new RequestAdscContractOnConnectionEstablishedHandler( + new TestClientManager(), new TestAdscContractRepository(), mediator, Logger.None); + + var notification = new AircraftConnectionEstablished( + "hoppies-ybbb", "YBBB", "UAL123", DataAuthorityState.NextDataAuthority); + + // Act & Assert - should not throw + await handler.Handle(notification, CancellationToken.None); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/RequestAdscDemandReportCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/RequestAdscDemandReportCommandHandlerTests.cs new file mode 100644 index 0000000..69a3f0e --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/RequestAdscDemandReportCommandHandlerTests.cs @@ -0,0 +1,78 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class RequestAdscDemandReportCommandHandlerTests +{ + [Fact] + public async Task Handle_SendsRequestViaAcarsClientForStation() + { + // Arrange + var clientManager = new TestClientManager(); + var handler = new RequestAdscDemandReportCommandHandler( + clientManager, new TestAdscContractRepository(), Substitute.For(), new TestClock(), Logger.None); + + // Act + await handler.Handle(new RequestAdscDemandReportCommand("YBBB", "UAL123"), CancellationToken.None); + + // Assert + var client = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Equal("UAL123", Assert.Single(client.RequestedAdscDemandReports)); + Assert.Empty(client.RequestedAdscContracts); + } + + [Fact] + public async Task Handle_WhenNoContractExists_AddsContractWithNoReportingInterval() + { + // Arrange + var contractRepository = new TestAdscContractRepository(); + var mediator = Substitute.For(); + var handler = new RequestAdscDemandReportCommandHandler( + new TestClientManager(), contractRepository, mediator, new TestClock(), Logger.None); + + // Act + await handler.Handle(new RequestAdscDemandReportCommand("YBBB", "UAL123"), CancellationToken.None); + + // Assert + var contract = await contractRepository.Find(new AdscContractKey("UAL123", "YBBB"), CancellationToken.None); + Assert.NotNull(contract); + Assert.Null(contract.ReportingInterval); + + await mediator.Received(1).Publish( + Arg.Is(n => n.Contract == contract), + Arg.Any()); + } + + [Fact] + public async Task Handle_WhenPeriodicContractExists_KeepsReportingInterval() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var existingContract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(10), clock.UtcNow()); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), existingContract, CancellationToken.None); + + var mediator = Substitute.For(); + var handler = new RequestAdscDemandReportCommandHandler( + new TestClientManager(), contractRepository, mediator, clock, Logger.None); + + // Act + await handler.Handle(new RequestAdscDemandReportCommand("YBBB", "UAL123"), CancellationToken.None); + + // Assert + var contract = Assert.Single(await contractRepository.All(CancellationToken.None)); + Assert.Same(existingContract, contract); + Assert.Equal(TimeSpan.FromMinutes(10), contract.ReportingInterval); + + await mediator.DidNotReceive().Publish( + Arg.Any(), + Arg.Any()); + } +} diff --git a/source/CPDLCServer.Tests/Handlers/RequestPeriodicAdscContractCommandHandlerTests.cs b/source/CPDLCServer.Tests/Handlers/RequestPeriodicAdscContractCommandHandlerTests.cs new file mode 100644 index 0000000..80c1729 --- /dev/null +++ b/source/CPDLCServer.Tests/Handlers/RequestPeriodicAdscContractCommandHandlerTests.cs @@ -0,0 +1,125 @@ +using CPDLCServer.Handlers; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using CPDLCServer.Tests.Mocks; +using MediatR; +using NSubstitute; +using Serilog.Core; + +namespace CPDLCServer.Tests.Handlers; + +public class RequestPeriodicAdscContractCommandHandlerTests +{ + [Fact] + public async Task Handle_SendsRequestViaAcarsClientForStation() + { + // Arrange + var clientManager = new TestClientManager(); + var handler = new RequestPeriodicAdscContractCommandHandler( + clientManager, new TestAdscContractRepository(), Substitute.For(), new TestClock(), Logger.None); + + var command = new RequestPeriodicAdscContractCommand("YBBB", "UAL123", TimeSpan.FromMinutes(5)); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + var ybbbClient = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + var ymmmClient = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ymmm", CancellationToken.None); + Assert.Equal(TimeSpan.FromMinutes(5), ybbbClient.RequestedAdscContracts["UAL123"]); + Assert.Empty(ymmmClient.RequestedAdscContracts); + } + + [Fact] + public async Task Handle_WhenNoContractExists_AddsContract() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var mediator = Substitute.For(); + var handler = new RequestPeriodicAdscContractCommandHandler( + new TestClientManager(), contractRepository, mediator, clock, Logger.None); + + var command = new RequestPeriodicAdscContractCommand("YBBB", "UAL123", TimeSpan.FromMinutes(5)); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + var contract = await contractRepository.Find(new AdscContractKey("UAL123", "YBBB"), CancellationToken.None); + Assert.NotNull(contract); + Assert.Equal(TimeSpan.FromMinutes(5), contract.ReportingInterval); + Assert.Equal(clock.UtcNow(), contract.Requested); + + await mediator.Received(1).Publish( + Arg.Is(n => n.Contract == contract), + Arg.Any()); + } + + [Fact] + public async Task Handle_WhenContractExists_ChangesReportingInterval() + { + // Arrange + var clock = new TestClock(); + var contractRepository = new TestAdscContractRepository(); + var existingContract = new AdscContract("UAL123", "YBBB", TimeSpan.FromMinutes(10), clock.UtcNow()); + await contractRepository.Add(new AdscContractKey("UAL123", "YBBB"), existingContract, CancellationToken.None); + + var handler = new RequestPeriodicAdscContractCommandHandler( + new TestClientManager(), contractRepository, Substitute.For(), clock, Logger.None); + + clock.SetUtcNow(clock.UtcNow().AddMinutes(1)); + var command = new RequestPeriodicAdscContractCommand("YBBB", "UAL123", TimeSpan.FromMinutes(2)); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + var contracts = await contractRepository.All(CancellationToken.None); + var contract = Assert.Single(contracts); + Assert.Same(existingContract, contract); + Assert.Equal(TimeSpan.FromMinutes(2), contract.ReportingInterval); + Assert.Equal(clock.UtcNow(), contract.Requested); + } + + [Fact] + public async Task Handle_WhenIntervalIsLessThanMinimum_ThrowsAndSendsNothing() + { + // Arrange + var clientManager = new TestClientManager(); + var contractRepository = new TestAdscContractRepository(); + var handler = new RequestPeriodicAdscContractCommandHandler( + clientManager, contractRepository, Substitute.For(), new TestClock(), Logger.None); + + var command = new RequestPeriodicAdscContractCommand("YBBB", "UAL123", TimeSpan.FromSeconds(59)); + + // Act & Assert + await Assert.ThrowsAsync(() => handler.Handle(command, CancellationToken.None)); + + var client = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Empty(client.RequestedAdscContracts); + Assert.Empty(await contractRepository.All(CancellationToken.None)); + } + + [Fact] + public async Task Handle_WhenIntervalIsMinimum_SendsRequest() + { + // Arrange + var clientManager = new TestClientManager(); + var handler = new RequestPeriodicAdscContractCommandHandler( + clientManager, new TestAdscContractRepository(), Substitute.For(), new TestClock(), Logger.None); + + var command = new RequestPeriodicAdscContractCommand( + "YBBB", + "UAL123", + AdscContract.MinimumReportingInterval); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + var client = (TestAcarsClient) await clientManager.GetAcarsClient("hoppies-ybbb", CancellationToken.None); + Assert.Single(client.RequestedAdscContracts); + } +} diff --git a/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs b/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs index 4dbc9bf..de2d30e 100644 --- a/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs +++ b/source/CPDLCServer.Tests/Mocks/TestAcarsClient.cs @@ -7,12 +7,17 @@ namespace CPDLCServer.Tests.Mocks; public class TestAcarsClient(string stationId) : IAcarsClient { private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly Channel _adscChannel = Channel.CreateUnbounded(); public ChannelReader MessageReader => _channel.Reader; + public ChannelReader AdscMessageReader => _adscChannel.Reader; public string StationId => stationId; public List Connections { get; } = []; public List SentMessages { get; } = new(); + public Dictionary RequestedAdscContracts { get; } = new(); + public List RequestedAdscDemandReports { get; } = []; + public List CancelledAdscContracts { get; } = []; public Task Connect(CancellationToken cancellationToken) { @@ -25,6 +30,24 @@ public Task Send(UplinkMessage message, CancellationToken cancellationToken) return Task.CompletedTask; } + public Task RequestPeriodicAdscContract(string callsign, TimeSpan reportingInterval, CancellationToken cancellationToken) + { + RequestedAdscContracts[callsign] = reportingInterval; + return Task.CompletedTask; + } + + public Task RequestAdscDemandReport(string callsign, CancellationToken cancellationToken) + { + RequestedAdscDemandReports.Add(callsign); + return Task.CompletedTask; + } + + public Task CancelAdscContract(string callsign, CancellationToken cancellationToken) + { + CancelledAdscContracts.Add(callsign); + return Task.CompletedTask; + } + public Task ListConnections(CancellationToken cancellationToken) { return Task.FromResult(Connections.ToArray()); diff --git a/source/CPDLCServer.Tests/Mocks/TestAdscContractRepository.cs b/source/CPDLCServer.Tests/Mocks/TestAdscContractRepository.cs new file mode 100644 index 0000000..d7742f5 --- /dev/null +++ b/source/CPDLCServer.Tests/Mocks/TestAdscContractRepository.cs @@ -0,0 +1,29 @@ +using CPDLCServer.Model; +using CPDLCServer.Persistence; + +namespace CPDLCServer.Tests.Mocks; + +public class TestAdscContractRepository : IAdscContractRepository +{ + private readonly InMemoryAdscContractRepository _inner = new(); + + public Task Add(AdscContractKey contractKey, AdscContract contract, CancellationToken cancellationToken) + { + return _inner.Add(contractKey, contract, cancellationToken); + } + + public Task Find(AdscContractKey contractKey, CancellationToken cancellationToken) + { + return _inner.Find(contractKey, cancellationToken); + } + + public Task All(CancellationToken cancellationToken) + { + return _inner.All(cancellationToken); + } + + public Task Remove(AdscContractKey contractKey, CancellationToken cancellationToken) + { + return _inner.Remove(contractKey, cancellationToken); + } +} diff --git a/source/CPDLCServer.Tests/Mocks/TestClientManager.cs b/source/CPDLCServer.Tests/Mocks/TestClientManager.cs index 564a716..06662c4 100644 --- a/source/CPDLCServer.Tests/Mocks/TestClientManager.cs +++ b/source/CPDLCServer.Tests/Mocks/TestClientManager.cs @@ -6,11 +6,14 @@ namespace CPDLCServer.Tests.Mocks; public class TestClientManager : IClientManager { private readonly Dictionary _clients = new(); + private readonly Dictionary _configurations = new(); public TestClientManager() { AddClient("hoppies-ybbb", new TestAcarsClient("YBBB")); AddClient("hoppies-ymmm", new TestAcarsClient("YMMM")); + AddConfiguration(CreateConfiguration("YBBB", 10)); + AddConfiguration(CreateConfiguration("YMMM", 20)); } public void AddClient(string acarsClientId, IAcarsClient client) @@ -18,6 +21,23 @@ public void AddClient(string acarsClientId, IAcarsClient client) _clients[acarsClientId] = client; } + public void AddConfiguration(HoppiesConfiguration configuration) + { + _configurations[configuration.StationIdentifier] = configuration; + } + + public static HoppiesConfiguration CreateConfiguration(string stationId, int defaultAdscReportingInterval) + { + return new HoppiesConfiguration + { + Url = new Uri("http://test.hoppie.nl/acars"), + AuthenticationCode = "TEST123", + FlightSimulationNetwork = "VATSIM", + StationIdentifier = stationId, + DefaultAdscReportingInterval = defaultAdscReportingInterval + }; + } + public Task GetAcarsClient(string acarsClientId, CancellationToken cancellationToken) { if (!_clients.TryGetValue(acarsClientId, out var client)) @@ -27,6 +47,25 @@ public Task GetAcarsClient(string acarsClientId, CancellationToken return Task.FromResult(client); } + public Task GetAcarsClientForStation(string stationId, CancellationToken cancellationToken) + { + var client = _clients.Values.FirstOrDefault(c => c.StationId == stationId); + if (client is null) + { + throw new Exception($"No ACARS client found for station '{stationId}'"); + } + return Task.FromResult(client); + } + + public Task GetConfigurationForStation(string stationId, CancellationToken cancellationToken) + { + if (!_configurations.TryGetValue(stationId, out var configuration)) + { + throw new Exception($"No ACARS configuration found for station '{stationId}'"); + } + return Task.FromResult(configuration); + } + public bool ClientExists(string acarsClientId) { return _clients.ContainsKey(acarsClientId); diff --git a/source/CPDLCServer/Clients/ClientManager.cs b/source/CPDLCServer/Clients/ClientManager.cs index f6175d1..1cad53a 100644 --- a/source/CPDLCServer/Clients/ClientManager.cs +++ b/source/CPDLCServer/Clients/ClientManager.cs @@ -1,6 +1,7 @@ using CPDLCServer.Exceptions; using CPDLCServer.Infrastructure; using CPDLCServer.Messages; +using CPDLCServer.Model; using MediatR; namespace CPDLCServer.Clients; @@ -8,6 +9,8 @@ namespace CPDLCServer.Clients; public interface IClientManager { Task GetAcarsClient(string acarsClientId, CancellationToken cancellationToken); + Task GetAcarsClientForStation(string stationId, CancellationToken cancellationToken); + Task GetConfigurationForStation(string stationId, CancellationToken cancellationToken); } public class ClientManager : BackgroundService, IClientManager @@ -39,6 +42,10 @@ public ClientManager( if (config is not null) { + if (TimeSpan.FromMinutes(config.DefaultAdscReportingInterval) < AdscContract.MinimumReportingInterval) + throw new Exception( + $"DefaultAdscReportingInterval for ACARS client '{config.ClientId}' must be {AdscContract.MinimumReportingInterval.TotalMinutes} minutes or more"); + configurationList.Add(config); } } @@ -122,6 +129,33 @@ public Task GetAcarsClient(string acarsClientId, CancellationToken return Task.FromResult(acarsClientHandle.Client); } + public Task GetAcarsClientForStation(string stationId, CancellationToken cancellationToken) + { + var acarsClientHandle = _clients.Values.FirstOrDefault(h => h.Client.StationId == stationId); + if (acarsClientHandle is null) + { + throw new Exception($"No ACARS client found for station '{stationId}'"); + } + + return Task.FromResult(acarsClientHandle.Client); + } + + public Task GetConfigurationForStation(string stationId, CancellationToken cancellationToken) + { + var configuration = _acarsConfigurations.FirstOrDefault(c => c switch + { + HoppiesConfiguration hoppieConfig => hoppieConfig.StationIdentifier == stationId, + _ => throw new NotSupportedException($"ACARS configuration type {c.GetType().Name} is not supported") + }); + + if (configuration is null) + { + throw new Exception($"No ACARS configuration found for station '{stationId}'"); + } + + return Task.FromResult(configuration); + } + async Task CreateAcarsClient(string acarsClientId, CancellationToken cancellationToken) { var configuration = _acarsConfigurations.FirstOrDefault(c => c.ClientId == acarsClientId); @@ -141,12 +175,19 @@ async Task CreateAcarsClient(string acarsClientId, Cancellati }; var subscribeTaskCancellationSource = new CancellationTokenSource(); - var subscribeTask = Subscribe( - acarsClientId, - stationId, - acarsClient, - _mediator, - subscribeTaskCancellationSource.Token); + var subscribeTask = Task.WhenAll( + Subscribe( + acarsClientId, + stationId, + acarsClient, + _mediator, + subscribeTaskCancellationSource.Token), + SubscribeAdsc( + acarsClientId, + stationId, + acarsClient, + _mediator, + subscribeTaskCancellationSource.Token)); var acarsClientHandle = new AcarsClientHandle(acarsClient, subscribeTask, subscribeTaskCancellationSource); @@ -211,6 +252,48 @@ await mediator.Publish( } } + async Task SubscribeAdsc(string acarsClientId, string stationId, IAcarsClient acarsClient, IMediator mediator, CancellationToken cancellationToken) + { + var subscriptionLogger = _logger.ForContext("AcarsClientId", acarsClientId); + + try + { + await foreach (var adscMessage in acarsClient.AdscMessageReader.ReadAllAsync(cancellationToken)) + { + // TODO: Make this configurable + using var publishTimeoutCancellationTokenSource = new CancellationTokenSource(); + publishTimeoutCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(10)); + + try + { + await mediator.Publish( + new AdscMessageReceivedNotification( + acarsClientId, + stationId, + adscMessage), + publishTimeoutCancellationTokenSource.Token); + } + catch (OperationCanceledException) when (publishTimeoutCancellationTokenSource.Token.IsCancellationRequested) + { + subscriptionLogger.Warning("Timeout handling ADS-C message {AdscMessage}", adscMessage); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + subscriptionLogger.Information("ADS-C subscription canceled"); + return; + } + catch (Exception ex) + { + subscriptionLogger.Error(ex, "Failed to relay {MessageType}", adscMessage.GetType()); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + subscriptionLogger.Information("ADS-C subscription canceled"); + } + } + record AcarsClientHandle( IAcarsClient Client, Task SubscribeTask, diff --git a/source/CPDLCServer/Clients/HoppieAcarsClient.cs b/source/CPDLCServer/Clients/HoppieAcarsClient.cs index d0e3049..7b1430b 100644 --- a/source/CPDLCServer/Clients/HoppieAcarsClient.cs +++ b/source/CPDLCServer/Clients/HoppieAcarsClient.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Threading.Channels; using CPDLCServer.Exceptions; using CPDLCServer.Infrastructure; @@ -5,7 +6,6 @@ namespace CPDLCServer.Clients; -// TODO: ADS-C // TODO: Accept encoded messages via the API (i.e. UMxxx), translate from within the client. public class HoppieAcarsClient : IAcarsClient @@ -21,6 +21,7 @@ public class HoppieAcarsClient : IAcarsClient readonly ILogger _logger; readonly Channel _messageChannel = Channel.CreateUnbounded(); + readonly Channel _adscMessageChannel = Channel.CreateUnbounded(); readonly Random _random = Random.Shared; CancellationTokenSource? _pollCancellationTokenSource; @@ -30,6 +31,7 @@ public class HoppieAcarsClient : IAcarsClient bool _disposed; public ChannelReader MessageReader => _messageChannel.Reader; + public ChannelReader AdscMessageReader => _adscMessageChannel.Reader; public string StationId => _configuration.StationIdentifier; public HoppieAcarsClient(HoppiesConfiguration configuration, HttpClient httpClient, IClock clock, ILogger logger) @@ -59,10 +61,41 @@ public async Task Send(UplinkMessage message, CancellationToken cancellationToke if (_disposed) throw new ObjectDisposedException(nameof(HoppieAcarsClient)); - var messageType = "cpdlc"; - var to = message.Recipient; var packet = SerializeCpdlcMessage(message); + await SendPacket(message.Recipient, "cpdlc", packet, cancellationToken); + } + + public async Task RequestPeriodicAdscContract(string callsign, TimeSpan reportingInterval, CancellationToken cancellationToken) + { + if (_disposed) + throw new ObjectDisposedException(nameof(HoppieAcarsClient)); + + var reportingIntervalSeconds = (int)reportingInterval.TotalSeconds; + if (reportingIntervalSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(reportingInterval), reportingInterval, "Reporting interval must be one second or more"); + await SendPacket(callsign, "ads-c", $"REQUEST PERIODIC {reportingIntervalSeconds}", cancellationToken); + } + + // Hoppie has no separate demand contract. A periodic request with an interval of zero asks for one report. + public async Task RequestAdscDemandReport(string callsign, CancellationToken cancellationToken) + { + if (_disposed) + throw new ObjectDisposedException(nameof(HoppieAcarsClient)); + + await SendPacket(callsign, "ads-c", "REQUEST PERIODIC 0", cancellationToken); + } + + public async Task CancelAdscContract(string callsign, CancellationToken cancellationToken) + { + if (_disposed) + throw new ObjectDisposedException(nameof(HoppieAcarsClient)); + + await SendPacket(callsign, "ads-c", "REQUEST CANCEL", cancellationToken); + } + + async Task SendPacket(string to, string messageType, string packet, CancellationToken cancellationToken) + { var parameters = new Dictionary { ["logon"] = _configuration.AuthenticationCode, @@ -79,7 +112,7 @@ public async Task Send(UplinkMessage message, CancellationToken cancellationToke try { - _logger.Verbose("Sending uplink to {Recipient} with packet {Packet}", message.Recipient, packet); + _logger.Verbose("Sending {MessageType} to {Recipient} with packet {Packet}", messageType, to, packet); var response = await _httpClient.PostAsync(_configuration.Url, content, cancellationToken); response.EnsureSuccessStatusCode(); @@ -267,20 +300,23 @@ void ParseAndPublishDownlinkMessages(string responseText) _logger.Verbose("Extracted: {Message}", messageText); var (from, type, packet) = ParseMessage(messageText); - var message = type.ToLowerInvariant() switch + switch (type.ToLowerInvariant()) { - "cpdlc" => ParseCpdlcDownlink(from, packet), - _ => null - }; - - if (message is not null) - { - _logger.Information("Received {MessageType} from {From}", type, from); - _messageChannel.Writer.TryWrite(message); - } - else - { - _logger.Information("Unsupported message type: {Type}", type); + case "cpdlc": + var downlink = ParseCpdlcDownlink(from, packet); + _logger.Information("Received {MessageType} from {From}", type, from); + _messageChannel.Writer.TryWrite(downlink); + break; + + case "ads-c": + var adscMessage = ParseAdscDownlink(from, packet); + _logger.Information("Received {MessageType} from {From}", type, from); + _adscMessageChannel.Writer.TryWrite(adscMessage); + break; + + default: + _logger.Information("Unsupported message type: {Type}", type); + break; } } } @@ -376,6 +412,57 @@ ReceivedDownlink ParseCpdlcDownlink(string from, string packet) _clock.UtcNow()); } + // Packet formats: + // REPORT [] + // REJECT + ReceivedAdscMessage ParseAdscDownlink(string from, string packet) + { + var parts = packet.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + throw new MessageParseException("Invalid ADS-C packet: Packet is empty"); + + switch (parts[0]) + { + case "REJECT": + return new ReceivedAdscRejection(from, _clock.UtcNow()); + + case "REPORT": + if (parts.Length < 6) + throw new MessageParseException($"Invalid ADS-C report: Expected 6 or more components, got {parts.Length}: \"{packet}\""); + + // Ignore the optional groups (track, ground speed, meteorological data) for now + var report = new AdscReport( + ParseAdscTimestamp(parts[2]), + double.Parse(parts[3], CultureInfo.InvariantCulture), + double.Parse(parts[4], CultureInfo.InvariantCulture), + int.Parse(parts[5], CultureInfo.InvariantCulture)); + + return new ReceivedAdscReport(from, report, _clock.UtcNow()); + + default: + throw new MessageParseException($"Unsupported ADS-C packet: \"{packet}\""); + } + } + + // The report time has the day of the month, the hour, and the minute (ddHHmm) in UTC. + // Use the current month, or the previous month if the day is in the future. + DateTimeOffset ParseAdscTimestamp(string text) + { + if (text.Length != 6) + throw new MessageParseException($"Invalid ADS-C report time: \"{text}\""); + + var day = int.Parse(text[..2], CultureInfo.InvariantCulture); + var hour = int.Parse(text[2..4], CultureInfo.InvariantCulture); + var minute = int.Parse(text[4..], CultureInfo.InvariantCulture); + + var now = _clock.UtcNow(); + var month = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero); + if (day > now.Day) + month = month.AddMonths(-1); + + return month.AddDays(day - 1).AddHours(hour).AddMinutes(minute); + } + TimeSpan GetPollInterval() { if (_lastSendTime.HasValue) diff --git a/source/CPDLCServer/Clients/HoppiesConfiguration.cs b/source/CPDLCServer/Clients/HoppiesConfiguration.cs index e98607c..9d8dc5c 100644 --- a/source/CPDLCServer/Clients/HoppiesConfiguration.cs +++ b/source/CPDLCServer/Clients/HoppiesConfiguration.cs @@ -7,6 +7,9 @@ namespace CPDLCServer.Clients; public abstract class AcarsConfiguration { public abstract string ClientId { get; } + + // The reporting interval in minutes for the ADS-C contract that is requested when an aircraft logs on with CPDLC + public int DefaultAdscReportingInterval { get; init; } = 10; } public class HoppiesConfiguration : AcarsConfiguration diff --git a/source/CPDLCServer/Clients/IAcarsClient.cs b/source/CPDLCServer/Clients/IAcarsClient.cs index 0aceefa..5cff89a 100644 --- a/source/CPDLCServer/Clients/IAcarsClient.cs +++ b/source/CPDLCServer/Clients/IAcarsClient.cs @@ -7,9 +7,13 @@ namespace CPDLCServer.Clients; public interface IAcarsClient : IAsyncDisposable { ChannelReader MessageReader { get; } + ChannelReader AdscMessageReader { get; } string StationId { get; } Task Connect(CancellationToken cancellationToken); Task Send(UplinkMessage message, CancellationToken cancellationToken); + Task RequestPeriodicAdscContract(string callsign, TimeSpan reportingInterval, CancellationToken cancellationToken); + Task RequestAdscDemandReport(string callsign, CancellationToken cancellationToken); + Task CancelAdscContract(string callsign, CancellationToken cancellationToken); Task ListConnections(CancellationToken cancellationToken); Task Disconnect(CancellationToken cancellationToken); } diff --git a/source/CPDLCServer/Handlers/AdscContractChangedNotificationHandler.cs b/source/CPDLCServer/Handlers/AdscContractChangedNotificationHandler.cs new file mode 100644 index 0000000..0691adc --- /dev/null +++ b/source/CPDLCServer/Handlers/AdscContractChangedNotificationHandler.cs @@ -0,0 +1,38 @@ +using CPDLCServer.Hubs; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; +using Microsoft.AspNetCore.SignalR; + +namespace CPDLCServer.Handlers; + +public class AdscContractChangedNotificationHandler( + IControllerRepository controllerRepository, + IHubContext hubContext, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(AdscContractChangedNotification notification, CancellationToken cancellationToken) + { + var contract = notification.Contract; + + var controllers = await controllerRepository.All(cancellationToken); + if (controllers.Length == 0) + { + logger.Verbose( + "No controllers to notify about ADS-C contract change for {Callsign}", + contract.Callsign); + return; + } + + await hubContext.Clients + .Clients(controllers.Select(c => c.ConnectionId)) + .SendAsync("AdscContractUpdated", AdscContractConverter.ToDto(contract), cancellationToken); + + logger.Information( + "Notified {ControllerCount} controller(s) about ADS-C contract change for {Callsign}", + controllers.Length, + contract.Callsign); + } +} diff --git a/source/CPDLCServer/Handlers/AdscContractRemovedNotificationHandler.cs b/source/CPDLCServer/Handlers/AdscContractRemovedNotificationHandler.cs new file mode 100644 index 0000000..e03a5f9 --- /dev/null +++ b/source/CPDLCServer/Handlers/AdscContractRemovedNotificationHandler.cs @@ -0,0 +1,35 @@ +using CPDLCServer.Hubs; +using CPDLCServer.Messages; +using CPDLCServer.Persistence; +using MediatR; +using Microsoft.AspNetCore.SignalR; + +namespace CPDLCServer.Handlers; + +public class AdscContractRemovedNotificationHandler( + IControllerRepository controllerRepository, + IHubContext hubContext, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(AdscContractRemovedNotification notification, CancellationToken cancellationToken) + { + var controllers = await controllerRepository.All(cancellationToken); + if (controllers.Length == 0) + { + logger.Verbose( + "No controllers to notify about removed ADS-C contract for {Callsign}", + notification.Callsign); + return; + } + + await hubContext.Clients + .Clients(controllers.Select(c => c.ConnectionId)) + .SendAsync("AdscContractRemoved", notification.Callsign, notification.StationId, cancellationToken); + + logger.Information( + "Notified {ControllerCount} controller(s) about removed ADS-C contract for {Callsign}", + controllers.Length, + notification.Callsign); + } +} diff --git a/source/CPDLCServer/Handlers/AdscMessageReceivedNotificationHandler.cs b/source/CPDLCServer/Handlers/AdscMessageReceivedNotificationHandler.cs new file mode 100644 index 0000000..5534419 --- /dev/null +++ b/source/CPDLCServer/Handlers/AdscMessageReceivedNotificationHandler.cs @@ -0,0 +1,61 @@ +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class AdscMessageReceivedNotificationHandler( + IAdscContractRepository adscContractRepository, + IMediator mediator, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(AdscMessageReceivedNotification notification, CancellationToken cancellationToken) + { + var message = notification.Message; + var contractKey = new AdscContractKey(message.Sender, notification.StationId); + + var contract = await adscContractRepository.Find(contractKey, cancellationToken); + if (contract is null) + { + logger.Information( + "{Callsign} has no ADS-C contract with {StationId}, ignoring ADS-C message", + message.Sender, + notification.StationId); + return; + } + + switch (message) + { + case ReceivedAdscReport reportMessage: + logger.Information( + "ADS-C report received from {Callsign}", + message.Sender); + + await mediator.Publish( + new AdscReportReceivedNotification( + message.Sender, + notification.StationId, + reportMessage.Report, + reportMessage.Received), + cancellationToken); + break; + + case ReceivedAdscRejection: + await adscContractRepository.Remove(contractKey, cancellationToken); + logger.Information( + "{Callsign} rejected the ADS-C contract with {StationId}", + message.Sender, + notification.StationId); + + await mediator.Publish( + new AdscContractRemovedNotification(message.Sender, notification.StationId), + cancellationToken); + break; + + default: + throw new ArgumentException($"Unknown ADS-C message type: {message.GetType()}"); + } + } +} diff --git a/source/CPDLCServer/Handlers/AdscReportReceivedNotificationHandler.cs b/source/CPDLCServer/Handlers/AdscReportReceivedNotificationHandler.cs new file mode 100644 index 0000000..0371a29 --- /dev/null +++ b/source/CPDLCServer/Handlers/AdscReportReceivedNotificationHandler.cs @@ -0,0 +1,42 @@ +using CPDLCServer.Hubs; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; +using Microsoft.AspNetCore.SignalR; + +namespace CPDLCServer.Handlers; + +public class AdscReportReceivedNotificationHandler( + IControllerRepository controllerRepository, + IHubContext hubContext, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(AdscReportReceivedNotification notification, CancellationToken cancellationToken) + { + var controllers = await controllerRepository.All(cancellationToken); + if (controllers.Length == 0) + { + logger.Verbose( + "No controllers to notify about ADS-C report from {Callsign}", + notification.Callsign); + return; + } + + var reportDto = AdscContractConverter.ToDto( + notification.Callsign, + notification.StationId, + notification.Report, + notification.Received); + + await hubContext.Clients + .Clients(controllers.Select(c => c.ConnectionId)) + .SendAsync("AdscReportReceived", reportDto, cancellationToken); + + logger.Information( + "Notified {ControllerCount} controller(s) about ADS-C report from {Callsign}", + controllers.Length, + notification.Callsign); + } +} diff --git a/source/CPDLCServer/Handlers/CancelAdscContractCommandHandler.cs b/source/CPDLCServer/Handlers/CancelAdscContractCommandHandler.cs new file mode 100644 index 0000000..0d10970 --- /dev/null +++ b/source/CPDLCServer/Handlers/CancelAdscContractCommandHandler.cs @@ -0,0 +1,42 @@ +using CPDLCServer.Clients; +using CPDLCServer.Messages; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class CancelAdscContractCommandHandler( + IClientManager clientManager, + IAdscContractRepository adscContractRepository, + IMediator mediator, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(CancelAdscContractCommand request, CancellationToken cancellationToken) + { + var contractKey = new AdscContractKey(request.Callsign, request.StationId); + var contract = await adscContractRepository.Find(contractKey, cancellationToken); + if (contract is null) + { + logger.Warning( + "Cannot cancel ADS-C contract with {Callsign} from {StationId}: not found", + request.Callsign, + request.StationId); + return; + } + + logger.Information( + "Cancelling ADS-C contract with {Callsign} from {StationId}", + request.Callsign, + request.StationId); + + var client = await clientManager.GetAcarsClientForStation(request.StationId, cancellationToken); + await client.CancelAdscContract(request.Callsign, cancellationToken); + + await adscContractRepository.Remove(contractKey, cancellationToken); + + await mediator.Publish( + new AdscContractRemovedNotification(request.Callsign, request.StationId), + cancellationToken); + } +} diff --git a/source/CPDLCServer/Handlers/CancelAdscContractOnConnectionEndedHandler.cs b/source/CPDLCServer/Handlers/CancelAdscContractOnConnectionEndedHandler.cs new file mode 100644 index 0000000..b05be1c --- /dev/null +++ b/source/CPDLCServer/Handlers/CancelAdscContractOnConnectionEndedHandler.cs @@ -0,0 +1,46 @@ +using CPDLCServer.Clients; +using CPDLCServer.Messages; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class CancelAdscContractOnConnectionEndedHandler( + IClientManager clientManager, + IAdscContractRepository adscContractRepository, + IMediator mediator, + ILogger logger) + : INotificationHandler, INotificationHandler +{ + public async Task Handle(AircraftConnectionTerminated notification, CancellationToken cancellationToken) + { + await CancelContract(notification.Callsign, notification.StationId, cancellationToken); + } + + public async Task Handle(AircraftConnectionLost notification, CancellationToken cancellationToken) + { + var client = await clientManager.GetAcarsClient(notification.AcarsClientId, cancellationToken); + await CancelContract(notification.Callsign, client.StationId, cancellationToken); + } + + async Task CancelContract(string callsign, string stationId, CancellationToken cancellationToken) + { + // A failure here must not stop the other handlers for the ended CPDLC connection + try + { + var contract = await adscContractRepository.Find(new AdscContractKey(callsign, stationId), cancellationToken); + if (contract is null) + return; + + await mediator.Send(new CancelAdscContractCommand(stationId, callsign), cancellationToken); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + logger.Error( + ex, + "Failed to cancel ADS-C contract with {Callsign} for {StationId}", + callsign, + stationId); + } + } +} diff --git a/source/CPDLCServer/Handlers/GetAdscContractsRequestHandler.cs b/source/CPDLCServer/Handlers/GetAdscContractsRequestHandler.cs new file mode 100644 index 0000000..454e9f6 --- /dev/null +++ b/source/CPDLCServer/Handlers/GetAdscContractsRequestHandler.cs @@ -0,0 +1,22 @@ +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class GetAdscContractsRequestHandler(IAdscContractRepository adscContractRepository) + : IRequestHandler +{ + public async Task Handle( + GetAdscContractsRequest request, + CancellationToken cancellationToken) + { + var contracts = await adscContractRepository.All(cancellationToken); + var contractDtos = contracts + .Select(AdscContractConverter.ToDto) + .ToArray(); + + return new GetAdscContractsResult(contractDtos); + } +} diff --git a/source/CPDLCServer/Handlers/GetAtsuSettingsRequestHandler.cs b/source/CPDLCServer/Handlers/GetAtsuSettingsRequestHandler.cs new file mode 100644 index 0000000..12f0f6d --- /dev/null +++ b/source/CPDLCServer/Handlers/GetAtsuSettingsRequestHandler.cs @@ -0,0 +1,19 @@ +using CPDLCServer.Clients; +using CPDLCServer.Contracts; +using CPDLCServer.Messages; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class GetAtsuSettingsRequestHandler(IClientManager clientManager) + : IRequestHandler +{ + public async Task Handle(GetAtsuSettingsRequest request, CancellationToken cancellationToken) + { + var configuration = await clientManager.GetConfigurationForStation(request.StationId, cancellationToken); + + return new AtsuSettingsDto( + request.StationId, + TimeSpan.FromMinutes(configuration.DefaultAdscReportingInterval)); + } +} diff --git a/source/CPDLCServer/Handlers/RequestAdscContractOnConnectionEstablishedHandler.cs b/source/CPDLCServer/Handlers/RequestAdscContractOnConnectionEstablishedHandler.cs new file mode 100644 index 0000000..c4f8ae1 --- /dev/null +++ b/source/CPDLCServer/Handlers/RequestAdscContractOnConnectionEstablishedHandler.cs @@ -0,0 +1,51 @@ +using CPDLCServer.Clients; +using CPDLCServer.Messages; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class RequestAdscContractOnConnectionEstablishedHandler( + IClientManager clientManager, + IAdscContractRepository adscContractRepository, + IMediator mediator, + ILogger logger) + : INotificationHandler +{ + public async Task Handle(AircraftConnectionEstablished notification, CancellationToken cancellationToken) + { + // A failure here must not stop the other handlers for the new CPDLC connection + try + { + // Keep a periodic contract that a controller requested before the logon + var contract = await adscContractRepository.Find( + new AdscContractKey(notification.Callsign, notification.StationId), + cancellationToken); + if (contract?.ReportingInterval is not null) + { + logger.Information( + "{Callsign} already has a periodic ADS-C contract with {StationId}", + notification.Callsign, + notification.StationId); + return; + } + + var configuration = await clientManager.GetConfigurationForStation(notification.StationId, cancellationToken); + + await mediator.Send( + new RequestPeriodicAdscContractCommand( + notification.StationId, + notification.Callsign, + TimeSpan.FromMinutes(configuration.DefaultAdscReportingInterval)), + cancellationToken); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + logger.Error( + ex, + "Failed to request ADS-C contract with {Callsign} for {StationId}", + notification.Callsign, + notification.StationId); + } + } +} diff --git a/source/CPDLCServer/Handlers/RequestAdscDemandReportCommandHandler.cs b/source/CPDLCServer/Handlers/RequestAdscDemandReportCommandHandler.cs new file mode 100644 index 0000000..01d2f77 --- /dev/null +++ b/source/CPDLCServer/Handlers/RequestAdscDemandReportCommandHandler.cs @@ -0,0 +1,45 @@ +using CPDLCServer.Clients; +using CPDLCServer.Infrastructure; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class RequestAdscDemandReportCommandHandler( + IClientManager clientManager, + IAdscContractRepository adscContractRepository, + IMediator mediator, + IClock clock, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(RequestAdscDemandReportCommand request, CancellationToken cancellationToken) + { + logger.Information( + "Requesting ADS-C demand report from {Callsign} for {StationId}", + request.Callsign, + request.StationId); + + var client = await clientManager.GetAcarsClientForStation(request.StationId, cancellationToken); + await client.RequestAdscDemandReport(request.Callsign, cancellationToken); + + // A demand report does not change an existing periodic contract. + // Without a contract, the report would be ignored, so add a contract with no reporting interval. + var contractKey = new AdscContractKey(request.Callsign, request.StationId); + var contract = await adscContractRepository.Find(contractKey, cancellationToken); + if (contract is not null) + return; + + contract = new AdscContract( + request.Callsign, + request.StationId, + null, + clock.UtcNow()); + + await adscContractRepository.Add(contractKey, contract, cancellationToken); + + await mediator.Publish(new AdscContractChangedNotification(contract), cancellationToken); + } +} diff --git a/source/CPDLCServer/Handlers/RequestPeriodicAdscContractCommandHandler.cs b/source/CPDLCServer/Handlers/RequestPeriodicAdscContractCommandHandler.cs new file mode 100644 index 0000000..c424cf8 --- /dev/null +++ b/source/CPDLCServer/Handlers/RequestPeriodicAdscContractCommandHandler.cs @@ -0,0 +1,55 @@ +using CPDLCServer.Clients; +using CPDLCServer.Infrastructure; +using CPDLCServer.Messages; +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using MediatR; + +namespace CPDLCServer.Handlers; + +public class RequestPeriodicAdscContractCommandHandler( + IClientManager clientManager, + IAdscContractRepository adscContractRepository, + IMediator mediator, + IClock clock, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(RequestPeriodicAdscContractCommand request, CancellationToken cancellationToken) + { + if (request.ReportingInterval < AdscContract.MinimumReportingInterval) + throw new ArgumentOutOfRangeException( + nameof(request.ReportingInterval), + request.ReportingInterval, + $"Reporting interval must be {AdscContract.MinimumReportingInterval.TotalSeconds} seconds or more"); + + logger.Information( + "Requesting periodic ADS-C contract with {Callsign} from {StationId} (Interval: {ReportingInterval})", + request.Callsign, + request.StationId, + request.ReportingInterval); + + var client = await clientManager.GetAcarsClientForStation(request.StationId, cancellationToken); + await client.RequestPeriodicAdscContract(request.Callsign, request.ReportingInterval, cancellationToken); + + // A new request replaces the existing contract on the aircraft + var contractKey = new AdscContractKey(request.Callsign, request.StationId); + var contract = await adscContractRepository.Find(contractKey, cancellationToken); + if (contract is null) + { + contract = new AdscContract( + request.Callsign, + request.StationId, + request.ReportingInterval, + clock.UtcNow()); + + await adscContractRepository.Add(contractKey, contract, cancellationToken); + } + else + { + contract.ChangeReportingInterval(request.ReportingInterval, clock.UtcNow()); + } + + await mediator.Publish(new AdscContractChangedNotification(contract), cancellationToken); + } +} diff --git a/source/CPDLCServer/Hubs/ControllerHub.cs b/source/CPDLCServer/Hubs/ControllerHub.cs index 08a1e81..4f1a6b6 100644 --- a/source/CPDLCServer/Hubs/ControllerHub.cs +++ b/source/CPDLCServer/Hubs/ControllerHub.cs @@ -223,6 +223,85 @@ public async Task UpdateNextDataAuthority( nextDataAuthority ?? "(none)"); } + public async Task GetAtsuSettings() + { + var controller = await controllerRepository.FindByConnectionId(Context.ConnectionId, CancellationToken.None); + if (controller is null) + { + _logger.Warning("Controller not found for connection {ConnectionId}", Context.ConnectionId); + throw new InvalidOperationException($"Controller not found for connection {Context.ConnectionId}"); + } + + return await mediator.Send(new GetAtsuSettingsRequest(controller.StationId)); + } + + public async Task RequestPeriodicAdscContract(string callsign, TimeSpan reportingInterval) + { + var controller = await controllerRepository.FindByConnectionId(Context.ConnectionId, CancellationToken.None); + if (controller is null) + { + _logger.Warning("Controller not found for connection {ConnectionId}", Context.ConnectionId); + throw new InvalidOperationException($"Controller not found for connection {Context.ConnectionId}"); + } + + await mediator.Send(new RequestPeriodicAdscContractCommand( + controller.StationId, + callsign, + reportingInterval)); + + _logger.Information( + "Controller {Callsign} requested periodic ADS-C contract with {AircraftCallsign}", + controller.Callsign, + callsign); + } + + public async Task RequestAdscDemandReport(string callsign) + { + var controller = await controllerRepository.FindByConnectionId(Context.ConnectionId, CancellationToken.None); + if (controller is null) + { + _logger.Warning("Controller not found for connection {ConnectionId}", Context.ConnectionId); + throw new InvalidOperationException($"Controller not found for connection {Context.ConnectionId}"); + } + + await mediator.Send(new RequestAdscDemandReportCommand(controller.StationId, callsign)); + + _logger.Information( + "Controller {Callsign} requested ADS-C demand report from {AircraftCallsign}", + controller.Callsign, + callsign); + } + + public async Task CancelAdscContract(string callsign) + { + var controller = await controllerRepository.FindByConnectionId(Context.ConnectionId, CancellationToken.None); + if (controller is null) + { + _logger.Warning("Controller not found for connection {ConnectionId}", Context.ConnectionId); + throw new InvalidOperationException($"Controller not found for connection {Context.ConnectionId}"); + } + + await mediator.Send(new CancelAdscContractCommand(controller.StationId, callsign)); + + _logger.Information( + "Controller {Callsign} cancelled ADS-C contract with {AircraftCallsign}", + controller.Callsign, + callsign); + } + + public async Task GetAdscContracts() + { + var controller = await controllerRepository.FindByConnectionId(Context.ConnectionId, CancellationToken.None); + if (controller is null) + { + _logger.Warning("Controller not found for connection {ConnectionId}", Context.ConnectionId); + throw new InvalidOperationException($"Controller not found for connection {Context.ConnectionId}"); + } + + var result = await mediator.Send(new GetAdscContractsRequest()); + return result.Contracts; + } + public async Task GetAllDialogues() { var controller = await controllerRepository.FindByConnectionId(Context.ConnectionId, CancellationToken.None); diff --git a/source/CPDLCServer/Messages/AdscContractChangedNotification.cs b/source/CPDLCServer/Messages/AdscContractChangedNotification.cs new file mode 100644 index 0000000..bd3abdf --- /dev/null +++ b/source/CPDLCServer/Messages/AdscContractChangedNotification.cs @@ -0,0 +1,6 @@ +using CPDLCServer.Model; +using MediatR; + +namespace CPDLCServer.Messages; + +public record AdscContractChangedNotification(AdscContract Contract) : INotification; diff --git a/source/CPDLCServer/Messages/AdscContractRemovedNotification.cs b/source/CPDLCServer/Messages/AdscContractRemovedNotification.cs new file mode 100644 index 0000000..5332347 --- /dev/null +++ b/source/CPDLCServer/Messages/AdscContractRemovedNotification.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace CPDLCServer.Messages; + +public record AdscContractRemovedNotification(string Callsign, string StationId) : INotification; diff --git a/source/CPDLCServer/Messages/AdscMessageReceivedNotification.cs b/source/CPDLCServer/Messages/AdscMessageReceivedNotification.cs new file mode 100644 index 0000000..42243cd --- /dev/null +++ b/source/CPDLCServer/Messages/AdscMessageReceivedNotification.cs @@ -0,0 +1,10 @@ +using CPDLCServer.Model; +using MediatR; + +namespace CPDLCServer.Messages; + +public record AdscMessageReceivedNotification( + string AcarsClientId, + string StationId, + ReceivedAdscMessage Message) + : INotification; diff --git a/source/CPDLCServer/Messages/AdscReportReceivedNotification.cs b/source/CPDLCServer/Messages/AdscReportReceivedNotification.cs new file mode 100644 index 0000000..6770cdc --- /dev/null +++ b/source/CPDLCServer/Messages/AdscReportReceivedNotification.cs @@ -0,0 +1,11 @@ +using CPDLCServer.Model; +using MediatR; + +namespace CPDLCServer.Messages; + +public record AdscReportReceivedNotification( + string Callsign, + string StationId, + AdscReport Report, + DateTimeOffset Received) + : INotification; diff --git a/source/CPDLCServer/Messages/CancelAdscContractCommand.cs b/source/CPDLCServer/Messages/CancelAdscContractCommand.cs new file mode 100644 index 0000000..c066148 --- /dev/null +++ b/source/CPDLCServer/Messages/CancelAdscContractCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace CPDLCServer.Messages; + +public record CancelAdscContractCommand( + string StationId, + string Callsign) + : IRequest; diff --git a/source/CPDLCServer/Messages/GetAdscContractsRequest.cs b/source/CPDLCServer/Messages/GetAdscContractsRequest.cs new file mode 100644 index 0000000..f8d90f7 --- /dev/null +++ b/source/CPDLCServer/Messages/GetAdscContractsRequest.cs @@ -0,0 +1,8 @@ +using CPDLCServer.Contracts; +using MediatR; + +namespace CPDLCServer.Messages; + +public record GetAdscContractsRequest : IRequest; + +public record GetAdscContractsResult(AdscContractDto[] Contracts); diff --git a/source/CPDLCServer/Messages/GetAtsuSettingsRequest.cs b/source/CPDLCServer/Messages/GetAtsuSettingsRequest.cs new file mode 100644 index 0000000..2165019 --- /dev/null +++ b/source/CPDLCServer/Messages/GetAtsuSettingsRequest.cs @@ -0,0 +1,6 @@ +using CPDLCServer.Contracts; +using MediatR; + +namespace CPDLCServer.Messages; + +public record GetAtsuSettingsRequest(string StationId) : IRequest; diff --git a/source/CPDLCServer/Messages/RequestAdscDemandReportCommand.cs b/source/CPDLCServer/Messages/RequestAdscDemandReportCommand.cs new file mode 100644 index 0000000..47f0f4e --- /dev/null +++ b/source/CPDLCServer/Messages/RequestAdscDemandReportCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace CPDLCServer.Messages; + +public record RequestAdscDemandReportCommand( + string StationId, + string Callsign) + : IRequest; diff --git a/source/CPDLCServer/Messages/RequestPeriodicAdscContractCommand.cs b/source/CPDLCServer/Messages/RequestPeriodicAdscContractCommand.cs new file mode 100644 index 0000000..e79dc52 --- /dev/null +++ b/source/CPDLCServer/Messages/RequestPeriodicAdscContractCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace CPDLCServer.Messages; + +public record RequestPeriodicAdscContractCommand( + string StationId, + string Callsign, + TimeSpan ReportingInterval) + : IRequest; diff --git a/source/CPDLCServer/Model/AdscContract.cs b/source/CPDLCServer/Model/AdscContract.cs new file mode 100644 index 0000000..009acb9 --- /dev/null +++ b/source/CPDLCServer/Model/AdscContract.cs @@ -0,0 +1,24 @@ +namespace CPDLCServer.Model; + +public class AdscContract( + string callsign, + string stationId, + TimeSpan? reportingInterval, + DateTimeOffset requested) +{ + // The ACARS clients poll for messages every 20 to 75 seconds. A shorter interval gives no more data. + public static readonly TimeSpan MinimumReportingInterval = TimeSpan.FromSeconds(60); + + public string Callsign { get; } = callsign; + public string StationId { get; } = stationId; + + // Null when only demand reports were requested + public TimeSpan? ReportingInterval { get; private set; } = reportingInterval; + public DateTimeOffset Requested { get; private set; } = requested; + + public void ChangeReportingInterval(TimeSpan reportingInterval, DateTimeOffset now) + { + ReportingInterval = reportingInterval; + Requested = now; + } +} diff --git a/source/CPDLCServer/Model/AdscContractConverter.cs b/source/CPDLCServer/Model/AdscContractConverter.cs new file mode 100644 index 0000000..2d27e27 --- /dev/null +++ b/source/CPDLCServer/Model/AdscContractConverter.cs @@ -0,0 +1,27 @@ +using CPDLCServer.Contracts; + +namespace CPDLCServer.Model; + +public static class AdscContractConverter +{ + public static AdscContractDto ToDto(AdscContract contract) + { + return new AdscContractDto( + contract.Callsign, + contract.StationId, + contract.ReportingInterval, + contract.Requested); + } + + public static AdscReportDto ToDto(string callsign, string stationId, AdscReport report, DateTimeOffset received) + { + return new AdscReportDto( + callsign, + stationId, + report.Timestamp, + report.Latitude, + report.Longitude, + report.Altitude, + received); + } +} diff --git a/source/CPDLCServer/Model/AdscReport.cs b/source/CPDLCServer/Model/AdscReport.cs new file mode 100644 index 0000000..d123584 --- /dev/null +++ b/source/CPDLCServer/Model/AdscReport.cs @@ -0,0 +1,8 @@ +namespace CPDLCServer.Model; + +// ADS-C basic group. Altitude is in feet. +public record AdscReport( + DateTimeOffset Timestamp, + double Latitude, + double Longitude, + int Altitude); diff --git a/source/CPDLCServer/Model/ReceivedAdscMessage.cs b/source/CPDLCServer/Model/ReceivedAdscMessage.cs new file mode 100644 index 0000000..fb90cc5 --- /dev/null +++ b/source/CPDLCServer/Model/ReceivedAdscMessage.cs @@ -0,0 +1,16 @@ +namespace CPDLCServer.Model; + +// Raw ADS-C data as parsed from an ACARS packet, before being assigned to a contract. +public abstract record ReceivedAdscMessage(string Sender, DateTimeOffset Received); + +public record ReceivedAdscReport( + string Sender, + AdscReport Report, + DateTimeOffset Received) + : ReceivedAdscMessage(Sender, Received); + +// The aircraft cannot accept the contract, or the pilot has switched ADS-C off. +public record ReceivedAdscRejection( + string Sender, + DateTimeOffset Received) + : ReceivedAdscMessage(Sender, Received); diff --git a/source/CPDLCServer/Persistence/IAdscContractRepository.cs b/source/CPDLCServer/Persistence/IAdscContractRepository.cs new file mode 100644 index 0000000..fddd90c --- /dev/null +++ b/source/CPDLCServer/Persistence/IAdscContractRepository.cs @@ -0,0 +1,13 @@ +using CPDLCServer.Model; + +namespace CPDLCServer.Persistence; + +public interface IAdscContractRepository +{ + Task Add(AdscContractKey contractKey, AdscContract contract, CancellationToken cancellationToken); + Task Find(AdscContractKey contractKey, CancellationToken cancellationToken); + Task All(CancellationToken cancellationToken); + Task Remove(AdscContractKey contractKey, CancellationToken cancellationToken); +} + +public record AdscContractKey(string Callsign, string StationId); diff --git a/source/CPDLCServer/Persistence/InMemoryAdscContractRepository.cs b/source/CPDLCServer/Persistence/InMemoryAdscContractRepository.cs new file mode 100644 index 0000000..dbad12e --- /dev/null +++ b/source/CPDLCServer/Persistence/InMemoryAdscContractRepository.cs @@ -0,0 +1,46 @@ +using CPDLCServer.Extensions; +using CPDLCServer.Model; + +namespace CPDLCServer.Persistence; + +public class InMemoryAdscContractRepository : IAdscContractRepository +{ + readonly SemaphoreSlim _semaphore = new(1, 1); + + private readonly Dictionary _contracts = new(); + + public async Task Add(AdscContractKey contractKey, AdscContract contract, CancellationToken cancellationToken) + { + using (await _semaphore.LockAsync(cancellationToken)) + { + _contracts[contractKey] = contract; + } + } + + public async Task Find(AdscContractKey contractKey, CancellationToken cancellationToken) + { + using (await _semaphore.LockAsync(cancellationToken)) + { + _contracts.TryGetValue(contractKey, out var contract); + return contract; + } + } + + public async Task All(CancellationToken cancellationToken) + { + using (await _semaphore.LockAsync(cancellationToken)) + { + return _contracts + .Select(kvp => kvp.Value) + .ToArray(); + } + } + + public async Task Remove(AdscContractKey contractKey, CancellationToken cancellationToken) + { + using (await _semaphore.LockAsync(cancellationToken)) + { + return _contracts.Remove(contractKey); + } + } +} diff --git a/source/CPDLCServer/Program.cs b/source/CPDLCServer/Program.cs index 68e3837..94a411e 100644 --- a/source/CPDLCServer/Program.cs +++ b/source/CPDLCServer/Program.cs @@ -33,6 +33,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); builder.Services.AddSignalR() .AddJsonProtocol(); From a598b4c4de2d43d90744ce4a504589d7200485b2 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:15:27 +1000 Subject: [PATCH 02/33] wip: Show ADS-C track --- docs/docs/vatsys-plugin/configuration.md | 12 ++ source/CPDLCPlugin/AdscTrackDisplay.cs | 56 +++++++++ source/CPDLCPlugin/AdscTrackPatches.cs | 107 ++++++++++++++++++ source/CPDLCPlugin/CPDLCPlugin.csproj | 1 + .../Configuration/PluginConfiguration.cs | 3 + .../AdscContractRemovedNotification.cs | 10 +- .../AdscReportReceivedNotification.cs | 8 +- .../Messages/DisconnectedNotification.cs | 4 + source/CPDLCPlugin/Plugin.cs | 18 +++ 9 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 source/CPDLCPlugin/AdscTrackDisplay.cs create mode 100644 source/CPDLCPlugin/AdscTrackPatches.cs diff --git a/docs/docs/vatsys-plugin/configuration.md b/docs/docs/vatsys-plugin/configuration.md index 75947b3..9c1efc6 100644 --- a/docs/docs/vatsys-plugin/configuration.md +++ b/docs/docs/vatsys-plugin/configuration.md @@ -128,6 +128,17 @@ Default: `true` "ShowInstallationMenuItems": false ``` +### AdscExtrapolatedAfterMinutes + +An ADS-C track uses the `ADSC` symbol. When the latest ADS-C report is older than this number of minutes, the track uses the `ADSCExtrapolated` symbol. + +Default: `10` + +**Example:** +```json +"AdscExtrapolatedAfterMinutes": 10 +``` + ## Uplink Messages The `UplinkMessages` object defines the available CPDLC messages for the controller to send. @@ -224,6 +235,7 @@ Array of message groups for organizing messages in the editor. Each group has: { "AtsuCode": "YMMM", "Sectors": ["GUN", "BLA", "IND"] } ], "NdaRecalculationIntervalMinutes": 1, + "AdscExtrapolatedAfterMinutes": 10, "UplinkMessages": { "MasterMessages": [ { diff --git a/source/CPDLCPlugin/AdscTrackDisplay.cs b/source/CPDLCPlugin/AdscTrackDisplay.cs new file mode 100644 index 0000000..82f5385 --- /dev/null +++ b/source/CPDLCPlugin/AdscTrackDisplay.cs @@ -0,0 +1,56 @@ +using System.Collections.Concurrent; +using CPDLCServer.Contracts; +using vatsys; + +namespace CPDLCPlugin; + +/// +/// Holds the ADS-C reports that move flight plan tracks on the ASD. +/// The patches in read this class on the vatSys threads, so all members are synchronous. +/// +public class AdscTrackDisplay +{ + readonly ConcurrentDictionary _reports = new(); + + /// + /// Shows the report on the ASD, unless a newer report for the same aircraft is shown. + /// + public void Show(AdscReportDto report) + { + _reports.AddOrUpdate( + report.Callsign, + report, + (_, existing) => existing.Timestamp > report.Timestamp ? existing : report); + + UpdateTrackPosition(report.Callsign); + } + + public void Hide(string callsign) + { + if (_reports.TryRemove(callsign, out _)) + UpdateTrackPosition(callsign); + } + + public void Clear() + { + var callsigns = _reports.Keys.ToArray(); + _reports.Clear(); + + foreach (var callsign in callsigns) + UpdateTrackPosition(callsign); + } + + public bool TryGetReport(string callsign, out AdscReportDto report) + { + return _reports.TryGetValue(callsign, out report); + } + + // vatSys calculates the track positions every 10 seconds. + // Calculate the position now, so that the change shows immediately. + static void UpdateTrackPosition(string callsign) + { + var fdr = FDP2.GetFDRs.FirstOrDefault(f => f.Callsign == callsign); + if (fdr is not null) + FDP2.GenerateTrackPosition(fdr); + } +} diff --git a/source/CPDLCPlugin/AdscTrackPatches.cs b/source/CPDLCPlugin/AdscTrackPatches.cs new file mode 100644 index 0000000..14e64fc --- /dev/null +++ b/source/CPDLCPlugin/AdscTrackPatches.cs @@ -0,0 +1,107 @@ +using System.Reflection; +using HarmonyLib; +using vatsys; + +namespace CPDLCPlugin; + +/// +/// Shows ADS-C tracks on the ASD. +/// vatSys does not draw ADS-C tracks, so these Harmony patches change the flight plan tracks that have an ADS-C report: +/// +/// The track moves to the position in the latest ADS-C report. +/// The track uses the ADSC symbol, or the ADSCExtrapolated symbol when the report is old. The symbol does not rotate. +/// +/// A coupled radar track always has priority over an ADS-C report. +/// +public static class AdscTrackPatches +{ + const string HarmonyId = "CPDLCPlugin.AdscTracks"; + + static AdscTrackDisplay? _display; + static IClock? _clock; + static TimeSpan _extrapolatedAfter; + + // The track that ASDControlDX.PaintASDTrack paints at this time. + // Each ASD window paints on its own thread. + [ThreadStatic] + static Track? _trackBeingPainted; + + public static void Apply(AdscTrackDisplay display, IClock clock, TimeSpan extrapolatedAfter) + { + _display = display; + _clock = clock; + _extrapolatedAfter = extrapolatedAfter; + + var harmony = new Harmony(HarmonyId); + try + { + // Patch the symbol before the position, so that an ADS-C position never shows with the flight plan symbol + harmony.Patch( + RequireMethod("vatsys.ASDControlDX:PaintASDTrack"), + prefix: new HarmonyMethod(typeof(AdscTrackPatches), nameof(PaintASDTrackPrefix)), + finalizer: new HarmonyMethod(typeof(AdscTrackPatches), nameof(PaintASDTrackFinalizer))); + + harmony.Patch( + RequireMethod("vatsys.ASDControlDX:PaintTrackGeometryRealisation"), + prefix: new HarmonyMethod(typeof(AdscTrackPatches), nameof(PaintTrackGeometryRealisationPrefix))); + + harmony.Patch( + RequireMethod("vatsys.FDP2:GenerateTrackPosition"), + postfix: new HarmonyMethod(typeof(AdscTrackPatches), nameof(GenerateTrackPositionPostfix))); + } + catch + { + harmony.UnpatchAll(HarmonyId); + throw; + } + } + + static MethodInfo RequireMethod(string typeColonMethodName) + { + return AccessTools.Method(typeColonMethodName) + ?? throw new MissingMethodException($"Cannot find the vatSys method {typeColonMethodName}"); + } + + static void PaintASDTrackPrefix(Track tl) + { + _trackBeingPainted = tl; + } + + static void PaintASDTrackFinalizer() + { + _trackBeingPainted = null; + } + + static void PaintTrackGeometryRealisationPrefix(ref TrackSymbols.Types type, ref double aircraftHeading) + { + if (_display is null || _clock is null || type != TrackSymbols.Types.FlightPlan) + return; + + var track = _trackBeingPainted; + if (track is null || track.Type != Track.TrackTypes.TRACK_TYPE_FP) + return; + + var fdr = track.GetFDR(); + if (fdr is null || !_display.TryGetReport(fdr.Callsign, out var report)) + return; + + var reportAge = _clock.UtcNow() - report.Timestamp; + type = reportAge > _extrapolatedAfter + ? TrackSymbols.Types.ADSCExtrapolated + : TrackSymbols.Types.ADSC; + + // vatSys does not rotate a symbol when the heading is -1 + aircraftHeading = -1; + } + + static void GenerateTrackPositionPostfix(FDP2.FDR fdr) + { + if (_display is null || fdr.CoupledTrack is not null) + return; + + if (!_display.TryGetReport(fdr.Callsign, out var report)) + return; + + fdr.PredictedPosition.Location = new Coordinate(report.Latitude, report.Longitude); + } +} diff --git a/source/CPDLCPlugin/CPDLCPlugin.csproj b/source/CPDLCPlugin/CPDLCPlugin.csproj index ff8f39d..6290c1b 100644 --- a/source/CPDLCPlugin/CPDLCPlugin.csproj +++ b/source/CPDLCPlugin/CPDLCPlugin.csproj @@ -20,6 +20,7 @@ + diff --git a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs index 878d695..d750a1a 100644 --- a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs +++ b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs @@ -26,4 +26,7 @@ public class PluginConfiguration // ACCs shipping the plugin with pre-modified profiles can disable these to prevent // controllers from accidentally reverting the changes. public bool ShowInstallationMenuItems { get; init; } = true; + + // ADS-C tracks use the ADSCExtrapolated symbol when the latest report is older than this number of minutes. + public int AdscExtrapolatedAfterMinutes { get; init; } = 10; } diff --git a/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs b/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs index 0dcc87d..6220b5c 100644 --- a/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs +++ b/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs @@ -5,7 +5,10 @@ namespace CPDLCPlugin.Messages; public record AdscContractRemovedNotification(string Callsign, string StationId) : INotification; -public class AdscContractRemovedNotificationHandler(AdscContractStore adscContractStore, ILogger logger) +public class AdscContractRemovedNotificationHandler( + AdscContractStore adscContractStore, + AdscTrackDisplay adscTrackDisplay, + ILogger logger) : INotificationHandler { public async Task Handle(AdscContractRemovedNotification notification, CancellationToken cancellationToken) @@ -16,5 +19,10 @@ public async Task Handle(AdscContractRemovedNotification notification, Cancellat { logger.Warning("ADS-C contract for {Callsign} was not tracked on {StationId}", notification.Callsign, notification.StationId); } + + // Show the ADS-C track only while the aircraft has a contract with an ATSU + var contracts = await adscContractStore.All(cancellationToken); + if (contracts.All(c => c.Callsign != notification.Callsign)) + adscTrackDisplay.Hide(notification.Callsign); } } diff --git a/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs b/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs index be47014..bb01f15 100644 --- a/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs +++ b/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs @@ -6,7 +6,10 @@ namespace CPDLCPlugin.Messages; public record AdscReportReceivedNotification(AdscReportDto AdscReportDto) : INotification; -public class AdscReportReceivedNotificationHandler(AdscReportStore adscReportStore, ILogger logger) +public class AdscReportReceivedNotificationHandler( + AdscReportStore adscReportStore, + AdscTrackDisplay adscTrackDisplay, + ILogger logger) : INotificationHandler { public async Task Handle(AdscReportReceivedNotification notification, CancellationToken cancellationToken) @@ -22,5 +25,8 @@ public async Task Handle(AdscReportReceivedNotification notification, Cancellati report.Timestamp); await adscReportStore.Add(report, cancellationToken); + + // The server sends reports only for active contracts + adscTrackDisplay.Show(report); } } diff --git a/source/CPDLCPlugin/Messages/DisconnectedNotification.cs b/source/CPDLCPlugin/Messages/DisconnectedNotification.cs index 96b586c..9afc7e2 100644 --- a/source/CPDLCPlugin/Messages/DisconnectedNotification.cs +++ b/source/CPDLCPlugin/Messages/DisconnectedNotification.cs @@ -12,6 +12,7 @@ public class DisconnectedNotificationBridge( AcarsStationStore acarsStationStore, AdscContractStore adscContractStore, AdscReportStore adscReportStore, + AdscTrackDisplay adscTrackDisplay, AtsuSettingsStore atsuSettingsStore, ILogger logger) : INotificationHandler { @@ -32,6 +33,9 @@ public async Task Handle(DisconnectedNotification notification, CancellationToke logger.Information("Clearing ADS-C report store"); await adscReportStore.Clear(cancellationToken); + logger.Information("Clearing ADS-C tracks"); + adscTrackDisplay.Clear(); + logger.Information("Clearing ATSU settings"); atsuSettingsStore.Clear(); diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs index 633e7c0..719e833 100644 --- a/source/CPDLCPlugin/Plugin.cs +++ b/source/CPDLCPlugin/Plugin.cs @@ -69,6 +69,8 @@ public Plugin() _configuration = configuration; ConfigureServices(configuration); + ApplyAdscTrackPatches(configuration); + AddToolbarItems(); CheckXmlInstallation(); @@ -98,6 +100,7 @@ void ConfigureServices(PluginConfiguration pluginConfiguration) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -126,6 +129,21 @@ ILogger ConfigureLogger(PluginConfiguration configuration) return logger; } + void ApplyAdscTrackPatches(PluginConfiguration pluginConfiguration) + { + try + { + AdscTrackPatches.Apply( + ServiceProvider.GetRequiredService(), + ServiceProvider.GetRequiredService(), + TimeSpan.FromMinutes(pluginConfiguration.AdscExtrapolatedAfterMinutes)); + } + catch (Exception ex) + { + AddError(ex, "Cannot show ADS-C tracks on the ASD"); + } + } + void NetworkConnected(object sender, EventArgs e) { Log.Information("Connected to VATSIM as {Callsign}", Network.Callsign); From 9e0b149471f07f12e9f8041c104e35cb6b99d254 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:18:51 +1000 Subject: [PATCH 03/33] add test button --- source/CPDLCPlugin/FakeAdscReportPatch.cs | 56 +++++++++++++++++++++++ source/CPDLCPlugin/Plugin.cs | 54 ++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 source/CPDLCPlugin/FakeAdscReportPatch.cs diff --git a/source/CPDLCPlugin/FakeAdscReportPatch.cs b/source/CPDLCPlugin/FakeAdscReportPatch.cs new file mode 100644 index 0000000..b9af925 --- /dev/null +++ b/source/CPDLCPlugin/FakeAdscReportPatch.cs @@ -0,0 +1,56 @@ +using System.Windows.Forms; +using HarmonyLib; +using vatsys; + +namespace CPDLCPlugin; + +/// +/// A test tool that sends fake ADS-C reports. +/// After , the next left click on an ASD goes to the callback with the clicked position. +/// vatSys does not get that click. +/// +public static class FakeAdscReportPatch +{ + const string HarmonyId = "CPDLCPlugin.FakeAdscReport"; + + static Action? _onClick; + static volatile bool _armed; + + public static void Apply(Action onClick) + { + _onClick = onClick; + + var method = AccessTools.Method("vatsys.ASDControlDX:ASD_MouseDown") + ?? throw new MissingMethodException("Cannot find the vatSys method vatsys.ASDControlDX:ASD_MouseDown"); + + new Harmony(HarmonyId).Patch( + method, + prefix: new HarmonyMethod(typeof(FakeAdscReportPatch), nameof(AsdMouseDownPrefix))); + } + + public static void Arm() + { + _armed = true; + } + + static bool AsdMouseDownPrefix(object __instance, MouseEventArgs e) + { + if (!_armed || e.Button != MouseButtons.Left) + return true; + + _armed = false; + + try + { + // ASDControlDX sets this field to the cursor position when the mouse moves + var position = Traverse.Create(__instance).Field("CursorLatLong").GetValue(); + _onClick?.Invoke(position); + } + catch (Exception ex) + { + Plugin.AddError(ex, "Cannot send a fake ADS-C report"); + } + + return false; + } +} diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs index 719e833..8caa374 100644 --- a/source/CPDLCPlugin/Plugin.cs +++ b/source/CPDLCPlugin/Plugin.cs @@ -70,6 +70,7 @@ public Plugin() ConfigureServices(configuration); ApplyAdscTrackPatches(configuration); + ApplyFakeAdscReportPatch(); AddToolbarItems(); @@ -144,6 +145,51 @@ void ApplyAdscTrackPatches(PluginConfiguration pluginConfiguration) } } + void ApplyFakeAdscReportPatch() + { + try + { + FakeAdscReportPatch.Apply(SendFakeAdscReport); + } + catch (Exception ex) + { + AddError(ex, "Cannot use fake ADS-C reports"); + } + } + + // Runs on the UI thread when the ASD is clicked + void SendFakeAdscReport(Coordinate position) + { + var fdr = MMI.SelectedTrack?.GetFDR(); + if (fdr is null) + throw new InvalidOperationException("Select a track with a flight plan before you send a fake ADS-C report"); + + if (fdr.PRL == -1) + throw new InvalidOperationException($"{fdr.Callsign} has no reported level"); + + var now = DateTimeOffset.UtcNow; + var report = new CPDLCServer.Contracts.AdscReportDto( + fdr.Callsign, + ConnectionManager?.StationIdentifier ?? string.Empty, + now, + position.Latitude, + position.Longitude, + fdr.PRL, + now); + + Log.Information("Sending fake ADS-C report for {Callsign} at {Latitude} {Longitude} {Altitude}", + report.Callsign, + report.Latitude, + report.Longitude, + report.Altitude); + + _workQueue.Enqueue(async () => + { + var mediator = ServiceProvider.GetRequiredService(); + await mediator.Publish(new AdscReportReceivedNotification(report)); + }); + } + void NetworkConnected(object sender, EventArgs e) { Log.Information("Connected to VATSIM as {Callsign}", Network.Callsign); @@ -356,6 +402,14 @@ void AddToolbarItems() MMI.AddCustomMenuItem(historyMenuItem); + var fakeAdscReportMenuItem = new CustomToolStripMenuItem( + CustomToolStripMenuItemWindowType.Main, + menuItemCategory, + new ToolStripMenuItem("Fake ADS-C Report")); + fakeAdscReportMenuItem.Item.Click += (_, _) => FakeAdscReportPatch.Arm(); + + MMI.AddCustomMenuItem(fakeAdscReportMenuItem); + if (_configuration?.ShowInstallationMenuItems ?? true) { var status = XmlInstaller.GetStatus(); From 1e0c2b9f46df4f259df1e931ead8ef6bd70dc352 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:42:11 +1000 Subject: [PATCH 04/33] Fix build --- build/Build.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build/Build.cs b/build/Build.cs index d914a8a..ad13d77 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -67,6 +67,9 @@ class Build : FalloutBuild const string DebugPluginName = "CPDLCPlugin - Debug"; const string PluginAssemblyFileName = "CPDLCPlugin.dll"; + // ILRepack cannot merge Harmony, so it stays as a separate file next to the plugin + const string HarmonyAssemblyFileName = "0Harmony.dll"; + string PluginName => Configuration == Configuration.Debug ? DebugPluginName : ReleasePluginName; // Test projects @@ -178,7 +181,7 @@ class Build : FalloutBuild var mainAssembly = PluginBuildOutputDirectory / PluginAssemblyFileName; var assembliesToMerge = PluginBuildOutputDirectory .GlobFiles("*.dll") - .Except([mainAssembly]) + .Except([mainAssembly, PluginBuildOutputDirectory / HarmonyAssemblyFileName]) .ToArray(); if (!mainAssembly.FileExists()) From 79d43092a728d5e123c4afb99e0f9474b4b16c51 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:37:42 +1000 Subject: [PATCH 05/33] Fixing bits up --- docs/docs/vatsys-plugin/configuration.md | 12 --- source/CPDLCPlugin/AdscExtrapolation.cs | 79 ++++++++++++++++++ source/CPDLCPlugin/AdscTrackDisplay.cs | 76 ++++++++++++----- source/CPDLCPlugin/AdscTrackPatches.cs | 31 +++---- .../Configuration/PluginConfiguration.cs | 3 - source/CPDLCPlugin/Plugin.cs | 9 +- .../Clients/HoppieAcarsClientTests.cs | 82 +++++++++++++++++++ .../CPDLCServer/Clients/HoppieAcarsClient.cs | 54 ++++++++++-- 8 files changed, 283 insertions(+), 63 deletions(-) create mode 100644 source/CPDLCPlugin/AdscExtrapolation.cs diff --git a/docs/docs/vatsys-plugin/configuration.md b/docs/docs/vatsys-plugin/configuration.md index 9c1efc6..75947b3 100644 --- a/docs/docs/vatsys-plugin/configuration.md +++ b/docs/docs/vatsys-plugin/configuration.md @@ -128,17 +128,6 @@ Default: `true` "ShowInstallationMenuItems": false ``` -### AdscExtrapolatedAfterMinutes - -An ADS-C track uses the `ADSC` symbol. When the latest ADS-C report is older than this number of minutes, the track uses the `ADSCExtrapolated` symbol. - -Default: `10` - -**Example:** -```json -"AdscExtrapolatedAfterMinutes": 10 -``` - ## Uplink Messages The `UplinkMessages` object defines the available CPDLC messages for the controller to send. @@ -235,7 +224,6 @@ Array of message groups for organizing messages in the editor. Each group has: { "AtsuCode": "YMMM", "Sectors": ["GUN", "BLA", "IND"] } ], "NdaRecalculationIntervalMinutes": 1, - "AdscExtrapolatedAfterMinutes": 10, "UplinkMessages": { "MasterMessages": [ { diff --git a/source/CPDLCPlugin/AdscExtrapolation.cs b/source/CPDLCPlugin/AdscExtrapolation.cs new file mode 100644 index 0000000..a1654fb --- /dev/null +++ b/source/CPDLCPlugin/AdscExtrapolation.cs @@ -0,0 +1,79 @@ +using CPDLCServer.Contracts; +using vatsys; + +namespace CPDLCPlugin; + +/// +/// Normal position extrapolation for ADS-C tracks. +/// Moves the reported position along the flight plan route, from the report time to now. +/// The speed on each route leg comes from the route estimates, as for flight plan tracks. +/// +public static class AdscExtrapolation +{ + public static Coordinate Extrapolate( + AdscReportDto report, + IReadOnlyList route, + DateTimeOffset now) + { + var position = new Coordinate(report.Latitude, report.Longitude); + if (route.Count < 2) + return position; + + var remainingHours = Math.Max(0, (now - report.Timestamp).TotalHours); + for (var waypointIndex = FindNextWaypointIndex(route, position); waypointIndex < route.Count; waypointIndex++) + { + var speed = EstimatedSpeed(route, waypointIndex); + if (speed is null or <= 0) + return position; + + var waypoint = route[waypointIndex].Intersection.LatLong; + var hoursToWaypoint = Conversions.CalculateDistance(position, waypoint) / speed.Value; + if (remainingHours < hoursToWaypoint) + return Conversions.CalculateLLFromFractionOfRoute(position, waypoint, remainingHours / hoursToWaypoint); + + remainingHours -= hoursToWaypoint; + position = waypoint; + } + + return position; + } + + // The next waypoint is the end of the route leg that is nearest to the position. + // The nearest leg is the leg with the smallest detour when the aircraft flies from the start of the leg, through the position, to the end of the leg. + static int FindNextWaypointIndex(IReadOnlyList route, Coordinate position) + { + var nextWaypointIndex = 1; + var smallestDetour = double.MaxValue; + for (var i = 1; i < route.Count; i++) + { + var legStart = route[i - 1].Intersection.LatLong; + var legEnd = route[i].Intersection.LatLong; + var detour = Conversions.CalculateDistance(legStart, position) + + Conversions.CalculateDistance(position, legEnd) + - Conversions.CalculateDistance(legStart, legEnd); + + if (detour < smallestDetour) + { + smallestDetour = detour; + nextWaypointIndex = i; + } + } + + return nextWaypointIndex; + } + + // The speed in knots on the route leg that ends at the waypoint, from the route estimates. + static double? EstimatedSpeed(IReadOnlyList route, int waypointIndex) + { + var legStart = route[waypointIndex - 1]; + var legEnd = route[waypointIndex]; + if (legStart.ETO == DateTime.MaxValue || legEnd.ETO == DateTime.MaxValue) + return null; + + var legTime = legEnd.ETO - legStart.ETO; + if (legTime <= TimeSpan.Zero) + return null; + + return Conversions.CalculateDistance(legStart.Intersection.LatLong, legEnd.Intersection.LatLong) / legTime.TotalHours; + } +} diff --git a/source/CPDLCPlugin/AdscTrackDisplay.cs b/source/CPDLCPlugin/AdscTrackDisplay.cs index 82f5385..0e4d1bf 100644 --- a/source/CPDLCPlugin/AdscTrackDisplay.cs +++ b/source/CPDLCPlugin/AdscTrackDisplay.cs @@ -4,53 +4,91 @@ namespace CPDLCPlugin; +/// The latest ADS-C report for the aircraft. +/// False until the first normal position extrapolation after the report. +public record AdscTrack(AdscReportDto Report, bool Extrapolated); + /// -/// Holds the ADS-C reports that move flight plan tracks on the ASD. +/// Holds the ADS-C tracks that move flight plan tracks on the ASD. /// The patches in read this class on the vatSys threads, so all members are synchronous. /// -public class AdscTrackDisplay +public class AdscTrackDisplay(IClock clock) { - readonly ConcurrentDictionary _reports = new(); + readonly ConcurrentDictionary _tracks = new(); /// - /// Shows the report on the ASD, unless a newer report for the same aircraft is shown. + /// Shows the reported position on the ASD (update position extrapolation), unless a newer report for the same aircraft is shown. + /// The position stays until the next flight plan extrapolation. + /// The reported altitude becomes the pilot reported level (PRL) of the flight plan. /// public void Show(AdscReportDto report) { - _reports.AddOrUpdate( + var track = new AdscTrack(report, Extrapolated: false); + var shownTrack = _tracks.AddOrUpdate( report.Callsign, - report, - (_, existing) => existing.Timestamp > report.Timestamp ? existing : report); + track, + (_, existing) => existing.Report.Timestamp > report.Timestamp ? existing : track); + + if (!ReferenceEquals(shownTrack, track)) + return; + + var fdr = FindFdr(report.Callsign); + if (fdr is null) + return; + + FDP2.SetPRL(fdr, report.Altitude); - UpdateTrackPosition(report.Callsign); + if (fdr.CoupledTrack is null) + fdr.PredictedPosition.Location = new Coordinate(report.Latitude, report.Longitude); } public void Hide(string callsign) { - if (_reports.TryRemove(callsign, out _)) - UpdateTrackPosition(callsign); + if (_tracks.TryRemove(callsign, out _)) + RestoreFlightPlanPosition(callsign); } public void Clear() { - var callsigns = _reports.Keys.ToArray(); - _reports.Clear(); + var callsigns = _tracks.Keys.ToArray(); + _tracks.Clear(); foreach (var callsign in callsigns) - UpdateTrackPosition(callsign); + RestoreFlightPlanPosition(callsign); } - public bool TryGetReport(string callsign, out AdscReportDto report) + public bool TryGetTrack(string callsign, out AdscTrack track) { - return _reports.TryGetValue(callsign, out report); + return _tracks.TryGetValue(callsign, out track); } - // vatSys calculates the track positions every 10 seconds. - // Calculate the position now, so that the change shows immediately. - static void UpdateTrackPosition(string callsign) + /// + /// Returns the normal extrapolated position of the ADS-C track, or null if the aircraft has no ADS-C track. + /// + public Coordinate? Extrapolate(FDP2.FDR fdr) { - var fdr = FDP2.GetFDRs.FirstOrDefault(f => f.Callsign == callsign); + if (!_tracks.TryGetValue(fdr.Callsign, out var track)) + return null; + + var position = AdscExtrapolation.Extrapolate(track.Report, fdr.ParsedRoute.ToList(), clock.UtcNow()); + + // Do not change a newer report that arrived during the extrapolation + _tracks.TryUpdate(fdr.Callsign, track with { Extrapolated = true }, track); + + return position; + } + + // vatSys extrapolates the flight plan positions every 10 seconds. + // Calculate the position now, so that the track goes back to its flight plan position immediately. + static void RestoreFlightPlanPosition(string callsign) + { + var fdr = FindFdr(callsign); if (fdr is not null) FDP2.GenerateTrackPosition(fdr); } + + static FDP2.FDR? FindFdr(string callsign) + { + return FDP2.GetFDRs.FirstOrDefault(f => f.Callsign == callsign); + } } diff --git a/source/CPDLCPlugin/AdscTrackPatches.cs b/source/CPDLCPlugin/AdscTrackPatches.cs index 14e64fc..32aaa3e 100644 --- a/source/CPDLCPlugin/AdscTrackPatches.cs +++ b/source/CPDLCPlugin/AdscTrackPatches.cs @@ -8,9 +8,10 @@ namespace CPDLCPlugin; /// Shows ADS-C tracks on the ASD. /// vatSys does not draw ADS-C tracks, so these Harmony patches change the flight plan tracks that have an ADS-C report: /// -/// The track moves to the position in the latest ADS-C report. -/// The track uses the ADSC symbol, or the ADSCExtrapolated symbol when the report is old. The symbol does not rotate. +/// When a report arrives, the track moves to the reported position and uses the ADSC symbol. +/// Each time vatSys extrapolates the flight plan positions, the track moves along the route from the reported position and uses the ADSCExtrapolated symbol. /// +/// The symbol does not rotate. /// A coupled radar track always has priority over an ADS-C report. /// public static class AdscTrackPatches @@ -18,19 +19,15 @@ public static class AdscTrackPatches const string HarmonyId = "CPDLCPlugin.AdscTracks"; static AdscTrackDisplay? _display; - static IClock? _clock; - static TimeSpan _extrapolatedAfter; // The track that ASDControlDX.PaintASDTrack paints at this time. // Each ASD window paints on its own thread. [ThreadStatic] static Track? _trackBeingPainted; - public static void Apply(AdscTrackDisplay display, IClock clock, TimeSpan extrapolatedAfter) + public static void Apply(AdscTrackDisplay display) { _display = display; - _clock = clock; - _extrapolatedAfter = extrapolatedAfter; var harmony = new Harmony(HarmonyId); try @@ -74,19 +71,18 @@ static void PaintASDTrackFinalizer() static void PaintTrackGeometryRealisationPrefix(ref TrackSymbols.Types type, ref double aircraftHeading) { - if (_display is null || _clock is null || type != TrackSymbols.Types.FlightPlan) + if (_display is null || type != TrackSymbols.Types.FlightPlan) return; - var track = _trackBeingPainted; - if (track is null || track.Type != Track.TrackTypes.TRACK_TYPE_FP) + var paintedTrack = _trackBeingPainted; + if (paintedTrack is null || paintedTrack.Type != Track.TrackTypes.TRACK_TYPE_FP) return; - var fdr = track.GetFDR(); - if (fdr is null || !_display.TryGetReport(fdr.Callsign, out var report)) + var fdr = paintedTrack.GetFDR(); + if (fdr is null || !_display.TryGetTrack(fdr.Callsign, out var adscTrack)) return; - var reportAge = _clock.UtcNow() - report.Timestamp; - type = reportAge > _extrapolatedAfter + type = adscTrack.Extrapolated ? TrackSymbols.Types.ADSCExtrapolated : TrackSymbols.Types.ADSC; @@ -99,9 +95,8 @@ static void GenerateTrackPositionPostfix(FDP2.FDR fdr) if (_display is null || fdr.CoupledTrack is not null) return; - if (!_display.TryGetReport(fdr.Callsign, out var report)) - return; - - fdr.PredictedPosition.Location = new Coordinate(report.Latitude, report.Longitude); + var position = _display.Extrapolate(fdr); + if (position is not null) + fdr.PredictedPosition.Location = position; } } diff --git a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs index d750a1a..878d695 100644 --- a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs +++ b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs @@ -26,7 +26,4 @@ public class PluginConfiguration // ACCs shipping the plugin with pre-modified profiles can disable these to prevent // controllers from accidentally reverting the changes. public bool ShowInstallationMenuItems { get; init; } = true; - - // ADS-C tracks use the ADSCExtrapolated symbol when the latest report is older than this number of minutes. - public int AdscExtrapolatedAfterMinutes { get; init; } = 10; } diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs index 8caa374..7db1210 100644 --- a/source/CPDLCPlugin/Plugin.cs +++ b/source/CPDLCPlugin/Plugin.cs @@ -69,7 +69,7 @@ public Plugin() _configuration = configuration; ConfigureServices(configuration); - ApplyAdscTrackPatches(configuration); + ApplyAdscTrackPatches(); ApplyFakeAdscReportPatch(); AddToolbarItems(); @@ -130,14 +130,11 @@ ILogger ConfigureLogger(PluginConfiguration configuration) return logger; } - void ApplyAdscTrackPatches(PluginConfiguration pluginConfiguration) + void ApplyAdscTrackPatches() { try { - AdscTrackPatches.Apply( - ServiceProvider.GetRequiredService(), - ServiceProvider.GetRequiredService(), - TimeSpan.FromMinutes(pluginConfiguration.AdscExtrapolatedAfterMinutes)); + AdscTrackPatches.Apply(ServiceProvider.GetRequiredService()); } catch (Exception ex) { diff --git a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs index ebd6396..643ea51 100644 --- a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs +++ b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs @@ -574,6 +574,88 @@ public async Task Poll_AdscReportFromPreviousMonth_ParsesTimestamp() Assert.Equal(new DateTimeOffset(2026, 10, 31, 23, 59, 0, TimeSpan.Zero), reportMessage.Report.Timestamp); } + [Theory] + [InlineData("-33.946100 151.177200", -33.9461, 151.1772)] + [InlineData("S33.946100 E151.177200", -33.9461, 151.1772)] + [InlineData("33.946100S 151.177200E", -33.9461, 151.1772)] + [InlineData("N51.47750 W0.46139", 51.4775, -0.46139)] + [InlineData("n51.47750 w0.46139", 51.4775, -0.46139)] + public async Task Poll_AdscReportPositionFormats_ParsesPosition(string position, double expectedLatitude, double expectedLongitude) + { + // Arrange + var clock = new TestClock(); + clock.SetUtcNow(new DateTimeOffset(2026, 10, 13, 12, 0, 0, TimeSpan.Zero)); + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, $"ok {{UAL123 ads-c {{REPORT UAL123 131155 {position} 37000}}}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler, clock); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var message = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var reportMessage = Assert.IsType(message); + Assert.Equal(expectedLatitude, reportMessage.Report.Latitude); + Assert.Equal(expectedLongitude, reportMessage.Report.Longitude); + } + + [Theory] + [InlineData("37000", 37000)] + [InlineData("10000", 10000)] + [InlineData("351", 35100)] + [InlineData("9999", 999900)] + public async Task Poll_AdscReportAltitude_ParsesAltitudeInFeet(string altitude, int expectedAltitude) + { + // Arrange + var clock = new TestClock(); + clock.SetUtcNow(new DateTimeOffset(2026, 10, 13, 12, 0, 0, TimeSpan.Zero)); + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, $"ok {{UAL123 ads-c {{REPORT UAL123 131155 -33.946100 151.177200 {altitude}}}}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler, clock); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var message = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var reportMessage = Assert.IsType(message); + Assert.Equal(expectedAltitude, reportMessage.Report.Altitude); + } + + [Theory] + [InlineData("ABC 151.177200")] + [InlineData("-33.946100 W151E")] + [InlineData("91.000000 151.177200")] + [InlineData("-33.946100 181.000000")] + [InlineData("E33.946100 N151.177200")] + public async Task Poll_AdscReportWithInvalidPosition_IgnoresReport(string position) + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse(HttpStatusCode.OK, $"ok {{UAL123 ads-c {{REPORT UAL123 131155 {position} 37000}}}} {{DAL456 ads-c {{REJECT}}}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var message = await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var rejection = Assert.IsType(message); + Assert.Equal("DAL456", rejection.Sender); + } + [Fact] public async Task Poll_AdscReject_ParsesCorrectly() { diff --git a/source/CPDLCServer/Clients/HoppieAcarsClient.cs b/source/CPDLCServer/Clients/HoppieAcarsClient.cs index 7b1430b..16e307a 100644 --- a/source/CPDLCServer/Clients/HoppieAcarsClient.cs +++ b/source/CPDLCServer/Clients/HoppieAcarsClient.cs @@ -310,6 +310,9 @@ void ParseAndPublishDownlinkMessages(string responseText) case "ads-c": var adscMessage = ParseAdscDownlink(from, packet); + if (adscMessage is null) + break; + _logger.Information("Received {MessageType} from {From}", type, from); _adscMessageChannel.Writer.TryWrite(adscMessage); break; @@ -415,7 +418,7 @@ ReceivedDownlink ParseCpdlcDownlink(string from, string packet) // Packet formats: // REPORT [] // REJECT - ReceivedAdscMessage ParseAdscDownlink(string from, string packet) + ReceivedAdscMessage? ParseAdscDownlink(string from, string packet) { var parts = packet.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) @@ -430,12 +433,19 @@ ReceivedAdscMessage ParseAdscDownlink(string from, string packet) if (parts.Length < 6) throw new MessageParseException($"Invalid ADS-C report: Expected 6 or more components, got {parts.Length}: \"{packet}\""); - // Ignore the optional groups (track, ground speed, meteorological data) for now + if (!TryParseCoordinate(parts[3], 'N', 'S', 90, out var latitude) || + !TryParseCoordinate(parts[4], 'E', 'W', 180, out var longitude)) + { + _logger.Warning("Ignoring ADS-C report from {From}: Cannot read the position: \"{Packet}\"", from, packet); + return null; + } + + // Aircraft do not agree on the format of the optional groups, so ignore them var report = new AdscReport( ParseAdscTimestamp(parts[2]), - double.Parse(parts[3], CultureInfo.InvariantCulture), - double.Parse(parts[4], CultureInfo.InvariantCulture), - int.Parse(parts[5], CultureInfo.InvariantCulture)); + latitude, + longitude, + ParseAdscAltitude(parts[5])); return new ReceivedAdscReport(from, report, _clock.UtcNow()); @@ -444,6 +454,40 @@ ReceivedAdscMessage ParseAdscDownlink(string from, string packet) } } + // Aircraft send coordinates in different formats. + // For example, a latitude can be "-33.9461", "S33.9461", or "33.9461S". + static bool TryParseCoordinate(string text, char positiveHemisphere, char negativeHemisphere, double maximum, out double coordinate) + { + coordinate = 0; + + var number = text.ToUpperInvariant(); + var sign = 1; + if (number.Length > 1 && (number[0] == positiveHemisphere || number[0] == negativeHemisphere)) + { + sign = number[0] == negativeHemisphere ? -1 : 1; + number = number[1..]; + } + else if (number.Length > 1 && (number[^1] == positiveHemisphere || number[^1] == negativeHemisphere)) + { + sign = number[^1] == negativeHemisphere ? -1 : 1; + number = number[..^1]; + } + + if (!double.TryParse(number, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value)) + return false; + + coordinate = sign * value; + return Math.Abs(coordinate) <= maximum; + } + + // Some aircraft send the altitude in feet, and some send it in hundreds of feet. + // Below 10,000, the altitude is in hundreds of feet. + static int ParseAdscAltitude(string text) + { + var altitude = int.Parse(text, CultureInfo.InvariantCulture); + return altitude < 10_000 ? altitude * 100 : altitude; + } + // The report time has the day of the month, the hour, and the minute (ddHHmm) in UTC. // Use the current month, or the previous month if the day is in the future. DateTimeOffset ParseAdscTimestamp(string text) From 6d70c76bbb0f4714c8d59635770dd3b0edb6bd8b Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:48:02 +1000 Subject: [PATCH 06/33] temp --- source/CPDLCPlugin/AdscTrackPatches.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/CPDLCPlugin/AdscTrackPatches.cs b/source/CPDLCPlugin/AdscTrackPatches.cs index 32aaa3e..d603409 100644 --- a/source/CPDLCPlugin/AdscTrackPatches.cs +++ b/source/CPDLCPlugin/AdscTrackPatches.cs @@ -82,9 +82,10 @@ static void PaintTrackGeometryRealisationPrefix(ref TrackSymbols.Types type, ref if (fdr is null || !_display.TryGetTrack(fdr.Callsign, out var adscTrack)) return; + // TODO: Temporary test. The symbols are swapped. Revert this change. type = adscTrack.Extrapolated - ? TrackSymbols.Types.ADSCExtrapolated - : TrackSymbols.Types.ADSC; + ? TrackSymbols.Types.ADSC + : TrackSymbols.Types.ADSCExtrapolated; // vatSys does not rotate a symbol when the heading is -1 aircraftHeading = -1; From 87ef50613fa385ff1ff5e54d85da9e5497c4a326 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:56:41 +1000 Subject: [PATCH 07/33] Fix extrapolation symbol --- docs/docs/vatsys-plugin/configuration.md | 12 ++++++++++ source/CPDLCPlugin/AdscTrackDisplay.cs | 24 ++++++++++++++----- source/CPDLCPlugin/AdscTrackPatches.cs | 5 ++-- .../Configuration/PluginConfiguration.cs | 4 ++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/docs/vatsys-plugin/configuration.md b/docs/docs/vatsys-plugin/configuration.md index 75947b3..116dc9a 100644 --- a/docs/docs/vatsys-plugin/configuration.md +++ b/docs/docs/vatsys-plugin/configuration.md @@ -128,6 +128,17 @@ Default: `true` "ShowInstallationMenuItems": false ``` +### ADSCTrackExtrapolationThreshold + +The minimum time (in seconds) that an ADS-C track shows the reported position with the `ADSC` symbol. After this time, the next flight plan extrapolation moves the track along its route, and the track uses the `ADSCExtrapolated` symbol. + +Default: `10` + +**Example:** +```json +"ADSCTrackExtrapolationThreshold": 10 +``` + ## Uplink Messages The `UplinkMessages` object defines the available CPDLC messages for the controller to send. @@ -224,6 +235,7 @@ Array of message groups for organizing messages in the editor. Each group has: { "AtsuCode": "YMMM", "Sectors": ["GUN", "BLA", "IND"] } ], "NdaRecalculationIntervalMinutes": 1, + "ADSCTrackExtrapolationThreshold": 10, "UplinkMessages": { "MasterMessages": [ { diff --git a/source/CPDLCPlugin/AdscTrackDisplay.cs b/source/CPDLCPlugin/AdscTrackDisplay.cs index 0e4d1bf..7c5ed44 100644 --- a/source/CPDLCPlugin/AdscTrackDisplay.cs +++ b/source/CPDLCPlugin/AdscTrackDisplay.cs @@ -1,29 +1,35 @@ using System.Collections.Concurrent; +using CPDLCPlugin.Configuration; using CPDLCServer.Contracts; using vatsys; namespace CPDLCPlugin; /// The latest ADS-C report for the aircraft. +/// The time the report was shown on the ASD. /// False until the first normal position extrapolation after the report. -public record AdscTrack(AdscReportDto Report, bool Extrapolated); +public record AdscTrack(AdscReportDto Report, DateTimeOffset Shown, bool Extrapolated); /// /// Holds the ADS-C tracks that move flight plan tracks on the ASD. /// The patches in read this class on the vatSys threads, so all members are synchronous. /// -public class AdscTrackDisplay(IClock clock) +public class AdscTrackDisplay(IClock clock, PluginConfiguration configuration) { + // vatSys extrapolates the flight plan positions every 10 seconds, and also each time it processes a flight plan. + // Show the reported position for this minimum time, so that a controller can see the ADSC symbol. + readonly TimeSpan _minimumReportedPositionTime = TimeSpan.FromSeconds(configuration.ADSCTrackExtrapolationThreshold); + readonly ConcurrentDictionary _tracks = new(); /// /// Shows the reported position on the ASD (update position extrapolation), unless a newer report for the same aircraft is shown. - /// The position stays until the next flight plan extrapolation. + /// The position stays until the first flight plan extrapolation after the ADS-C track extrapolation threshold. /// The reported altitude becomes the pilot reported level (PRL) of the flight plan. /// public void Show(AdscReportDto report) { - var track = new AdscTrack(report, Extrapolated: false); + var track = new AdscTrack(report, clock.UtcNow(), Extrapolated: false); var shownTrack = _tracks.AddOrUpdate( report.Callsign, track, @@ -63,14 +69,20 @@ public bool TryGetTrack(string callsign, out AdscTrack track) } /// - /// Returns the normal extrapolated position of the ADS-C track, or null if the aircraft has no ADS-C track. + /// Returns the position of the ADS-C track, or null if the aircraft has no ADS-C track. + /// Until the ADS-C track extrapolation threshold after the report was shown, this is the reported position. + /// After that, this is the normal extrapolated position. /// public Coordinate? Extrapolate(FDP2.FDR fdr) { if (!_tracks.TryGetValue(fdr.Callsign, out var track)) return null; - var position = AdscExtrapolation.Extrapolate(track.Report, fdr.ParsedRoute.ToList(), clock.UtcNow()); + var now = clock.UtcNow(); + if (now - track.Shown < _minimumReportedPositionTime) + return new Coordinate(track.Report.Latitude, track.Report.Longitude); + + var position = AdscExtrapolation.Extrapolate(track.Report, fdr.ParsedRoute.ToList(), now); // Do not change a newer report that arrived during the extrapolation _tracks.TryUpdate(fdr.Callsign, track with { Extrapolated = true }, track); diff --git a/source/CPDLCPlugin/AdscTrackPatches.cs b/source/CPDLCPlugin/AdscTrackPatches.cs index d603409..32aaa3e 100644 --- a/source/CPDLCPlugin/AdscTrackPatches.cs +++ b/source/CPDLCPlugin/AdscTrackPatches.cs @@ -82,10 +82,9 @@ static void PaintTrackGeometryRealisationPrefix(ref TrackSymbols.Types type, ref if (fdr is null || !_display.TryGetTrack(fdr.Callsign, out var adscTrack)) return; - // TODO: Temporary test. The symbols are swapped. Revert this change. type = adscTrack.Extrapolated - ? TrackSymbols.Types.ADSC - : TrackSymbols.Types.ADSCExtrapolated; + ? TrackSymbols.Types.ADSCExtrapolated + : TrackSymbols.Types.ADSC; // vatSys does not rotate a symbol when the heading is -1 aircraftHeading = -1; diff --git a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs index 878d695..e970dfc 100644 --- a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs +++ b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs @@ -26,4 +26,8 @@ public class PluginConfiguration // ACCs shipping the plugin with pre-modified profiles can disable these to prevent // controllers from accidentally reverting the changes. public bool ShowInstallationMenuItems { get; init; } = true; + + // The minimum time (in seconds) that an ADS-C track shows the reported position and the ADSC symbol. + // After this time, the track is extrapolated along the route and uses the ADSCExtrapolated symbol. + public int ADSCTrackExtrapolationThreshold { get; init; } = 10; } From e6ae20bfd4036ada66c2503edbfa0f62ee4b4ef0 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:45:46 +1100 Subject: [PATCH 08/33] Stub ADS-C window --- .../ViewModels/ADSContractViewModel.cs | 29 +++ .../Windows/ADSContractWindow.xaml | 189 ++++++++++++++++++ .../Windows/ADSContractWindow.xaml.cs | 12 ++ 3 files changed, 230 insertions(+) create mode 100644 source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs create mode 100644 source/CPDLCPlugin/Windows/ADSContractWindow.xaml create mode 100644 source/CPDLCPlugin/Windows/ADSContractWindow.xaml.cs diff --git a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs new file mode 100644 index 0000000..dcd11b3 --- /dev/null +++ b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs @@ -0,0 +1,29 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CPDLCPlugin.ViewModels; + + + +enum TransmissionState +{ + None, + Accepted, + ContractSent, + ContractRefused +} + +public partial class ADSContractViewModel : ObservableObject +{ + [ObservableProperty] string _callsign; + [ObservableProperty] int _currentPeriodMinutes; + [ObservableProperty] DateTimeOffset _lastReport; + + [ObservableProperty] int _defaultPeriod; + [ObservableProperty] TransmissionState _defaultPeriodTransmissionState; + + [ObservableProperty] int _selectedPeriod; + [ObservableProperty] TransmissionState _selectedPeriodTransmissionState; + [ObservableProperty] DateTimeOffset _selectedPeriodEndTime; + + [ObservableProperty] TransmissionState _demandContractTransmissionState; +} diff --git a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml new file mode 100644 index 0000000..4f70f3b --- /dev/null +++ b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/CPDLCPlugin/Windows/HistoryWindow.xaml b/source/CPDLCPlugin/Windows/HistoryWindow.xaml index 80430a5..eaa6ccc 100644 --- a/source/CPDLCPlugin/Windows/HistoryWindow.xaml +++ b/source/CPDLCPlugin/Windows/HistoryWindow.xaml @@ -42,6 +42,7 @@ Date: Thu, 15 Oct 2026 07:25:45 +1000 Subject: [PATCH 13/33] Wire up demand button --- .../RequestAdscDemandReportRequest.cs | 25 +++++++++++++++++++ .../ViewModels/ADSContractViewModel.cs | 16 ++++++++++++ .../Windows/ADSContractWindow.xaml | 3 ++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 source/CPDLCPlugin/Messages/RequestAdscDemandReportRequest.cs diff --git a/source/CPDLCPlugin/Messages/RequestAdscDemandReportRequest.cs b/source/CPDLCPlugin/Messages/RequestAdscDemandReportRequest.cs new file mode 100644 index 0000000..3a4afe7 --- /dev/null +++ b/source/CPDLCPlugin/Messages/RequestAdscDemandReportRequest.cs @@ -0,0 +1,25 @@ +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record RequestAdscDemandReportRequest(string Callsign) : IRequest; + +public class RequestAdscDemandReportRequestHandler( + Plugin plugin, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(RequestAdscDemandReportRequest request, CancellationToken cancellationToken) + { + logger.Verbose("Requesting ADS-C demand report for {Callsign}", request.Callsign); + if (plugin.ConnectionManager is null || !plugin.ConnectionManager.IsConnected) + { + logger.Warning("Not connected to server"); + return; + } + + await plugin.ConnectionManager.RequestAdscDemandReport(request.Callsign, cancellationToken); + logger.Information("Requested ADS-C demand report for {Callsign}", request.Callsign); + } +} diff --git a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs index 6f75d4c..a6e4ff1 100644 --- a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs +++ b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs @@ -149,6 +149,22 @@ async Task SelectCustomPeriod(string text) await SelectPeriod(minutes); } + [RelayCommand] + async Task RequestDemandReport() + { + try + { + if (string.IsNullOrWhiteSpace(Callsign)) + return; + + await _mediator.Send(new RequestAdscDemandReportRequest(Callsign)); + } + catch (Exception ex) + { + _errorReporter.ReportError(ex); + } + } + [RelayCommand] async Task LoadContract() { diff --git a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml index f4b8ab6..3ac2461 100644 --- a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml +++ b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml @@ -229,7 +229,8 @@ From bf72bf8c9e21ae934265ad64efb14f054a56d709 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:34:23 +1000 Subject: [PATCH 14/33] Add ADS-C page --- .../Clients/HoppieAcarsClientTests.cs | 71 ++++++++ .../Mocks/TestAdscMessageLogRepository.cs | 19 ++ .../InMemoryAdscMessageLogRepositoryTests.cs | 56 ++++++ source/CPDLCServer/Clients/ClientManager.cs | 5 + .../CPDLCServer/Clients/HoppieAcarsClient.cs | 36 +++- .../CPDLCServer/Model/AdscMessageLogEntry.cs | 16 ++ source/CPDLCServer/Pages/Adsc/Index.cshtml | 166 ++++++++++++++++++ source/CPDLCServer/Pages/Adsc/Index.cshtml.cs | 48 +++++ .../CPDLCServer/Pages/Shared/_Layout.cshtml | 1 + .../Persistence/IAdscMessageLogRepository.cs | 11 ++ .../InMemoryAdscMessageLogRepository.cs | 32 ++++ source/CPDLCServer/Program.cs | 1 + 12 files changed, 456 insertions(+), 6 deletions(-) create mode 100644 source/CPDLCServer.Tests/Mocks/TestAdscMessageLogRepository.cs create mode 100644 source/CPDLCServer.Tests/Persistence/InMemoryAdscMessageLogRepositoryTests.cs create mode 100644 source/CPDLCServer/Model/AdscMessageLogEntry.cs create mode 100644 source/CPDLCServer/Pages/Adsc/Index.cshtml create mode 100644 source/CPDLCServer/Pages/Adsc/Index.cshtml.cs create mode 100644 source/CPDLCServer/Persistence/IAdscMessageLogRepository.cs create mode 100644 source/CPDLCServer/Persistence/InMemoryAdscMessageLogRepository.cs diff --git a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs index 643ea51..64ac5a3 100644 --- a/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs +++ b/source/CPDLCServer.Tests/Clients/HoppieAcarsClientTests.cs @@ -1,6 +1,7 @@ using System.Net; using CPDLCServer.Clients; using CPDLCServer.Model; +using CPDLCServer.Persistence; using CPDLCServer.Tests.Mocks; using Serilog.Core; @@ -17,6 +18,7 @@ public class HoppieAcarsClientTests : IDisposable }; private readonly List _clients = new(); + private readonly TestAdscMessageLogRepository _adscMessageLogRepository = new(); public void Dispose() { @@ -44,6 +46,7 @@ private HoppieAcarsClient CreateClient(TestHttpMessageHandler httpHandler, TestC _configuration, httpClient, clock ?? new TestClock(), + _adscMessageLogRepository, Logger.None); _clients.Add(client); return client; @@ -701,6 +704,74 @@ public async Task Poll_CpdlcAndAdscMessages_AreWrittenToSeparateReaders() Assert.False(client.AdscMessageReader.TryRead(out _)); } + [Fact] + public async Task RequestPeriodicAdscContract_LogsUplinkPacket() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var clock = new TestClock(); + var client = CreateClient(httpHandler, clock); + + // Act + await client.RequestPeriodicAdscContract("UAL123", TimeSpan.FromMinutes(5), CancellationToken.None); + + // Assert + var entry = Assert.Single(await _adscMessageLogRepository.All(CancellationToken.None)); + Assert.Equal(clock.UtcNow(), entry.Time); + Assert.Equal("Hoppies/YBBB", entry.AcarsClientId); + Assert.Equal("YBBB", entry.StationId); + Assert.Equal("UAL123", entry.Callsign); + Assert.Equal(AdscMessageDirection.Uplink, entry.Direction); + Assert.Equal("REQUEST PERIODIC 300", entry.Packet); + } + + [Fact] + public async Task RequestAdscDemandReport_WhenSendFails_DoesNotLogPacket() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.SetResponse(HttpStatusCode.InternalServerError, "error"); + var client = CreateClient(httpHandler); + + // Act & Assert + await Assert.ThrowsAsync(() => + client.RequestAdscDemandReport("UAL123", CancellationToken.None)); + Assert.Empty(await _adscMessageLogRepository.All(CancellationToken.None)); + } + + [Fact] + public async Task Poll_AdscMessages_LogsDownlinkPackets() + { + // Arrange + var httpHandler = new TestHttpMessageHandler(); + httpHandler.QueueResponse( + HttpStatusCode.OK, + "ok {UAL123 cpdlc {/data2/5//Y/REQUEST DESCENT}} {UAL123 ads-c {REPORT UAL123 131155 ABC 151.177200 37000}} {DAL456 ads-c {REJECT}}"); + httpHandler.SetResponse(HttpStatusCode.OK, "ok"); + var client = CreateClient(httpHandler); + + // Act + await client.Connect(CancellationToken.None); + await Task.Delay(100); // Give polling task time to start + + // The packet is logged before the message is written to the reader + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await client.AdscMessageReader.ReadAsync(cts.Token); + + // Assert + var entries = await _adscMessageLogRepository.All(CancellationToken.None); + Assert.Equal(2, entries.Length); + Assert.All(entries, e => Assert.Equal(AdscMessageDirection.Downlink, e.Direction)); + + // The report cannot be parsed, but it is logged + Assert.Equal("UAL123", entries[1].Callsign); + Assert.Equal("REPORT UAL123 131155 ABC 151.177200 37000", entries[1].Packet); + + Assert.Equal("DAL456", entries[0].Callsign); + Assert.Equal("REJECT", entries[0].Packet); + } + private async Task> ParseFormDataFromRequest(HttpRequestMessage request) { var formDataString = await request.Content!.ReadAsStringAsync(); diff --git a/source/CPDLCServer.Tests/Mocks/TestAdscMessageLogRepository.cs b/source/CPDLCServer.Tests/Mocks/TestAdscMessageLogRepository.cs new file mode 100644 index 0000000..5286fe9 --- /dev/null +++ b/source/CPDLCServer.Tests/Mocks/TestAdscMessageLogRepository.cs @@ -0,0 +1,19 @@ +using CPDLCServer.Model; +using CPDLCServer.Persistence; + +namespace CPDLCServer.Tests.Mocks; + +public class TestAdscMessageLogRepository : IAdscMessageLogRepository +{ + private readonly InMemoryAdscMessageLogRepository _inner = new(); + + public Task Add(AdscMessageLogEntry entry, CancellationToken cancellationToken) + { + return _inner.Add(entry, cancellationToken); + } + + public Task All(CancellationToken cancellationToken) + { + return _inner.All(cancellationToken); + } +} diff --git a/source/CPDLCServer.Tests/Persistence/InMemoryAdscMessageLogRepositoryTests.cs b/source/CPDLCServer.Tests/Persistence/InMemoryAdscMessageLogRepositoryTests.cs new file mode 100644 index 0000000..7547aaf --- /dev/null +++ b/source/CPDLCServer.Tests/Persistence/InMemoryAdscMessageLogRepositoryTests.cs @@ -0,0 +1,56 @@ +using CPDLCServer.Model; +using CPDLCServer.Persistence; + +namespace CPDLCServer.Tests.Persistence; + +public class InMemoryAdscMessageLogRepositoryTests +{ + static readonly DateTimeOffset Start = new(2026, 10, 13, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task All_ReturnsNewestEntriesFirst() + { + // Arrange + var repository = new InMemoryAdscMessageLogRepository(); + var first = CreateEntry(0); + var second = CreateEntry(1); + + // Act + await repository.Add(first, CancellationToken.None); + await repository.Add(second, CancellationToken.None); + + // Assert + Assert.Equal([second, first], await repository.All(CancellationToken.None)); + } + + [Fact] + public async Task Add_WhenFull_RemovesOldestEntry() + { + // Arrange + var repository = new InMemoryAdscMessageLogRepository(); + for (var i = 0; i < InMemoryAdscMessageLogRepository.Capacity; i++) + await repository.Add(CreateEntry(i), CancellationToken.None); + + var newest = CreateEntry(InMemoryAdscMessageLogRepository.Capacity); + + // Act + await repository.Add(newest, CancellationToken.None); + + // Assert + var entries = await repository.All(CancellationToken.None); + Assert.Equal(InMemoryAdscMessageLogRepository.Capacity, entries.Length); + Assert.Equal(newest, entries.First()); + Assert.Equal(CreateEntry(1), entries.Last()); + } + + static AdscMessageLogEntry CreateEntry(int index) + { + return new AdscMessageLogEntry( + Start.AddMinutes(index), + "Hoppies/YBBB", + "YBBB", + "UAL123", + AdscMessageDirection.Uplink, + $"REQUEST PERIODIC {index}"); + } +} diff --git a/source/CPDLCServer/Clients/ClientManager.cs b/source/CPDLCServer/Clients/ClientManager.cs index 1cad53a..6b9a7e8 100644 --- a/source/CPDLCServer/Clients/ClientManager.cs +++ b/source/CPDLCServer/Clients/ClientManager.cs @@ -2,6 +2,7 @@ using CPDLCServer.Infrastructure; using CPDLCServer.Messages; using CPDLCServer.Model; +using CPDLCServer.Persistence; using MediatR; namespace CPDLCServer.Clients; @@ -19,6 +20,7 @@ public class ClientManager : BackgroundService, IClientManager readonly SemaphoreSlim _semaphoreSlim = new(1, 1); readonly Dictionary _clients = []; readonly IClock _clock; + readonly IAdscMessageLogRepository _adscMessageLogRepository; readonly ILogger _logger; readonly IMediator _mediator; @@ -26,6 +28,7 @@ public ClientManager( IConfiguration configuration, IMediator mediator, IClock clock, + IAdscMessageLogRepository adscMessageLogRepository, ILogger logger) { var acarsConfigurationSection = configuration.GetSection("Acars"); @@ -54,6 +57,7 @@ public ClientManager( _mediator = mediator; _clock = clock; + _adscMessageLogRepository = adscMessageLogRepository; _logger = logger.ForContext(); } @@ -207,6 +211,7 @@ HoppieAcarsClient CreateHoppieClient(HoppiesConfiguration configuration) configuration, httpClient, _clock, + _adscMessageLogRepository, _logger.ForContext()); } diff --git a/source/CPDLCServer/Clients/HoppieAcarsClient.cs b/source/CPDLCServer/Clients/HoppieAcarsClient.cs index 16e307a..eff9599 100644 --- a/source/CPDLCServer/Clients/HoppieAcarsClient.cs +++ b/source/CPDLCServer/Clients/HoppieAcarsClient.cs @@ -3,6 +3,7 @@ using CPDLCServer.Exceptions; using CPDLCServer.Infrastructure; using CPDLCServer.Model; +using CPDLCServer.Persistence; namespace CPDLCServer.Clients; @@ -18,6 +19,7 @@ public class HoppieAcarsClient : IAcarsClient readonly HoppiesConfiguration _configuration; readonly HttpClient _httpClient; readonly IClock _clock; + readonly IAdscMessageLogRepository _adscMessageLogRepository; readonly ILogger _logger; readonly Channel _messageChannel = Channel.CreateUnbounded(); @@ -34,12 +36,13 @@ public class HoppieAcarsClient : IAcarsClient public ChannelReader AdscMessageReader => _adscMessageChannel.Reader; public string StationId => _configuration.StationIdentifier; - public HoppieAcarsClient(HoppiesConfiguration configuration, HttpClient httpClient, IClock clock, ILogger logger) + public HoppieAcarsClient(HoppiesConfiguration configuration, HttpClient httpClient, IClock clock, IAdscMessageLogRepository adscMessageLogRepository, ILogger logger) { _configuration = configuration; _httpClient = httpClient; _httpClient.Timeout = TimeSpan.FromSeconds(15); _clock = clock; + _adscMessageLogRepository = adscMessageLogRepository; _logger = logger.ForContext("ClientId", _configuration.ClientId); } @@ -74,7 +77,7 @@ public async Task RequestPeriodicAdscContract(string callsign, TimeSpan reportin if (reportingIntervalSeconds <= 0) throw new ArgumentOutOfRangeException(nameof(reportingInterval), reportingInterval, "Reporting interval must be one second or more"); - await SendPacket(callsign, "ads-c", $"REQUEST PERIODIC {reportingIntervalSeconds}", cancellationToken); + await SendAdscPacket(callsign, $"REQUEST PERIODIC {reportingIntervalSeconds}", cancellationToken); } // Hoppie has no separate demand contract. A periodic request with an interval of zero asks for one report. @@ -83,7 +86,7 @@ public async Task RequestAdscDemandReport(string callsign, CancellationToken can if (_disposed) throw new ObjectDisposedException(nameof(HoppieAcarsClient)); - await SendPacket(callsign, "ads-c", "REQUEST PERIODIC 0", cancellationToken); + await SendAdscPacket(callsign, "REQUEST PERIODIC 0", cancellationToken); } public async Task CancelAdscContract(string callsign, CancellationToken cancellationToken) @@ -91,7 +94,25 @@ public async Task CancelAdscContract(string callsign, CancellationToken cancella if (_disposed) throw new ObjectDisposedException(nameof(HoppieAcarsClient)); - await SendPacket(callsign, "ads-c", "REQUEST CANCEL", cancellationToken); + await SendAdscPacket(callsign, "REQUEST CANCEL", cancellationToken); + } + + async Task SendAdscPacket(string callsign, string packet, CancellationToken cancellationToken) + { + await SendPacket(callsign, "ads-c", packet, cancellationToken); + await LogAdscPacket(callsign, AdscMessageDirection.Uplink, packet, cancellationToken); + } + + async Task LogAdscPacket(string callsign, AdscMessageDirection direction, string packet, CancellationToken cancellationToken) + { + var entry = new AdscMessageLogEntry( + _clock.UtcNow(), + _configuration.ClientId, + _configuration.StationIdentifier, + callsign, + direction, + packet); + await _adscMessageLogRepository.Add(entry, cancellationToken); } async Task SendPacket(string to, string messageType, string packet, CancellationToken cancellationToken) @@ -242,7 +263,7 @@ async Task Poll(CancellationToken cancellationToken) if (!string.IsNullOrWhiteSpace(responseText) && responseText != "ok") { - ParseAndPublishDownlinkMessages(responseText); + await ParseAndPublishDownlinkMessages(responseText, cancellationToken); } } catch (HttpRequestException ex) @@ -273,7 +294,7 @@ async Task Poll(CancellationToken cancellationToken) } } - void ParseAndPublishDownlinkMessages(string responseText) + async Task ParseAndPublishDownlinkMessages(string responseText, CancellationToken cancellationToken) { try { @@ -309,6 +330,9 @@ void ParseAndPublishDownlinkMessages(string responseText) break; case "ads-c": + // Log the packet before it is parsed, so that packets that cannot be parsed are also in the log + await LogAdscPacket(from, AdscMessageDirection.Downlink, packet, cancellationToken); + var adscMessage = ParseAdscDownlink(from, packet); if (adscMessage is null) break; diff --git a/source/CPDLCServer/Model/AdscMessageLogEntry.cs b/source/CPDLCServer/Model/AdscMessageLogEntry.cs new file mode 100644 index 0000000..36568a4 --- /dev/null +++ b/source/CPDLCServer/Model/AdscMessageLogEntry.cs @@ -0,0 +1,16 @@ +namespace CPDLCServer.Model; + +public enum AdscMessageDirection +{ + Uplink, + Downlink +} + +// An ADS-C packet as it was sent to or received from the ACARS network +public record AdscMessageLogEntry( + DateTimeOffset Time, + string AcarsClientId, + string StationId, + string Callsign, + AdscMessageDirection Direction, + string Packet); diff --git a/source/CPDLCServer/Pages/Adsc/Index.cshtml b/source/CPDLCServer/Pages/Adsc/Index.cshtml new file mode 100644 index 0000000..16782c8 --- /dev/null +++ b/source/CPDLCServer/Pages/Adsc/Index.cshtml @@ -0,0 +1,166 @@ +@page "/adsc" +@using CPDLCServer.Model +@model CPDLCServer.Pages.Dashboard.Adsc.IndexModel +@{ + ViewData["Title"] = "ADS-C"; + + string FormatInterval(TimeSpan? interval) => interval is null ? "-" : $"{interval.Value.TotalMinutes:0.#} min"; + + string GetStateBadgeClass(AdscContractState? state) => state switch + { + AdscContractState.Sent => "bg-info", + AdscContractState.Accepted => "bg-success", + AdscContractState.Rejected => "bg-danger", + _ => "bg-light text-dark" + }; +} + + + +

ADS-C

+ +
+
+
Contracts
+
+
+ @if (Model.Contracts.Length == 0) + { +

No ADS-C contracts.

+ } + else + { +
+ + + + + + + + + + + + + + + @foreach (var contract in Model.Contracts) + { + + + + + + + + + + + } + +
CallsignATSU CodeCurrent IntervalDefaultSelected IntervalSelectedSelected EndsDemand
@contract.Callsign@contract.StationId@FormatInterval(contract.ReportingInterval)@(contract.DefaultContractState?.ToString() ?? "-")@FormatInterval(contract.SelectedReportingInterval)@(contract.SelectedContractState?.ToString() ?? "-")@(contract.SelectedContractEnds?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")@(contract.DemandContractState?.ToString() ?? "-")
+
+ } +
+
+ +
+
+
Message Log
+
+
+ @if (Model.LogEntries.Length == 0) + { +

No ADS-C messages.

+ } + else + { +
+ + + + + + + + + + + + + @foreach (var entry in Model.LogEntries) + { + + + + + + + + + } + +
TimestampNetworkDirATSU CodeCallsignPacket
@entry.Time.ToString("yyyy-MM-dd HH:mm:ss")@entry.AcarsClientId@(entry.Direction == AdscMessageDirection.Uplink ? "↑" : "↓")@entry.StationId@entry.Callsign@entry.Packet
+
+ } +
+
+ +@if (Model.TotalPages > 1) +{ + +} diff --git a/source/CPDLCServer/Pages/Adsc/Index.cshtml.cs b/source/CPDLCServer/Pages/Adsc/Index.cshtml.cs new file mode 100644 index 0000000..ed242a3 --- /dev/null +++ b/source/CPDLCServer/Pages/Adsc/Index.cshtml.cs @@ -0,0 +1,48 @@ +using CPDLCServer.Model; +using CPDLCServer.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace CPDLCServer.Pages.Dashboard.Adsc; + +public class IndexModel : PageModel +{ + private readonly IAdscContractRepository _adscContractRepository; + private readonly IAdscMessageLogRepository _adscMessageLogRepository; + private const int PageSize = 50; + + public IndexModel( + IAdscContractRepository adscContractRepository, + IAdscMessageLogRepository adscMessageLogRepository) + { + _adscContractRepository = adscContractRepository; + _adscMessageLogRepository = adscMessageLogRepository; + } + + [BindProperty(SupportsGet = true)] + public int PageNumber { get; set; } = 1; + + public AdscContract[] Contracts { get; private set; } = []; + public AdscMessageLogEntry[] LogEntries { get; private set; } = []; + public int TotalPages { get; private set; } + public int CurrentPage => PageNumber; + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + var contracts = await _adscContractRepository.All(cancellationToken); + Contracts = contracts + .OrderBy(c => c.Callsign) + .ThenBy(c => c.StationId) + .ToArray(); + + var logEntries = await _adscMessageLogRepository.All(cancellationToken); + + TotalPages = (int)Math.Ceiling(logEntries.Length / (double)PageSize); + PageNumber = Math.Clamp(PageNumber, 1, Math.Max(1, TotalPages)); + + LogEntries = logEntries + .Skip((PageNumber - 1) * PageSize) + .Take(PageSize) + .ToArray(); + } +} diff --git a/source/CPDLCServer/Pages/Shared/_Layout.cshtml b/source/CPDLCServer/Pages/Shared/_Layout.cshtml index e3d9df0..145d41a 100644 --- a/source/CPDLCServer/Pages/Shared/_Layout.cshtml +++ b/source/CPDLCServer/Pages/Shared/_Layout.cshtml @@ -16,6 +16,7 @@ Dashboard Connections Messages + ADS-C Documentation diff --git a/source/CPDLCServer/Persistence/IAdscMessageLogRepository.cs b/source/CPDLCServer/Persistence/IAdscMessageLogRepository.cs new file mode 100644 index 0000000..28b57b8 --- /dev/null +++ b/source/CPDLCServer/Persistence/IAdscMessageLogRepository.cs @@ -0,0 +1,11 @@ +using CPDLCServer.Model; + +namespace CPDLCServer.Persistence; + +public interface IAdscMessageLogRepository +{ + Task Add(AdscMessageLogEntry entry, CancellationToken cancellationToken); + + // Newest entries first + Task All(CancellationToken cancellationToken); +} diff --git a/source/CPDLCServer/Persistence/InMemoryAdscMessageLogRepository.cs b/source/CPDLCServer/Persistence/InMemoryAdscMessageLogRepository.cs new file mode 100644 index 0000000..0610566 --- /dev/null +++ b/source/CPDLCServer/Persistence/InMemoryAdscMessageLogRepository.cs @@ -0,0 +1,32 @@ +using CPDLCServer.Extensions; +using CPDLCServer.Model; + +namespace CPDLCServer.Persistence; + +public class InMemoryAdscMessageLogRepository : IAdscMessageLogRepository +{ + // The oldest entries are removed when the log is full + public const int Capacity = 1000; + + readonly SemaphoreSlim _semaphore = new(1, 1); + + private readonly Queue _entries = new(); + + public async Task Add(AdscMessageLogEntry entry, CancellationToken cancellationToken) + { + using (await _semaphore.LockAsync(cancellationToken)) + { + _entries.Enqueue(entry); + while (_entries.Count > Capacity) + _entries.Dequeue(); + } + } + + public async Task All(CancellationToken cancellationToken) + { + using (await _semaphore.LockAsync(cancellationToken)) + { + return _entries.Reverse().ToArray(); + } + } +} diff --git a/source/CPDLCServer/Program.cs b/source/CPDLCServer/Program.cs index bf00260..593dd90 100644 --- a/source/CPDLCServer/Program.cs +++ b/source/CPDLCServer/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); builder.Services.AddSignalR() .AddJsonProtocol(); From 5051ad97454d791158e978fd68a7d30d5cdc202c Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:19:07 +1000 Subject: [PATCH 15/33] Add example viewmodel --- .../ViewModels/ADSContractViewModel.cs | 25 +++++++++++++++++++ .../Windows/ADSContractWindow.xaml | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs index a6e4ff1..7d909a2 100644 --- a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs +++ b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs @@ -52,6 +52,31 @@ public ADSContractViewModel( } } + // Example data for the XAML designer. Do not use at runtime. + public ADSContractViewModel() + { + _plugin = null!; + _adscContractStore = null!; + _adscReportStore = null!; + _atsuSettingsStore = null!; + _mediator = null!; + _guiInvoker = null!; + _errorReporter = null!; + + _callsign = "QFA123"; + CurrentPeriodMinutes = 5; + LastReport = DateTimeOffset.UtcNow.AddMinutes(-2); + + DefaultPeriod = 10; + DefaultPeriodTransmissionState = AdscContractState.Accepted; + + SelectedPeriod = 5; + SelectedPeriodTransmissionState = AdscContractState.Sent; + SelectedPeriodEndTime = DateTimeOffset.UtcNow.AddMinutes(60); + + DemandContractTransmissionState = AdscContractState.Rejected; + } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CallsignButtonText))] string _callsign; diff --git a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml index 3ac2461..a0308bd 100644 --- a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml +++ b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml @@ -8,7 +8,7 @@ xmlns:controls="clr-namespace:CPDLCPlugin.Controls" xmlns:converters="clr-namespace:CPDLCPlugin.Converters" Background="{x:Static local:Theme.BackgroundColor}" - d:DataContext="{d:DesignInstance Type=viewModels:ADSContractViewModel}" + d:DataContext="{d:DesignInstance Type=viewModels:ADSContractViewModel, IsDesignTimeCreatable=True}" mc:Ignorable="d" Title="ADS CONTRACT WINDOW" Height="220" From 086f870b223f097b96448e6b83bc4e0abc4d5ebc Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:35:44 +1000 Subject: [PATCH 16/33] Tweak window --- .../ViewModels/ADSContractViewModel.cs | 2 +- .../Windows/ADSContractWindow.xaml | 47 ++++++++++--------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs index 7d909a2..362e065 100644 --- a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs +++ b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs @@ -63,7 +63,7 @@ public ADSContractViewModel() _guiInvoker = null!; _errorReporter = null!; - _callsign = "QFA123"; + _callsign = "QFA123AB"; CurrentPeriodMinutes = 5; LastReport = DateTimeOffset.UtcNow.AddMinutes(-2); diff --git a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml index a0308bd..42455cf 100644 --- a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml +++ b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml @@ -11,8 +11,8 @@ d:DataContext="{d:DesignInstance Type=viewModels:ADSContractViewModel, IsDesignTimeCreatable=True}" mc:Ignorable="d" Title="ADS CONTRACT WINDOW" - Height="220" - Width="640" + Height="Auto" + Width="Auto" SizeToContent="Height" ResizeMode="NoResize"> @@ -37,10 +37,10 @@ - - - - + + + + @@ -49,7 +49,7 @@ Margin="0,0,8,0" Text="ACID:"/> - + + Height="24"> @@ -86,36 +86,37 @@ + Height="24"> + - - - + + + - - + + @@ -132,10 +133,10 @@ + Height="24"> @@ -205,10 +206,10 @@ + Height="24"> @@ -228,7 +229,7 @@ From 636c8e76d9762db953afa84a86c51b7560e085bb Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:45:54 +1000 Subject: [PATCH 18/33] Fix window sizing --- source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs | 2 +- source/CPDLCPlugin/Windows/ADSContractWindow.xaml | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs index 362e065..679133c 100644 --- a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs +++ b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs @@ -64,7 +64,7 @@ public ADSContractViewModel() _errorReporter = null!; _callsign = "QFA123AB"; - CurrentPeriodMinutes = 5; + CurrentPeriodMinutes = 60; LastReport = DateTimeOffset.UtcNow.AddMinutes(-2); DefaultPeriod = 10; diff --git a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml index fa02ed6..0373bcb 100644 --- a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml +++ b/source/CPDLCPlugin/Windows/ADSContractWindow.xaml @@ -10,10 +10,8 @@ Background="{x:Static local:Theme.BackgroundColor}" d:DataContext="{d:DesignInstance Type=viewModels:ADSContractViewModel, IsDesignTimeCreatable=True}" mc:Ignorable="d" - Title="ADS CONTRACT WINDOW" - Height="Auto" - Width="Auto" - SizeToContent="Height" + Title="ADS CONTRACT" + SizeToContent="WidthAndHeight" ResizeMode="NoResize"> @@ -88,13 +86,13 @@ - + - + From 720bfc4420a0fb9aa63cb8130166a470a6b835a8 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:52:26 +1000 Subject: [PATCH 19/33] wip: Use vatSys forms --- .../Messages/OpenADSContractWindowRequest.cs | 11 ++-- ...ntractWindow.xaml => ADSContractView.xaml} | 15 ++--- ...Window.xaml.cs => ADSContractView.xaml.cs} | 7 +-- source/CPDLCPlugin/Windows/VatSysForm.cs | 48 ++++++++++++++++ source/CPDLCPlugin/Windows/WindowManager.cs | 57 +++++++++++++++++++ 5 files changed, 120 insertions(+), 18 deletions(-) rename source/CPDLCPlugin/Windows/{ADSContractWindow.xaml => ADSContractView.xaml} (97%) rename source/CPDLCPlugin/Windows/{ADSContractWindow.xaml.cs => ADSContractView.xaml.cs} (89%) create mode 100644 source/CPDLCPlugin/Windows/VatSysForm.cs diff --git a/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs b/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs index 463f7f1..df3c0c9 100644 --- a/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs +++ b/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs @@ -28,11 +28,13 @@ public Task Handle(OpenADSContractWindowRequest request, CancellationToken cance throw new Exception("Cannot open the ADS Contract window: not connected to the CPDLC server"); } - windowManager.FocusOrCreateWindow( + ADSContractViewModel? viewModel = null; + windowManager.FocusOrCreateForm( WindowKeys.ADSContract, + "ADS CONTRACT WINDOW", windowHandle => { - var viewModel = new ADSContractViewModel( + viewModel = new ADSContractViewModel( plugin, adscContractStore, adscReportStore, @@ -42,8 +44,9 @@ public Task Handle(OpenADSContractWindowRequest request, CancellationToken cance errorReporter, request.Callsign); - return new ADSContractWindow(viewModel); - }); + return new ADSContractView(viewModel); + }, + onClosed: () => viewModel?.Dispose()); return Task.CompletedTask; } diff --git a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml b/source/CPDLCPlugin/Windows/ADSContractView.xaml similarity index 97% rename from source/CPDLCPlugin/Windows/ADSContractWindow.xaml rename to source/CPDLCPlugin/Windows/ADSContractView.xaml index fa02ed6..4065150 100644 --- a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml +++ b/source/CPDLCPlugin/Windows/ADSContractView.xaml @@ -1,4 +1,4 @@ - - + mc:Ignorable="d"> + @@ -59,7 +54,7 @@ - + @@ -195,4 +190,4 @@ - + diff --git a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml.cs b/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs similarity index 89% rename from source/CPDLCPlugin/Windows/ADSContractWindow.xaml.cs rename to source/CPDLCPlugin/Windows/ADSContractView.xaml.cs index e8abe4a..04dc19f 100644 --- a/source/CPDLCPlugin/Windows/ADSContractWindow.xaml.cs +++ b/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs @@ -1,20 +1,19 @@ using System.Windows; +using System.Windows.Controls; using System.Windows.Input; using CPDLCPlugin.ViewModels; namespace CPDLCPlugin.Windows; -public partial class ADSContractWindow : Window +public partial class ADSContractView : UserControl { readonly ADSContractViewModel _viewModel; - public ADSContractWindow(ADSContractViewModel viewModel) + public ADSContractView(ADSContractViewModel viewModel) { InitializeComponent(); _viewModel = viewModel; DataContext = viewModel; - - Closed += (_, _) => _viewModel.Dispose(); } void CallsignButton_Click(object sender, RoutedEventArgs e) diff --git a/source/CPDLCPlugin/Windows/VatSysForm.cs b/source/CPDLCPlugin/Windows/VatSysForm.cs new file mode 100644 index 0000000..45e41e8 --- /dev/null +++ b/source/CPDLCPlugin/Windows/VatSysForm.cs @@ -0,0 +1,48 @@ +using System.Windows; +using System.Windows.Forms; +using System.Windows.Forms.Integration; +using vatsys; + +namespace CPDLCPlugin.Windows; + +// A vatSys form that hosts WPF content +public class VatSysForm : BaseForm +{ + public VatSysForm(string title, Func createView, bool shrinkToContent) + { + MiddleClickClose = false; + + Text = title; + + var child = createView(new FormWindowHandle(this)); + + var elementHost = new ElementHost(); + if (shrinkToContent) + { + // Measure with a maximum width so that text can wrap + const int maxWidth = 1000; + child.Measure(new Size(maxWidth, double.PositiveInfinity)); + child.Arrange(new Rect(child.DesiredSize)); + child.UpdateLayout(); + + var desired = child.DesiredSize; + elementHost.Child = child; + elementHost.Size = new System.Drawing.Size((int)Math.Ceiling(desired.Width), (int)Math.Ceiling(desired.Height)); + elementHost.Location = new System.Drawing.Point(0, 0); + + FormBorderStyle = FormBorderStyle.FixedDialog; + ClientSize = elementHost.Size; + } + else + { + elementHost.Child = child; + elementHost.Dock = DockStyle.Fill; + } + + Controls.Add(elementHost); + + Padding = new Padding(0); + Margin = new Padding(0); + PerformLayout(); + } +} diff --git a/source/CPDLCPlugin/Windows/WindowManager.cs b/source/CPDLCPlugin/Windows/WindowManager.cs index 7b171c7..f9e3956 100644 --- a/source/CPDLCPlugin/Windows/WindowManager.cs +++ b/source/CPDLCPlugin/Windows/WindowManager.cs @@ -1,4 +1,5 @@ using System.Windows; +using System.Windows.Forms; using System.Windows.Forms.Integration; namespace CPDLCPlugin.Windows; @@ -6,8 +7,50 @@ namespace CPDLCPlugin.Windows; public class WindowManager(IGuiInvoker guiInvoker) { readonly IDictionary _windows = new Dictionary(); + readonly IDictionary _forms = new Dictionary(); readonly object _gate = new(); + public void FocusOrCreateForm( + string key, + string title, + Func createView, + Action? onClosed = null) + { + guiInvoker.InvokeOnGUI(mainForm => + { + try + { + lock (_gate) + { + if (_forms.TryGetValue(key, out var existingForm)) + { + existingForm.WindowState = FormWindowState.Normal; + existingForm.Activate(); + return; + } + + var form = new VatSysForm(title, createView, shrinkToContent: true) + { + StartPosition = FormStartPosition.CenterParent + }; + + form.Closed += (_, _) => + { + TryRemoveFormInternal(key); + onClosed?.Invoke(); + }; + form.Show(mainForm); + + _forms[key] = form; + } + } + catch (Exception ex) + { + Plugin.AddError(ex, "Failed to open window"); + } + }); + } + public void FocusOrCreateWindow( string key, Func createWindow) @@ -52,6 +95,12 @@ public void TryRemoveWindow(string key) { lock (_gate) { + if (_forms.TryGetValue(key, out var form)) + { + form.Close(); + return; + } + if (!_windows.TryGetValue(key, out var window)) return; @@ -74,4 +123,12 @@ void TryRemoveWindowInternal(string key) _windows.Remove(key); } } + + void TryRemoveFormInternal(string key) + { + lock (_gate) + { + _forms.Remove(key); + } + } } From 61784b87786c9c7f8aa112bc4af5d048b8033ea9 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:56:18 +1000 Subject: [PATCH 20/33] Fix window sizing --- source/CPDLCPlugin/Windows/ADSContractView.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/CPDLCPlugin/Windows/ADSContractView.xaml b/source/CPDLCPlugin/Windows/ADSContractView.xaml index 4065150..f71847b 100644 --- a/source/CPDLCPlugin/Windows/ADSContractView.xaml +++ b/source/CPDLCPlugin/Windows/ADSContractView.xaml @@ -83,13 +83,13 @@ - + - + From 86d25b3338d53a5d1ee0360e2d7587f216271653 Mon Sep 17 00:00:00 2001 From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:08:58 +1000 Subject: [PATCH 21/33] wip: use vatsys forms --- source/CPDLCPlugin/IWindowHandle.cs | 34 +++----- .../Messages/OpenADSContractWindowRequest.cs | 13 +-- .../OpenCurrentMessagesWindowRequest.cs | 10 ++- .../Messages/OpenEditorWindowRequest.cs | 3 +- .../Messages/OpenHistoryWindowRequest.cs | 12 ++- .../Messages/OpenSetupWindowRequest.cs | 3 +- .../Windows/ADSContractView.xaml.cs | 4 +- ...esWindow.xaml => CurrentMessagesView.xaml} | 13 ++- ...ow.xaml.cs => CurrentMessagesView.xaml.cs} | 6 +- .../{EditorWindow.xaml => EditorView.xaml} | 27 +++---- ...ditorWindow.xaml.cs => EditorView.xaml.cs} | 14 ++-- .../{HistoryWindow.xaml => HistoryView.xaml} | 13 ++- ...toryWindow.xaml.cs => HistoryView.xaml.cs} | 27 ++----- .../{SetupWindow.xaml => SetupView.xaml} | 13 ++- ...{SetupWindow.xaml.cs => SetupView.xaml.cs} | 6 +- source/CPDLCPlugin/Windows/VatSysForm.cs | 59 +++++++++----- source/CPDLCPlugin/Windows/WindowManager.cs | 79 +++---------------- 17 files changed, 145 insertions(+), 191 deletions(-) rename source/CPDLCPlugin/Windows/{CurrentMessagesWindow.xaml => CurrentMessagesView.xaml} (97%) rename source/CPDLCPlugin/Windows/{CurrentMessagesWindow.xaml.cs => CurrentMessagesView.xaml.cs} (97%) rename source/CPDLCPlugin/Windows/{EditorWindow.xaml => EditorView.xaml} (97%) rename source/CPDLCPlugin/Windows/{EditorWindow.xaml.cs => EditorView.xaml.cs} (94%) rename source/CPDLCPlugin/Windows/{HistoryWindow.xaml => HistoryView.xaml} (97%) rename source/CPDLCPlugin/Windows/{HistoryWindow.xaml.cs => HistoryView.xaml.cs} (75%) rename source/CPDLCPlugin/Windows/{SetupWindow.xaml => SetupView.xaml} (95%) rename source/CPDLCPlugin/Windows/{SetupWindow.xaml.cs => SetupView.xaml.cs} (53%) diff --git a/source/CPDLCPlugin/IWindowHandle.cs b/source/CPDLCPlugin/IWindowHandle.cs index 3e6e0ff..db03c65 100644 --- a/source/CPDLCPlugin/IWindowHandle.cs +++ b/source/CPDLCPlugin/IWindowHandle.cs @@ -1,39 +1,31 @@ -using System.Windows; -using System.Windows.Forms; +using System.Windows.Forms; namespace CPDLCPlugin; public interface IWindowHandle { + event EventHandler? Closed; + event EventHandler? Moved; + void Close(); } -public class WpfWindowHandle : IWindowHandle +public class FormWindowHandle : IWindowHandle { - Window? Window { get; set; } + readonly Form _form; - public void SetWindow(Window window) - { - Window = window; - } - - public void Close() + public FormWindowHandle(Form form) { - Window?.Close(); + _form = form; + _form.FormClosed += (_, _) => Closed?.Invoke(this, EventArgs.Empty); + _form.Move += (_, _) => Moved?.Invoke(this, EventArgs.Empty); } -} - -public class FormWindowHandle(Form? form = null) : IWindowHandle -{ - Form? Form { get; set; } = form; - public void SetForm(Form form) - { - Form = form; - } + public event EventHandler? Closed; + public event EventHandler? Moved; public void Close() { - Form?.Close(); + _form.Close(); } } diff --git a/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs b/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs index df3c0c9..1d39614 100644 --- a/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs +++ b/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs @@ -28,13 +28,12 @@ public Task Handle(OpenADSContractWindowRequest request, CancellationToken cance throw new Exception("Cannot open the ADS Contract window: not connected to the CPDLC server"); } - ADSContractViewModel? viewModel = null; - windowManager.FocusOrCreateForm( + windowManager.FocusOrCreateWindow( WindowKeys.ADSContract, "ADS CONTRACT WINDOW", windowHandle => { - viewModel = new ADSContractViewModel( + var viewModel = new ADSContractViewModel( plugin, adscContractStore, adscReportStore, @@ -44,9 +43,13 @@ public Task Handle(OpenADSContractWindowRequest request, CancellationToken cance errorReporter, request.Callsign); - return new ADSContractView(viewModel); + return new ADSContractView(viewModel, windowHandle); }, - onClosed: () => viewModel?.Dispose()); + configureForm: form => + { + form.HasMinimizeButton = false; + form.MinimizeBox = false; + }); return Task.CompletedTask; } diff --git a/source/CPDLCPlugin/Messages/OpenCurrentMessagesWindowRequest.cs b/source/CPDLCPlugin/Messages/OpenCurrentMessagesWindowRequest.cs index 3bbfc1a..abfab4d 100644 --- a/source/CPDLCPlugin/Messages/OpenCurrentMessagesWindowRequest.cs +++ b/source/CPDLCPlugin/Messages/OpenCurrentMessagesWindowRequest.cs @@ -1,6 +1,7 @@ using CPDLCPlugin.Configuration; using CPDLCPlugin.ViewModels; using CPDLCPlugin.Windows; +using System.Drawing; using MediatR; namespace CPDLCPlugin.Messages; @@ -21,6 +22,7 @@ public Task Handle(OpenCurrentMessagesWindowRequest request, CancellationToken c { windowManager.FocusOrCreateWindow( WindowKeys.CurrentMessages, + "Current Messages", windowHandle => { var viewModel = new CurrentMessagesViewModel( @@ -32,7 +34,13 @@ public Task Handle(OpenCurrentMessagesWindowRequest request, CancellationToken c windowHandle, jurisdictionChecker); - return new CurrentMessagesWindow(viewModel); + return new CurrentMessagesView(viewModel, windowHandle); + }, + sizeToContent: false, + configureForm: form => + { + form.Size = new Size(467, 320); + form.MinimumSize = new Size(140, 24); }); return Task.CompletedTask; diff --git a/source/CPDLCPlugin/Messages/OpenEditorWindowRequest.cs b/source/CPDLCPlugin/Messages/OpenEditorWindowRequest.cs index f7e52fb..5e1924f 100644 --- a/source/CPDLCPlugin/Messages/OpenEditorWindowRequest.cs +++ b/source/CPDLCPlugin/Messages/OpenEditorWindowRequest.cs @@ -27,6 +27,7 @@ public Task Handle(OpenEditorWindowRequest request, CancellationToken cancellati windowManager.FocusOrCreateWindow( WindowKeys.Editor, + request.Callsign, windowHandle => { var viewModel = new EditorViewModel( @@ -40,7 +41,7 @@ public Task Handle(OpenEditorWindowRequest request, CancellationToken cancellati windowHandle, logger); - var control = new EditorWindow(viewModel); + var control = new EditorView(viewModel, windowHandle); return control; }); diff --git a/source/CPDLCPlugin/Messages/OpenHistoryWindowRequest.cs b/source/CPDLCPlugin/Messages/OpenHistoryWindowRequest.cs index 2f60c79..c9044e9 100644 --- a/source/CPDLCPlugin/Messages/OpenHistoryWindowRequest.cs +++ b/source/CPDLCPlugin/Messages/OpenHistoryWindowRequest.cs @@ -1,6 +1,7 @@ using CPDLCPlugin.Configuration; using CPDLCPlugin.ViewModels; using CPDLCPlugin.Windows; +using System.Drawing; using MediatR; namespace CPDLCPlugin.Messages; @@ -19,6 +20,7 @@ public Task Handle(OpenHistoryWindowRequest request, CancellationToken cancellat { windowManager.FocusOrCreateWindow( WindowKeys.History, + "CPDLC History", windowHandle => { var viewModel = new HistoryViewModel( @@ -28,7 +30,15 @@ public Task Handle(OpenHistoryWindowRequest request, CancellationToken cancellat errorReporter, request.Callsign); - return new HistoryWindow(viewModel); + return new HistoryView(viewModel, windowHandle); + }, + sizeToContent: false, + configureForm: form => + { + // Only the height can change + form.Size = new Size(467, 320); + form.MinimumSize = new Size(467, 24); + form.MaximumSize = new Size(467, 0); }); return Task.CompletedTask; diff --git a/source/CPDLCPlugin/Messages/OpenSetupWindowRequest.cs b/source/CPDLCPlugin/Messages/OpenSetupWindowRequest.cs index 58b557e..298955a 100644 --- a/source/CPDLCPlugin/Messages/OpenSetupWindowRequest.cs +++ b/source/CPDLCPlugin/Messages/OpenSetupWindowRequest.cs @@ -19,6 +19,7 @@ public Task Handle(OpenSetupWindowRequest request, CancellationToken cancellatio { windowManager.FocusOrCreateWindow( WindowKeys.Setup, + "CPDLC Setup", windowHandle => { // Create the view model with current configuration and connection state @@ -32,7 +33,7 @@ public Task Handle(OpenSetupWindowRequest request, CancellationToken cancellatio isConnected ? plugin.ConnectionManager!.StationIdentifier : pluginConfiguration.Stations.First(), isConnected); - var control = new SetupWindow(viewModel); + var control = new SetupView(viewModel); return control; }); diff --git a/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs b/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs index 04dc19f..6ba2d60 100644 --- a/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs +++ b/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs @@ -9,11 +9,13 @@ public partial class ADSContractView : UserControl { readonly ADSContractViewModel _viewModel; - public ADSContractView(ADSContractViewModel viewModel) + public ADSContractView(ADSContractViewModel viewModel, IWindowHandle windowHandle) { InitializeComponent(); _viewModel = viewModel; DataContext = viewModel; + + windowHandle.Closed += (_, _) => _viewModel.Dispose(); } void CallsignButton_Click(object sender, RoutedEventArgs e) diff --git a/source/CPDLCPlugin/Windows/CurrentMessagesWindow.xaml b/source/CPDLCPlugin/Windows/CurrentMessagesView.xaml similarity index 97% rename from source/CPDLCPlugin/Windows/CurrentMessagesWindow.xaml rename to source/CPDLCPlugin/Windows/CurrentMessagesView.xaml index 27b5716..dac6d7a 100644 --- a/source/CPDLCPlugin/Windows/CurrentMessagesWindow.xaml +++ b/source/CPDLCPlugin/Windows/CurrentMessagesView.xaml @@ -1,4 +1,4 @@ - - + @@ -28,7 +25,7 @@ - + @@ -181,4 +178,4 @@ - + diff --git a/source/CPDLCPlugin/Windows/CurrentMessagesWindow.xaml.cs b/source/CPDLCPlugin/Windows/CurrentMessagesView.xaml.cs similarity index 97% rename from source/CPDLCPlugin/Windows/CurrentMessagesWindow.xaml.cs rename to source/CPDLCPlugin/Windows/CurrentMessagesView.xaml.cs index a38ce88..615c5cb 100644 --- a/source/CPDLCPlugin/Windows/CurrentMessagesWindow.xaml.cs +++ b/source/CPDLCPlugin/Windows/CurrentMessagesView.xaml.cs @@ -8,19 +8,19 @@ namespace CPDLCPlugin.Windows; -public partial class CurrentMessagesWindow : Window +public partial class CurrentMessagesView : UserControl { CurrentMessageViewModel? _selectedMessage; FrameworkElement? _extendedMessageAnchor; readonly CurrentMessagesViewModel _viewModel; - public CurrentMessagesWindow(CurrentMessagesViewModel viewModel) + public CurrentMessagesView(CurrentMessagesViewModel viewModel, IWindowHandle windowHandle) { InitializeComponent(); _viewModel = viewModel; DataContext = viewModel; - Closed += (_, _) => _viewModel.Dispose(); + windowHandle.Closed += (_, _) => _viewModel.Dispose(); // Wire up button click events StandbyButton.Click += (_, _) => ExecuteCommandAndClosePopup(vm => vm.SendStandbyCommand); diff --git a/source/CPDLCPlugin/Windows/EditorWindow.xaml b/source/CPDLCPlugin/Windows/EditorView.xaml similarity index 97% rename from source/CPDLCPlugin/Windows/EditorWindow.xaml rename to source/CPDLCPlugin/Windows/EditorView.xaml index a4c70b8..8732a63 100644 --- a/source/CPDLCPlugin/Windows/EditorWindow.xaml +++ b/source/CPDLCPlugin/Windows/EditorView.xaml @@ -1,4 +1,4 @@ - - + Width="640"> + @@ -28,7 +25,7 @@ - + @@ -43,7 +40,7 @@ - + @@ -60,7 +57,7 @@ - + @@ -110,7 +107,7 @@