diff --git a/build/Build.cs b/build/Build.cs index d914a8a..17651de 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -176,9 +176,15 @@ class Build : FalloutBuild .Executes(() => { var mainAssembly = PluginBuildOutputDirectory / PluginAssemblyFileName; + AbsolutePath[] nonMergedAssemblies = + [ + // ILRepack cannot merge Harmony, so it stays as a separate file next to the plugin + PluginBuildOutputDirectory / "0Harmony.dll" + ]; + var assembliesToMerge = PluginBuildOutputDirectory .GlobFiles("*.dll") - .Except([mainAssembly]) + .Except([mainAssembly, ..nonMergedAssemblies]) .ToArray(); if (!mainAssembly.FileExists()) diff --git a/docs/docs/concepts/index.md b/docs/docs/concepts/index.md index 693a0bc..54315e5 100644 --- a/docs/docs/concepts/index.md +++ b/docs/docs/concepts/index.md @@ -233,6 +233,75 @@ sequenceDiagram Note over YBBB,Pilot: Dialogue closes, connection terminates ``` +## ADS-C + +Automatic Dependent Surveillance - Contract (ADS-C) lets a ground station get position reports from an aircraft by data link. +Controllers use ADS-C to see aircraft that are outside radar coverage, such as aircraft over the ocean. + +The aircraft sends reports automatically. The pilot does not have to do anything. +Each report has the position, the altitude, and the time of the report. + +:::note +ADS-C and CPDLC are separate systems. An aircraft can have an ADS-C contract with a ground station without a CPDLC connection. +For convenience, this plugin connects ADS-C to CPDLC. The server requests an ADS-C contract when an aircraft logs on with CPDLC, and cancels the contract when the CPDLC connection ends. +::: + +### Contracts + +The ground station requests a **contract** with the aircraft. The contract tells the aircraft when to send reports. +The plugin uses these types of contract: + +- **Periodic contract:** The aircraft sends a report at a fixed interval, for example, each 10 minutes +- **Demand contract:** The aircraft sends one report immediately + +An aircraft has only one periodic contract with each ground station. A new periodic request replaces the previous one. + +:::note +Event contracts are not supported. +::: + +### Default and Selected Contracts + +Each ATSU has a **default reporting interval**. +When an aircraft logs on with CPDLC, the server automatically requests a periodic contract with the default reporting interval. This is the **default contract**. + +A controller can select a different reporting interval for an aircraft, for example, a shorter interval near a conflict. This is the **selected contract**. +A selected contract ends after a fixed time. Then the server requests the default contract again. + +```mermaid +sequenceDiagram + participant Pilot + participant ATSU + + Pilot->>ATSU: REQUEST LOGON + ATSU->>Pilot: LOGON ACCEPTED + ATSU->>Pilot: Periodic contract (default interval) + Pilot->>ATSU: Report + Note over ATSU,Pilot: Default contract accepted + ATSU->>Pilot: Periodic contract (selected interval) + Pilot->>ATSU: Report + Note over ATSU,Pilot: Selected contract accepted + Note over ATSU,Pilot: Selected contract ends + ATSU->>Pilot: Periodic contract (default interval) +``` + +When the CPDLC connection ends, the server cancels the contract. + +### Contract State + +Each request has one of these states: + +| State | Description | +| :---: | ----------- | +| Sent | The request was sent to the aircraft. The aircraft has not answered. | +| Accepted | The aircraft sent a report after the request. | +| Rejected | The aircraft rejected the request. | + +The aircraft does not acknowledge a request. The first report after a request accepts that request. + +If the aircraft rejects a selected contract, the server requests the default contract again. +If the aircraft rejects the default contract, the server does not request it again. + ## CPDLC on VATSIM VATSIM does not have an official CPDLC solution, though one is in development. In the meantime, CPDLC for flight simulation networks typically uses Hoppie's ACARS network. @@ -247,6 +316,9 @@ Stations connected to Hoppie's network poll for messages at regular intervals. Messages are not received instantly by the recipient. It may take up to one minute for a message to reach the pilot, and another minute for a response to return. +This delay also applies to ADS-C. A report can arrive up to one minute after its report time, and a demand report can take up to two minutes to arrive. +For this reason, the shortest reporting interval is one minute. + ### Aggregation Server Hoppie's network does not allow multiple controllers to use a single ATSU callsign. The aggregation server solves this problem by acting as a relay between controllers and the ACARS network, allowing multiple controllers to share a single ATSU connection. diff --git a/docs/docs/index.md b/docs/docs/index.md index 53464c7..b57e1b6 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -19,6 +19,7 @@ The system consists of two components: Multiple controllers can share a single ATSU connection, with messages automatically routed to the appropriate controller. **[vatSys Plugin](/vatsys-plugin/installation)** provides the controller interface within vatSys for sending and receiving CPDLC messages with aircraft. +It also shows ADS-C position reports on the ASD, and lets controllers change the ADS-C contract with an aircraft. ![Architecture Diagram](../static/diagram.png) diff --git a/docs/docs/server/index.md b/docs/docs/server/index.md index 8a5c450..3b9ce2b 100644 --- a/docs/docs/server/index.md +++ b/docs/docs/server/index.md @@ -46,6 +46,10 @@ 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 +# Minutes from the request of a selected ADS-C contract to its end. Then the default contract is requested again (default: 60) +Acars__0__SelectedAdscContractDuration=60 # Handoff Configuration # How many minutes before the expected transfer time to send the NEXT DATA AUTHORITY message (default: 10) @@ -78,6 +82,19 @@ 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 the default 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. + +A controller can select a different reporting interval for an aircraft. The selected contract ends `Acars____SelectedAdscContractDuration` minutes after the server sends the request. Then the server requests the default contract again. A controller can also go back to the default contract at any time. + +The aircraft does not acknowledge a request. The first report after a request accepts it. If the aircraft rejects a selected contract, the server requests the default contract again. If the aircraft rejects the default contract, the server does not send it again. + +The server keeps the latest report for each aircraft. When a controller connects, the plugin loads these reports, so that the controller sees the ADS-C tracks immediately. + +The ADS-C page of the dashboard (`/adsc`) shows all the contracts and their states, and a log of all the ADS-C messages that the server sent and received. ## Running ### Docker 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/docs/docs/vatsys-plugin/plugin.md b/docs/docs/vatsys-plugin/plugin.md index 6a0ec35..28cd0d3 100644 --- a/docs/docs/vatsys-plugin/plugin.md +++ b/docs/docs/vatsys-plugin/plugin.md @@ -5,7 +5,7 @@ sidebar_position: 2 # Plugin Guide This guide covers the CPDLC Plugin for vatSys. -It explains how to connect to the server, interpret track labels, and manage CPDLC messages with pilots. +It explains how to connect to the server, interpret track labels, manage CPDLC messages with pilots, and use ADS-C. ## Connecting to the CPDLC Server @@ -176,6 +176,72 @@ Enter a callsign in the ACID input to display that aircraft's message history. O Messages are displayed the same way as in the Current Messages Window. Messages prefixed with `M` were manually acknowledged by the controller. +## ADS-C + +The plugin shows ADS-C position reports on the ASD, and lets you change the ADS-C contract with an aircraft. +Refer to [ADS-C](/concepts#ads-c) for an explanation of contracts. + +You do not have to do anything to start ADS-C. When an aircraft logs on with CPDLC, the server automatically requests the default contract. + +### ADS-C Tracks + +![Screenshot of an ADS-C track on the ASD, next to the same track after extrapolation. Show the ADSC symbol and the ADSCExtrapolated symbol.](../../static/adsc_tracks.png) + +When a report arrives, the flight plan track of the aircraft moves to the reported position, and shows the `ADSC` symbol. +The reported altitude becomes the pilot reported level (PRL) of the flight plan. + +After a short time, the track moves along the flight plan route from the reported position, and shows the `ADSCExtrapolated` symbol. +The plugin uses the route estimates of the flight plan to calculate the speed of the track. +You can change this time with the [`ADSCTrackExtrapolationThreshold`](configuration#adsctrackextrapolationthreshold) setting. + +The vatSys profile sets how the symbols look, so the exact symbols may vary. +By default, the ADSC symbol is a filled triangle, and the ADSCExtrapolated is a hollow triangle. + +A radar track always has priority. When the flight plan is coupled to a radar track, the plugin does not move the track. + +### ADS Contract Window + +![Screenshot of the ADS Contract window with an aircraft loaded.](../../static/adsc_window.png) + +The ADS Contract Window shows the ADS-C contract that your ATSU has with an aircraft. You can use it to select a different reporting interval, or to request a demand report. + +To open the window, select a track, then open the CPDLC menu and select ADS Contract. The window shows the contract for the selected track. + +To show a different aircraft, click the ACID button, type the callsign, then press Enter. + +The window shows: + +- **Current Period:** The reporting interval of the latest periodic contract, in minutes +- **Last Report:** The time of the latest report from the aircraft +- **Default Period:** The default reporting interval of your ATSU, in minutes +- **Selected Period:** The reporting interval that a controller selected, in minutes. `DEF` shows that the default period is selected. +- **Selected Period End:** The time when the selected contract ends. Then the server requests the default contract again. + +A symbol next to each contract shows the state of the request: + +- `→`: The request was sent. The aircraft has not answered. +- `✓`: The aircraft accepted the request. +- `×`: The aircraft rejected the request. + +#### Selecting a Reporting Interval + +1. Click the Selected Period button. A list of periods opens. +2. Click a period in the list, or type a period (in minutes) in the box at the bottom of the list, then press Enter. + +The server sends the request to the aircraft immediately. +The selected contract ends after a time that the server sets (60 minutes by default). + +To go back to the default contract before the selected contract ends, click `DEF` in the list. + +#### Requesting a Demand Report + +Click Demand Contract to request one report from the aircraft immediately. +A demand report does not change the periodic contract. + +:::tip +Because of the delays on Hoppie's network, a demand report can take up to two minutes to arrive. +::: + ## Voice Capability Indicators ![Screenshot of voice capability label items](../../static/text_label_items.png) @@ -220,7 +286,7 @@ This ensures pilots can acknowledge frequency change instructions before losing The plugin does not fully simulate all aspects of real-world CPDLC. -**ADS-C:** ADS-C position reporting is not simulated due to limitations within the vatSys plugin API. +**ADS-C:** Only periodic and demand contracts are supported. Event contracts are not supported. **Equipment codes:** Aircraft equipment codes are ignored. The `.` symbol indicates the aircraft is connected to an ACARS network, regardless of their filed equipment. diff --git a/docs/static/adsc_tracks.png b/docs/static/adsc_tracks.png new file mode 100644 index 0000000..c21354a Binary files /dev/null and b/docs/static/adsc_tracks.png differ diff --git a/docs/static/adsc_window.png b/docs/static/adsc_window.png new file mode 100644 index 0000000..046b64b Binary files /dev/null and b/docs/static/adsc_window.png differ diff --git a/example.env b/example.env index 3f70675..cf35d96 100644 --- a/example.env +++ b/example.env @@ -13,6 +13,10 @@ 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 +# Minutes from the request of a selected ADS-C contract to its end. Then the default contract is requested again (default: 60) +Acars__0__SelectedAdscContractDuration=60 # 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/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/AdscReportStore.cs b/source/CPDLCPlugin/AdscReportStore.cs new file mode 100644 index 0000000..d2d0681 --- /dev/null +++ b/source/CPDLCPlugin/AdscReportStore.cs @@ -0,0 +1,90 @@ +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 Populate(AdscReportDto[] reports, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + _latestReports.Clear(); + foreach (var report in reports) + { + _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/AdscTrackDisplay.cs b/source/CPDLCPlugin/AdscTrackDisplay.cs new file mode 100644 index 0000000..a3461d8 --- /dev/null +++ b/source/CPDLCPlugin/AdscTrackDisplay.cs @@ -0,0 +1,100 @@ +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, 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, 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 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, clock.UtcNow(), Extrapolated: false); + var shownTrack = _tracks.AddOrUpdate( + report.Callsign, + 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); + + if (fdr.CoupledTrack is null) + fdr.PredictedPosition.Location = new Coordinate(report.Latitude, report.Longitude); + } + + public void Clear() + { + var callsigns = _tracks.Keys.ToArray(); + _tracks.Clear(); + + foreach (var callsign in callsigns) + RestoreFlightPlanPosition(callsign); + } + + public bool TryGetTrack(string callsign, out AdscTrack track) + { + return _tracks.TryGetValue(callsign, out 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 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); + + 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 new file mode 100644 index 0000000..6854f1e --- /dev/null +++ b/source/CPDLCPlugin/AdscTrackPatches.cs @@ -0,0 +1,101 @@ +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: +/// +/// 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. +/// +/// A coupled radar track always has priority over an ADS-C report. +/// +public static class AdscTrackPatches +{ + const string HarmonyId = "CPDLCPlugin.AdscTracks"; + + static AdscTrackDisplay? _display; + + // 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) + { + _display = display; + + 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 || type != TrackSymbols.Types.FlightPlan) + return; + + var paintedTrack = _trackBeingPainted; + if (paintedTrack is null || paintedTrack.Type != Track.TrackTypes.TRACK_TYPE_FP) + return; + + var fdr = paintedTrack.GetFDR(); + if (fdr is null || !_display.TryGetTrack(fdr.Callsign, out var adscTrack)) + return; + + type = adscTrack.Extrapolated + ? 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; + + var position = _display.Extrapolate(fdr); + if (position is not null) + fdr.PredictedPosition.Location = position; + } +} 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/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..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; } diff --git a/source/CPDLCPlugin/Converters/AdscContractStateToSymbolConverter.cs b/source/CPDLCPlugin/Converters/AdscContractStateToSymbolConverter.cs new file mode 100644 index 0000000..35805ea --- /dev/null +++ b/source/CPDLCPlugin/Converters/AdscContractStateToSymbolConverter.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using System.Windows.Data; +using CPDLCServer.Contracts; + +namespace CPDLCPlugin.Converters; + +[ValueConversion(typeof(AdscContractState), typeof(string))] +public class AdscContractStateToSymbolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value switch + { + AdscContractState.Sent => "→", + AdscContractState.Accepted => "✓", + AdscContractState.Rejected => "×", + _ => string.Empty + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} 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/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/AdscContractRemovedNotification.cs b/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs new file mode 100644 index 0000000..9105604 --- /dev/null +++ b/source/CPDLCPlugin/Messages/AdscContractRemovedNotification.cs @@ -0,0 +1,25 @@ +using CommunityToolkit.Mvvm.Messaging; +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); + } + + WeakReferenceMessenger.Default.Send(notification); + } +} diff --git a/source/CPDLCPlugin/Messages/AdscContractUpdatedNotification.cs b/source/CPDLCPlugin/Messages/AdscContractUpdatedNotification.cs new file mode 100644 index 0000000..98d3395 --- /dev/null +++ b/source/CPDLCPlugin/Messages/AdscContractUpdatedNotification.cs @@ -0,0 +1,23 @@ +using CommunityToolkit.Mvvm.Messaging; +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); + + WeakReferenceMessenger.Default.Send(notification); + } +} diff --git a/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs b/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs new file mode 100644 index 0000000..d4cc10f --- /dev/null +++ b/source/CPDLCPlugin/Messages/AdscReportReceivedNotification.cs @@ -0,0 +1,34 @@ +using CommunityToolkit.Mvvm.Messaging; +using CPDLCServer.Contracts; +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record AdscReportReceivedNotification(AdscReportDto AdscReportDto) : INotification; + +public class AdscReportReceivedNotificationHandler( + AdscReportStore adscReportStore, + AdscTrackDisplay adscTrackDisplay, + 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); + + adscTrackDisplay.Show(report); + + WeakReferenceMessenger.Default.Send(notification); + } +} diff --git a/source/CPDLCPlugin/Messages/ConnectedNotification.cs b/source/CPDLCPlugin/Messages/ConnectedNotification.cs index 139e491..f23444e 100644 --- a/source/CPDLCPlugin/Messages/ConnectedNotification.cs +++ b/source/CPDLCPlugin/Messages/ConnectedNotification.cs @@ -11,6 +11,10 @@ public class ConnectedNotificationHandler( DialogueStore dialogueStore, AircraftConnectionStore aircraftConnectionStore, AcarsStationStore acarsStationStore, + AdscContractStore adscContractStore, + AdscReportStore adscReportStore, + AdscTrackDisplay adscTrackDisplay, + AtsuSettingsStore atsuSettingsStore, ILogger logger) : INotificationHandler { @@ -23,6 +27,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 +54,23 @@ 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); + + // Load ADS-C reports + logger.Verbose("Loading all ADS-C reports"); + var adscReports = await plugin.ConnectionManager.GetAdscReports(cancellationToken); + await adscReportStore.Populate(adscReports, cancellationToken); + foreach (var adscReport in adscReports) + { + adscTrackDisplay.Show(adscReport); + } + + logger.Information("Loaded {ReportCount} ADS-C report(s)", adscReports.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..9afc7e2 100644 --- a/source/CPDLCPlugin/Messages/DisconnectedNotification.cs +++ b/source/CPDLCPlugin/Messages/DisconnectedNotification.cs @@ -10,6 +10,10 @@ public class DisconnectedNotificationBridge( DialogueStore dialogueStore, AircraftConnectionStore aircraftConnectionStore, AcarsStationStore acarsStationStore, + AdscContractStore adscContractStore, + AdscReportStore adscReportStore, + AdscTrackDisplay adscTrackDisplay, + AtsuSettingsStore atsuSettingsStore, ILogger logger) : INotificationHandler { public async Task Handle(DisconnectedNotification notification, CancellationToken cancellationToken) @@ -23,6 +27,18 @@ 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 ADS-C tracks"); + adscTrackDisplay.Clear(); + + logger.Information("Clearing ATSU settings"); + atsuSettingsStore.Clear(); + WeakReferenceMessenger.Default.Send(notification); } } diff --git a/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs b/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs new file mode 100644 index 0000000..29e2438 --- /dev/null +++ b/source/CPDLCPlugin/Messages/OpenADSContractWindowRequest.cs @@ -0,0 +1,56 @@ +using CPDLCPlugin.ViewModels; +using CPDLCPlugin.Windows; +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +public record OpenADSContractWindowRequest(string? Callsign = null) : IRequest; + +public class OpenADSContractWindowRequestHandler( + WindowManager windowManager, + Plugin plugin, + AdscContractStore adscContractStore, + AdscReportStore adscReportStore, + AtsuSettingsStore atsuSettingsStore, + IMediator mediator, + IGuiInvoker guiInvoker, + IErrorReporter errorReporter, + ILogger logger) + : IRequestHandler +{ + public Task Handle(OpenADSContractWindowRequest request, CancellationToken cancellationToken) + { + // The window shows and changes contracts that the server holds + if (plugin.ConnectionManager is null || !plugin.ConnectionManager.IsConnected) + { + logger.Warning("Cannot open the ADS Contract window: not connected to server"); + throw new Exception("Cannot open the ADS Contract window: not connected to the CPDLC server"); + } + + windowManager.FocusOrCreateWindow( + WindowKeys.ADSContract, + "ADS Contract", + windowHandle => + { + var viewModel = new ADSContractViewModel( + plugin, + adscContractStore, + adscReportStore, + atsuSettingsStore, + mediator, + guiInvoker, + errorReporter, + request.Callsign); + + return new ADSContractView(viewModel, windowHandle); + }, + 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..cbb69ea 100644 --- a/source/CPDLCPlugin/Messages/OpenHistoryWindowRequest.cs +++ b/source/CPDLCPlugin/Messages/OpenHistoryWindowRequest.cs @@ -1,6 +1,8 @@ using CPDLCPlugin.Configuration; using CPDLCPlugin.ViewModels; using CPDLCPlugin.Windows; +using System.Drawing; +using System.Windows.Forms; using MediatR; namespace CPDLCPlugin.Messages; @@ -19,6 +21,7 @@ public Task Handle(OpenHistoryWindowRequest request, CancellationToken cancellat { windowManager.FocusOrCreateWindow( WindowKeys.History, + "CPDLC History", windowHandle => { var viewModel = new HistoryViewModel( @@ -28,7 +31,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, SystemInformation.VirtualScreen.Height); }); 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/Messages/RequestAdscDemandReportRequest.cs b/source/CPDLCPlugin/Messages/RequestAdscDemandReportRequest.cs new file mode 100644 index 0000000..4f69157 --- /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("Cannot request ADS-C demand report for {Callsign}: not connected to server", request.Callsign); + return; + } + + await plugin.ConnectionManager.RequestAdscDemandReport(request.Callsign, cancellationToken); + logger.Information("Requested ADS-C demand report for {Callsign}", request.Callsign); + } +} diff --git a/source/CPDLCPlugin/Messages/SelectAdscReportingIntervalRequest.cs b/source/CPDLCPlugin/Messages/SelectAdscReportingIntervalRequest.cs new file mode 100644 index 0000000..bcfe059 --- /dev/null +++ b/source/CPDLCPlugin/Messages/SelectAdscReportingIntervalRequest.cs @@ -0,0 +1,38 @@ +using MediatR; +using Serilog; + +namespace CPDLCPlugin.Messages; + +// A null reporting interval ends the selected contract and requests the default contract again +public record SelectAdscReportingIntervalRequest(string Callsign, TimeSpan? ReportingInterval) : IRequest; + +public class SelectAdscReportingIntervalRequestHandler( + Plugin plugin, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(SelectAdscReportingIntervalRequest request, CancellationToken cancellationToken) + { + logger.Verbose( + "Selecting ADS-C reporting interval {ReportingInterval} for {Callsign}", + request.ReportingInterval?.ToString() ?? "Default", + request.Callsign); + if (plugin.ConnectionManager is null || !plugin.ConnectionManager.IsConnected) + { + logger.Warning( + "Cannot select ADS-C reporting interval {ReportingInterval} for {Callsign}: not connected to server", + request.ReportingInterval?.ToString() ?? "Default", + request.Callsign); + return; + } + + await plugin.ConnectionManager.RequestPeriodicAdscContract( + request.Callsign, + request.ReportingInterval, + cancellationToken); + logger.Information( + "Selected ADS-C reporting interval {ReportingInterval} for {Callsign}", + request.ReportingInterval?.ToString() ?? "Default", + request.Callsign); + } +} diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs index 6bc66ac..581fed4 100644 --- a/source/CPDLCPlugin/Plugin.cs +++ b/source/CPDLCPlugin/Plugin.cs @@ -69,6 +69,9 @@ public Plugin() _configuration = configuration; ConfigureServices(configuration); + ApplyAdscTrackPatches(); + ApplyFakeAdscReportPatch(); + AddToolbarItems(); CheckXmlInstallation(); @@ -96,6 +99,10 @@ void ConfigureServices(PluginConfiguration pluginConfiguration) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -123,6 +130,63 @@ ILogger ConfigureLogger(PluginConfiguration configuration) return logger; } + void ApplyAdscTrackPatches() + { + try + { + AdscTrackPatches.Apply(ServiceProvider.GetRequiredService()); + } + catch (Exception ex) + { + AddError(ex, "Cannot show ADS-C tracks on the ASD"); + } + } + + 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); @@ -335,6 +399,52 @@ void AddToolbarItems() MMI.AddCustomMenuItem(historyMenuItem); + var adsContractMenuItem = new CustomToolStripMenuItem( + CustomToolStripMenuItemWindowType.Main, + menuItemCategory, + new ToolStripMenuItem("ADS Contract")); + adsContractMenuItem.Item.Click += (_, _) => + { + _workQueue.Enqueue(async () => + { + var selectedCallsign = MMI.SelectedTrack?.GetFDR()?.Callsign; + + var mediator = ServiceProvider.GetRequiredService(); + await mediator.Send(new OpenADSContractWindowRequest(selectedCallsign)); + }); + }; + + MMI.AddCustomMenuItem(adsContractMenuItem); + +#if DEBUG + var editorMenuItem = new CustomToolStripMenuItem( + CustomToolStripMenuItemWindowType.Main, + menuItemCategory, + new ToolStripMenuItem("CPDLC Editor")); + editorMenuItem.Item.Click += (_, _) => + { + _workQueue.Enqueue(async () => + { + var selectedCallsign = MMI.SelectedTrack?.GetFDR()?.Callsign; + if (selectedCallsign is null) + throw new InvalidOperationException("Select a track with a flight plan before you open the CPDLC Editor"); + + var mediator = ServiceProvider.GetRequiredService(); + await mediator.Send(new OpenEditorWindowRequest(selectedCallsign)); + }); + }; + + MMI.AddCustomMenuItem(editorMenuItem); + + var fakeAdscReportMenuItem = new CustomToolStripMenuItem( + CustomToolStripMenuItemWindowType.Main, + menuItemCategory, + new ToolStripMenuItem("Fake ADS-C Report")); + fakeAdscReportMenuItem.Item.Click += (_, _) => FakeAdscReportPatch.Arm(); + + MMI.AddCustomMenuItem(fakeAdscReportMenuItem); +#endif + if (_configuration?.ShowInstallationMenuItems ?? true) { var status = XmlInstaller.GetStatus(); 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..618d1fb 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,56 @@ await connection.InvokeAsync( cancellationToken: cancellationToken); } + public async Task GetAtsuSettings(CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + return await connection.InvokeAsync( + "GetAtsuSettings", + cancellationToken); + } + + // A null reporting interval ends the selected contract and requests the default contract again + 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 GetAdscReports(CancellationToken cancellationToken) + { + var connection = GetConnectedOrThrow(); + return await connection.InvokeAsync( + "GetAdscReports", + cancellationToken); + } + public async Task GetAllDialogues(CancellationToken cancellationToken) { var connection = GetConnectedOrThrow(); diff --git a/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs new file mode 100644 index 0000000..679133c --- /dev/null +++ b/source/CPDLCPlugin/ViewModels/ADSContractViewModel.cs @@ -0,0 +1,256 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using CPDLCPlugin.Messages; +using CPDLCServer.Contracts; +using MediatR; + +namespace CPDLCPlugin.ViewModels; + +public partial class ADSContractViewModel : ObservableObject, + IRecipient, + IRecipient, + IRecipient, + IDisposable +{ + readonly Plugin _plugin; + readonly AdscContractStore _adscContractStore; + readonly AdscReportStore _adscReportStore; + readonly AtsuSettingsStore _atsuSettingsStore; + readonly IMediator _mediator; + readonly IGuiInvoker _guiInvoker; + readonly IErrorReporter _errorReporter; + bool _disposed; + + public ADSContractViewModel( + Plugin plugin, + AdscContractStore adscContractStore, + AdscReportStore adscReportStore, + AtsuSettingsStore atsuSettingsStore, + IMediator mediator, + IGuiInvoker guiInvoker, + IErrorReporter errorReporter, + string? initialCallsign = null) + { + _plugin = plugin; + _adscContractStore = adscContractStore; + _adscReportStore = adscReportStore; + _atsuSettingsStore = atsuSettingsStore; + _mediator = mediator; + _guiInvoker = guiInvoker; + _errorReporter = errorReporter; + + _callsign = initialCallsign ?? string.Empty; + + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + + if (!string.IsNullOrWhiteSpace(initialCallsign)) + { + _ = LoadContractAsync(); + } + } + + // 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 = "QFA123AB"; + CurrentPeriodMinutes = 60; + 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; + + public string CallsignButtonText => string.IsNullOrWhiteSpace(Callsign) ? "ACID" : Callsign; + + [ObservableProperty] int? _currentPeriodMinutes; + [ObservableProperty] DateTimeOffset? _lastReport; + + [ObservableProperty] int? _defaultPeriod; + [ObservableProperty] AdscContractState? _defaultPeriodTransmissionState; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SelectedPeriodButtonText))] + int? _selectedPeriod; + + public string SelectedPeriodButtonText => SelectedPeriod?.ToString() ?? "DEF"; + + // The periods in minutes that a controller can select from the list. A controller can also type a different period. + public int[] PeriodOptions { get; } = [3, 5, 10, 15, 20, 24, 30, 60]; + + [ObservableProperty] AdscContractState? _selectedPeriodTransmissionState; + [ObservableProperty] DateTimeOffset? _selectedPeriodEndTime; + + [ObservableProperty] AdscContractState? _demandContractTransmissionState; + + async Task LoadContractAsync() + { + try + { + DefaultPeriod = (int?)_atsuSettingsStore.Settings?.DefaultAdscReportingInterval.TotalMinutes; + + if (string.IsNullOrWhiteSpace(Callsign)) + { + ShowContract(null); + LastReport = null; + return; + } + + // Show the contract that this station holds + var stationId = _plugin.ConnectionManager?.StationIdentifier; + var contract = (await _adscContractStore.All(CancellationToken.None)) + .FirstOrDefault(c => c.Callsign == Callsign && c.StationId == stationId); + ShowContract(contract); + + var report = await _adscReportStore.Find(Callsign, CancellationToken.None); + LastReport = report?.Timestamp; + } + catch (Exception ex) + { + _errorReporter.ReportError(ex); + } + } + + void ShowContract(AdscContractDto? contract) + { + CurrentPeriodMinutes = (int?)contract?.ReportingInterval?.TotalMinutes; + + DefaultPeriodTransmissionState = contract?.DefaultContractState; + + SelectedPeriod = (int?)contract?.SelectedReportingInterval?.TotalMinutes; + SelectedPeriodTransmissionState = contract?.SelectedContractState; + SelectedPeriodEndTime = contract?.SelectedContractEnds; + + DemandContractTransmissionState = contract?.DemandContractState; + } + + // A null period requests the default contract + [RelayCommand] + async Task SelectPeriod(int? minutes) + { + try + { + if (string.IsNullOrWhiteSpace(Callsign)) + return; + + var reportingInterval = minutes is null ? (TimeSpan?)null : TimeSpan.FromMinutes(minutes.Value); + await _mediator.Send(new SelectAdscReportingIntervalRequest(Callsign, reportingInterval)); + } + catch (Exception ex) + { + _errorReporter.ReportError(ex); + } + } + + [RelayCommand] + async Task SelectCustomPeriod(string text) + { + if (!int.TryParse(text, out var minutes) || minutes < 1) + { + _errorReporter.ReportError(new ArgumentException($"Period must be 1 to 99 minutes: {text}")); + return; + } + + 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() + { + try + { + Callsign = Callsign.Trim().ToUpperInvariant(); + await LoadContractAsync(); + } + catch (Exception ex) + { + _errorReporter.ReportError(ex); + } + } + + public void Receive(AdscContractUpdatedNotification notification) + { + ReloadIfShown(notification.AdscContractDto.Callsign); + } + + public void Receive(AdscContractRemovedNotification notification) + { + ReloadIfShown(notification.Callsign); + } + + public void Receive(AdscReportReceivedNotification notification) + { + ReloadIfShown(notification.AdscReportDto.Callsign); + } + + void ReloadIfShown(string callsign) + { + if (_disposed) + return; + + _guiInvoker.InvokeOnGUI(mainForm => + { + if (_disposed) + return; + + try + { + if (callsign != Callsign) + return; + + _ = LoadContractAsync(); + } + catch (Exception ex) + { + _errorReporter.ReportError(ex); + } + }); + } + + public void Dispose() + { + if (_disposed) + return; + + WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + _disposed = true; + } +} diff --git a/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs b/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs index 5a28830..4aea07a 100644 --- a/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs +++ b/source/CPDLCPlugin/ViewModels/CurrentMessagesViewModel.cs @@ -40,8 +40,8 @@ public CurrentMessagesViewModel( WeakReferenceMessenger.Default.Register(this); - // Initial load - _ = LoadDialoguesAsync(); + // Initial load. The window stays open when there are no messages, so that it can open empty. + _ = LoadDialoguesAsync(closeIfEmpty: false); } public void Dispose() @@ -534,7 +534,7 @@ DialogueDto CreateDialogue(string callsign, params CpdlcMessageDto[] messages) [ObservableProperty] CurrentMessageViewModel? currentlyExtendedMessage; - async Task LoadDialoguesAsync() + async Task LoadDialoguesAsync(bool closeIfEmpty) { try { @@ -558,7 +558,7 @@ async Task LoadDialoguesAsync() } // Close the window if there are no messages left - if (Dialogues.Count == 0) + if (closeIfEmpty && Dialogues.Count == 0) { _windowHandle.Close(); } @@ -579,7 +579,7 @@ public void Receive(DialogueChangedNotification message) if (_disposed) return; - await LoadDialoguesAsync(); + await LoadDialoguesAsync(closeIfEmpty: true); }); } diff --git a/source/CPDLCPlugin/Windows/ADSContractView.xaml b/source/CPDLCPlugin/Windows/ADSContractView.xaml new file mode 100644 index 0000000..f71847b --- /dev/null +++ b/source/CPDLCPlugin/Windows/ADSContractView.xaml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs b/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs new file mode 100644 index 0000000..6ba2d60 --- /dev/null +++ b/source/CPDLCPlugin/Windows/ADSContractView.xaml.cs @@ -0,0 +1,63 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using CPDLCPlugin.ViewModels; + +namespace CPDLCPlugin.Windows; + +public partial class ADSContractView : UserControl +{ + readonly ADSContractViewModel _viewModel; + + public ADSContractView(ADSContractViewModel viewModel, IWindowHandle windowHandle) + { + InitializeComponent(); + _viewModel = viewModel; + DataContext = viewModel; + + windowHandle.Closed += (_, _) => _viewModel.Dispose(); + } + + void CallsignButton_Click(object sender, RoutedEventArgs e) + { + CallsignButton.Visibility = Visibility.Collapsed; + CallsignTextBox.Visibility = Visibility.Visible; + CallsignTextBox.Focus(); + CallsignTextBox.SelectAll(); + } + + void CallsignTextBox_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + return; + + _viewModel.LoadContractCommand.Execute(null); + CallsignTextBox.Visibility = Visibility.Collapsed; + CallsignButton.Visibility = Visibility.Visible; + + e.Handled = true; + } + + void SelectedPeriodButton_Click(object sender, RoutedEventArgs e) + { + CustomPeriodInput.Value = string.Empty; + PeriodPopup.IsOpen = true; + } + + void PeriodButton_Click(object sender, RoutedEventArgs e) + { + PeriodPopup.IsOpen = false; + } + + void CustomPeriodInput_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + return; + + _viewModel.SelectCustomPeriodCommand.Execute(CustomPeriodInput.Value); + PeriodPopup.IsOpen = false; + + e.Handled = true; + } +} + 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 @@