diff --git a/SimConnect.NET.sln b/SimConnect.NET.sln index 758c169..899140f 100644 --- a/SimConnect.NET.sln +++ b/SimConnect.NET.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{02EA681E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimConnect.NET.Tests.Net8", "tests\SimConnect.NET.Tests.Net8\SimConnect.NET.Tests.Net8.csproj", "{38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimConnect.NET.UnitTests", "tests\SimConnect.NET.UnitTests\SimConnect.NET.UnitTests.csproj", "{A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,15 +45,28 @@ Global {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|Any CPU.Build.0 = Release|Any CPU {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x64.ActiveCfg = Release|Any CPU {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x64.Build.0 = Release|Any CPU - {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.ActiveCfg = Release|Any CPU - {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.Build.0 = Release|Any CPU + {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.ActiveCfg = Release|Any CPU + {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.Build.0 = Release|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x64.Build.0 = Debug|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x86.Build.0 = Debug|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|Any CPU.Build.0 = Release|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|x64.ActiveCfg = Release|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|x64.Build.0 = Release|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|x86.ActiveCfg = Release|Any CPU + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {4252A45B-8C7E-487B-9670-53935D9CD06A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D38737E6-D81A-4DDE-9278-DE960E039FEB} diff --git a/src/SimConnect.NET/Facilities/FacilityDataResponse.cs b/src/SimConnect.NET/Facilities/FacilityDataResponse.cs new file mode 100644 index 0000000..dcf92a2 --- /dev/null +++ b/src/SimConnect.NET/Facilities/FacilityDataResponse.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System.Collections.Generic; + +namespace SimConnect.NET.Facilities +{ + /// + /// Represents the result of a facility data request. Contains one or more payloads describing the + /// requested facility and any child objects (runways, approaches, etc.). + /// + public sealed class FacilityDataResponse + { + internal FacilityDataResponse(uint requestId, IReadOnlyList results) + { + this.RequestId = requestId; + this.Results = results; + } + + /// + /// Gets the client supplied request identifier. + /// + public uint RequestId { get; } + + /// + /// Gets the collection of data packets returned by SimConnect. + /// + public IReadOnlyList Results { get; } + + /// + /// Finds the first payload matching the specified . + /// + /// The desired data type. + /// The payload if present; otherwise . + public FacilityDataResult? Find(SimConnectFacilityDataType dataType) + { + foreach (var result in this.Results) + { + if (result.DataType == dataType) + { + return result; + } + } + + return null; + } + } +} diff --git a/src/SimConnect.NET/Facilities/FacilityDataResult.cs b/src/SimConnect.NET/Facilities/FacilityDataResult.cs new file mode 100644 index 0000000..065cf99 --- /dev/null +++ b/src/SimConnect.NET/Facilities/FacilityDataResult.cs @@ -0,0 +1,86 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; +using System.Runtime.InteropServices; + +namespace SimConnect.NET.Facilities +{ + /// + /// Represents a single facility data packet returned by SimConnect. + /// + public readonly struct FacilityDataResult + { + private readonly byte[] payload; + + internal FacilityDataResult( + uint uniqueRequestId, + uint parentUniqueRequestId, + SimConnectFacilityDataType dataType, + bool isListItem, + uint itemIndex, + uint listSize, + byte[] payload) + { + this.UniqueRequestId = uniqueRequestId; + this.ParentUniqueRequestId = parentUniqueRequestId; + this.DataType = dataType; + this.IsListItem = isListItem; + this.ItemIndex = itemIndex; + this.ListSize = listSize; + this.payload = payload ?? Array.Empty(); + } + + /// + /// Gets the unique request identifier assigned by SimConnect. + /// + public uint UniqueRequestId { get; } + + /// + /// Gets the parent unique request identifier, if any. + /// + public uint ParentUniqueRequestId { get; } + + /// + /// Gets the facility data type represented by this payload. + /// + public SimConnectFacilityDataType DataType { get; } + + /// + /// Gets a value indicating whether this payload represents an element of a list. + /// + public bool IsListItem { get; } + + /// + /// Gets the index of the item within a list, if is true. + /// + public uint ItemIndex { get; } + + /// + /// Gets the total number of items in the list, if is true. + /// + public uint ListSize { get; } + + /// + /// Gets the raw payload memory returned by SimConnect. + /// + public ReadOnlyMemory Payload => this.payload; + + /// + /// Marshals the payload into a managed struct of the specified type. + /// + /// The target struct type. Must be blittable. + /// The marshalled struct. + public T As() + where T : struct + { + if (this.payload.Length < Marshal.SizeOf()) + { + throw new InvalidOperationException("Payload is smaller than the requested struct type."); + } + + return MemoryMarshal.Read(this.payload.AsSpan()); + } + } +} diff --git a/src/SimConnect.NET/Facilities/FacilityDefinition.cs b/src/SimConnect.NET/Facilities/FacilityDefinition.cs new file mode 100644 index 0000000..9e0d1ac --- /dev/null +++ b/src/SimConnect.NET/Facilities/FacilityDefinition.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; +using System.Collections.Generic; + +namespace SimConnect.NET.Facilities +{ + /// + /// Represents a facility data definition registered with SimConnect. + /// + public sealed class FacilityDefinition + { + internal FacilityDefinition(uint definitionId, IReadOnlyList fields, Type? structType) + { + this.DefinitionId = definitionId; + this.Fields = fields; + this.StructType = structType; + } + + /// + /// Gets the SimConnect definition identifier. + /// + public uint DefinitionId { get; } + + /// + /// Gets the list of facility fields that are part of this definition. + /// + public IReadOnlyList Fields { get; } + + /// + /// Gets the struct type used to generate this definition, if any. + /// + public Type? StructType { get; } + } +} diff --git a/src/SimConnect.NET/Facilities/FacilityDefinitionBuilder.cs b/src/SimConnect.NET/Facilities/FacilityDefinitionBuilder.cs new file mode 100644 index 0000000..7b9fbc2 --- /dev/null +++ b/src/SimConnect.NET/Facilities/FacilityDefinitionBuilder.cs @@ -0,0 +1,35 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; +using System.Collections.Generic; + +namespace SimConnect.NET.Facilities +{ + /// + /// Fluent builder for instances. + /// + public sealed class FacilityDefinitionBuilder + { + private readonly List fields = new(); + + /// + /// Gets the configured field list. + /// + internal IReadOnlyList Fields => this.fields; + + /// + /// Adds a field path to the definition. + /// + /// The SimConnect facility field path (for example "Airport.Latitude"). + /// The same builder instance to allow fluent chaining. + /// Thrown when is null or whitespace. + public FacilityDefinitionBuilder AddField(string fieldPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fieldPath); + this.fields.Add(fieldPath); + return this; + } + } +} diff --git a/src/SimConnect.NET/Facilities/FacilityFieldAttribute.cs b/src/SimConnect.NET/Facilities/FacilityFieldAttribute.cs new file mode 100644 index 0000000..0693125 --- /dev/null +++ b/src/SimConnect.NET/Facilities/FacilityFieldAttribute.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; + +namespace SimConnect.NET.Facilities +{ + /// + /// Annotates a struct field with a SimConnect facility field path for automatic definition creation. + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] + public sealed class FacilityFieldAttribute : Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The SimConnect facility field path. + /// Optional explicit ordering (lower numbers are processed first). + public FacilityFieldAttribute(string path, int order = 0) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + this.Path = path; + this.Order = order; + } + + /// + /// Gets the SimConnect facility field path represented by this attribute. + /// + public string Path { get; } + + /// + /// Gets the optional ordering index. Lower values are processed first. + /// + public int Order { get; } + } +} diff --git a/src/SimConnect.NET/Facilities/FacilityManager.cs b/src/SimConnect.NET/Facilities/FacilityManager.cs new file mode 100644 index 0000000..6fcee93 --- /dev/null +++ b/src/SimConnect.NET/Facilities/FacilityManager.cs @@ -0,0 +1,514 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using SimConnect.NET.Internal; + +namespace SimConnect.NET.Facilities +{ + /// + /// Provides high-level helpers for working with the SimConnect facility APIs, including + /// dynamic data definitions, ad-hoc facility data requests, and in-range subscriptions. + /// + public sealed class FacilityManager : IDisposable + { + private const uint BaseDefinitionId = 50000; + private const uint BaseRequestId = 60000; + + private readonly IntPtr simConnectHandle; + private readonly ISimConnectFacilityApi facilityApi; + private readonly ConcurrentDictionary definitionCache = new(); + private readonly ConcurrentDictionary minimalListRequests = new(); + private readonly ConcurrentDictionary facilityDataRequests = new(); + private readonly ConcurrentDictionary activeSubscriptions = new(); + private readonly ConcurrentDictionary subscriptionLookup = new(); + private int definitionCounter = (int)BaseDefinitionId - 1; + private int requestCounter = (int)BaseRequestId - 1; + private bool disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The active SimConnect handle. + public FacilityManager(IntPtr simConnectHandle) + : this(simConnectHandle, null) + { + } + + /// + /// Initializes a new instance of the class for testing. + /// + /// The active SimConnect handle. + /// Optional facility API abstraction used for testing. + internal FacilityManager(IntPtr simConnectHandle, ISimConnectFacilityApi? facilityApi) + { + if (simConnectHandle == IntPtr.Zero) + { + throw new ArgumentException("SimConnect handle must be initialized.", nameof(simConnectHandle)); + } + + this.simConnectHandle = simConnectHandle; + this.facilityApi = facilityApi ?? SimConnectFacilityApi.Instance; + } + + /// + /// Creates a facility definition from the supplied builder configuration. + /// + /// Action that defines the desired facility fields. + /// A instance bound to a SimConnect definition ID. + public FacilityDefinition CreateDefinition(Action configure) + { + ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager)); + ArgumentNullException.ThrowIfNull(configure); + + var builder = new FacilityDefinitionBuilder(); + configure(builder); + if (builder.Fields.Count == 0) + { + throw new InvalidOperationException("At least one facility field must be added to the definition."); + } + + var definitionId = this.GetNextDefinitionId(); + foreach (var field in builder.Fields) + { + ThrowIfError( + this.facilityApi.AddToFacilityDefinition(this.simConnectHandle, definitionId, field), + $"Add facility field '{field}'"); + } + + return new FacilityDefinition(definitionId, builder.Fields.ToArray(), null); + } + + /// + /// Creates or retrieves a cached facility definition using annotations. + /// + /// Struct containing annotated fields. + /// The cached . + public FacilityDefinition GetOrCreateDefinition() + where T : struct + { + ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager)); + return this.definitionCache.GetOrAdd(typeof(T), _ => this.CreateDefinitionFromStruct()); + } + + /// + /// Requests facility data using a previously created definition. + /// + /// The facility definition to use. + /// The ICAO identifier of the facility. + /// Optional region string. + /// Cancellation token. + /// A task returning the full response payload. + public Task RequestFacilityDataAsync( + FacilityDefinition definition, + string icao, + string? region = null, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager)); + ArgumentNullException.ThrowIfNull(definition); + ArgumentException.ThrowIfNullOrEmpty(icao); + + var requestId = this.GetNextRequestId(); + var requestState = new FacilityDataRequestState(requestId); + if (!this.facilityDataRequests.TryAdd(requestId, requestState)) + { + throw new InvalidOperationException("Failed to track facility data request state."); + } + + cancellationToken.Register(() => + { + if (this.facilityDataRequests.TryRemove(requestId, out var canceled)) + { + canceled.TrySetCanceled(); + } + }); + + ThrowIfError( + this.facilityApi.RequestFacilityData(this.simConnectHandle, definition.DefinitionId, requestId, icao, region ?? string.Empty), + "RequestFacilityData"); + + return requestState.Task; + } + + /// + /// Requests facility data for the specified annotated struct type. + /// + /// The annotated struct type describing the desired fields. + /// The ICAO identifier. + /// Optional region code. + /// Cancellation token for the request. + /// The complete . + public Task RequestFacilityDataAsync( + string icao, + string? region = null, + CancellationToken cancellationToken = default) + where T : struct + => this.RequestFacilityDataAsync(this.GetOrCreateDefinition(), icao, region, cancellationToken); + + /// + /// Requests the minimal facility list (ICAO and Lat/Lon/Alt) for the specified type. + /// + /// The facility list type (airport, waypoint, etc.). + /// Cancellation token for the request. + /// A task that completes with the full list of facilities. + public Task> RequestMinimalListAsync( + SimConnectFacilityListType type, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager)); + var requestId = this.GetNextRequestId(); + var state = new FacilityMinimalListRequest(requestId); + if (!this.minimalListRequests.TryAdd(requestId, state)) + { + throw new InvalidOperationException("Failed to track facility list request state."); + } + + cancellationToken.Register(() => + { + if (this.minimalListRequests.TryRemove(requestId, out var canceled)) + { + canceled.TrySetCanceled(); + } + }); + + ThrowIfError( + this.facilityApi.RequestFacilitiesListEx1(this.simConnectHandle, (uint)type, requestId), + "RequestFacilitiesList_EX1"); + + return state.Task; + } + + /// + /// Subscribes to facilities entering and leaving the user's reality bubble. + /// + /// Facility list type to monitor. + /// Callback invoked when facilities enter range. + /// Optional callback for facilities leaving range. + /// A disposable subscription object. + public FacilitySubscription SubscribeToMinimalFacilities( + SimConnectFacilityListType type, + Action> onEntered, + Action>? onExited = null) + { + ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager)); + ArgumentNullException.ThrowIfNull(onEntered); + + var inRangeRequestId = this.GetNextRequestId(); + var outRangeRequestId = this.GetNextRequestId(); + var subscription = new FacilitySubscription(this, type, inRangeRequestId, outRangeRequestId, onEntered, onExited); + + if (!this.activeSubscriptions.TryAdd(type, subscription)) + { + throw new InvalidOperationException($"A subscription for {type} already exists. Dispose the existing subscription before creating a new one."); + } + + this.subscriptionLookup[inRangeRequestId] = subscription; + this.subscriptionLookup[outRangeRequestId] = subscription; + + try + { + ThrowIfError( + this.facilityApi.SubscribeToFacilitiesEx1(this.simConnectHandle, (uint)type, inRangeRequestId, outRangeRequestId), + "SubscribeToFacilities_EX1"); + } + catch + { + this.subscriptionLookup.TryRemove(inRangeRequestId, out _); + this.subscriptionLookup.TryRemove(outRangeRequestId, out _); + this.activeSubscriptions.TryRemove(type, out _); + throw; + } + + return subscription; + } + + /// + /// Releases unmanaged resources. + /// + public void Dispose() + { + if (this.disposed) + { + return; + } + + foreach (var subscription in this.activeSubscriptions.Values) + { + subscription?.Dispose(); + } + + this.disposed = true; + } + + internal void RemoveSubscription(FacilitySubscription subscription) + { + if (subscription == null) + { + return; + } + + this.subscriptionLookup.TryRemove(subscription.EnteredRequestId, out _); + this.subscriptionLookup.TryRemove(subscription.ExitedRequestId, out _); + this.activeSubscriptions.TryRemove(subscription.Type, out _); + try + { + ThrowIfError( + this.facilityApi.UnsubscribeFromFacilitiesEx1(this.simConnectHandle, (uint)subscription.Type, true, true), + "UnsubscribeToFacilities_EX1"); + } + catch (SimConnectException ex) when (!ExceptionHelper.IsCritical(ex)) + { + SimConnectLogger.Warning($"Failed to unsubscribe from facilities: {ex.Message}"); + } + } + + internal void ProcessFacilityMinimalList(IntPtr data) + { + if (data == IntPtr.Zero) + { + return; + } + + var header = Marshal.PtrToStructure(data)!; + var dataStart = IntPtr.Add(data, FacilityMinimalListHeader.SizeInBytes); + var list = FacilityManager.MarshalArray(dataStart, (int)header.ArraySize); + + if (this.subscriptionLookup.TryGetValue(header.RequestId, out var subscription)) + { + subscription.Dispatch(header.RequestId, list); + return; + } + + if (this.minimalListRequests.TryGetValue(header.RequestId, out var request)) + { + request.AddChunk(list, header.EntryNumber, header.OutOf); + if (request.IsComplete) + { + this.minimalListRequests.TryRemove(header.RequestId, out _); + } + + return; + } + } + + internal void ProcessFacilityData(IntPtr data) + { + if (data == IntPtr.Zero) + { + return; + } + + var recv = Marshal.PtrToStructure(data)!; + if (this.facilityDataRequests.TryGetValue(recv.UserRequestId, out var state)) + { + state.AddPacket(data, recv); + } + } + + internal void ProcessFacilityDataEnd(IntPtr data) + { + if (data == IntPtr.Zero) + { + return; + } + + var recvEnd = Marshal.PtrToStructure(data)!; + if (this.facilityDataRequests.TryRemove(recvEnd.RequestId, out var state)) + { + state.Complete(); + } + } + + private static void ThrowIfError(int result, string operation) + { + if (result == (int)SimConnectError.None) + { + return; + } + + var error = (SimConnectError)result; + var message = $"{operation} failed: {SimConnectErrorMapper.Describe(error)}"; + throw new SimConnectException(message, error); + } + + private static T[] MarshalArray(IntPtr dataStart, int count) + where T : struct + { + if (count <= 0 || dataStart == IntPtr.Zero) + { + return Array.Empty(); + } + + var elementSize = Marshal.SizeOf(); + var result = new T[count]; + for (var i = 0; i < count; i++) + { + var elementPtr = IntPtr.Add(dataStart, i * elementSize); + result[i] = Marshal.PtrToStructure(elementPtr)!; + } + + return result; + } + + private FacilityDefinition CreateDefinitionFromStruct() + where T : struct + { + var fields = typeof(T) + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Select(f => (Field: f, Attribute: f.GetCustomAttribute())) + .Where(tuple => tuple.Attribute != null) + .OrderBy(tuple => tuple.Attribute!.Order) + .ThenBy(tuple => tuple.Field.MetadataToken) + .Select(tuple => tuple.Attribute!.Path) + .ToList(); + + if (fields.Count == 0) + { + throw new InvalidOperationException($"Type {typeof(T).FullName} does not define any FacilityFieldAttribute annotations."); + } + + var definitionId = this.GetNextDefinitionId(); + foreach (var field in fields) + { + ThrowIfError( + this.facilityApi.AddToFacilityDefinition(this.simConnectHandle, definitionId, field), + $"Add facility field '{field}'"); + } + + return new FacilityDefinition(definitionId, fields, typeof(T)); + } + + private uint GetNextDefinitionId() => (uint)Interlocked.Increment(ref this.definitionCounter); + + private uint GetNextRequestId() => (uint)Interlocked.Increment(ref this.requestCounter); + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct FacilityMinimalListHeader + { + public static readonly int SizeInBytes = Marshal.SizeOf(); + + public uint Size; + public uint Version; + public uint Id; + public uint RequestId; + public uint ArraySize; + public uint EntryNumber; + public uint OutOf; + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct SimConnectRecvFacilityDataInternal + { + public static readonly int DataOffset = Marshal.OffsetOf(nameof(Data)).ToInt32(); + + public uint Size; + public uint Version; + public uint Id; + public uint UserRequestId; + public uint UniqueRequestId; + public uint ParentUniqueRequestId; + public SimConnectFacilityDataType Type; + public uint IsListItem; + public uint ItemIndex; + public uint ListSize; + public uint Data; + } + + private sealed class FacilityMinimalListRequest + { + private readonly TaskCompletionSource> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List buffer = new(); + private uint expectedPackets; + private uint receivedPackets; + + internal FacilityMinimalListRequest(uint requestId) + { + } + + internal Task> Task => this.tcs.Task; + + internal bool IsComplete => this.expectedPackets != 0 && this.receivedPackets >= this.expectedPackets; + + internal void AddChunk(SimConnectFacilityMinimal[] chunk, uint entryNumber, uint outOf) + { + if (chunk.Length > 0) + { + this.buffer.AddRange(chunk); + } + + this.receivedPackets++; + this.expectedPackets = outOf == 0 ? 1u : outOf; + + if (this.IsComplete) + { + this.tcs.TrySetResult(this.buffer); + } + } + + internal void TrySetCanceled() + { + this.tcs.TrySetCanceled(); + } + } + + private sealed class FacilityDataRequestState + { + private readonly TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List packets = new(); + private readonly uint requestId; + + internal FacilityDataRequestState(uint requestId) + { + this.requestId = requestId; + } + + internal Task Task => this.tcs.Task; + + internal void AddPacket(IntPtr basePtr, SimConnectRecvFacilityDataInternal data) + { + var payloadSize = (int)data.Size - SimConnectRecvFacilityDataInternal.DataOffset; + if (payloadSize < 0) + { + payloadSize = 0; + } + + var payload = new byte[payloadSize]; + if (payloadSize > 0) + { + var payloadPtr = IntPtr.Add(basePtr, SimConnectRecvFacilityDataInternal.DataOffset); + Marshal.Copy(payloadPtr, payload, 0, payloadSize); + } + + var result = new FacilityDataResult( + data.UniqueRequestId, + data.ParentUniqueRequestId, + data.Type, + data.IsListItem != 0, + data.ItemIndex, + data.ListSize, + payload); + + this.packets.Add(result); + } + + internal void Complete() + { + this.tcs.TrySetResult(new FacilityDataResponse(this.requestId, this.packets)); + } + + internal void TrySetCanceled() + { + this.tcs.TrySetCanceled(); + } + } + } +} diff --git a/src/SimConnect.NET/Facilities/FacilitySubscription.cs b/src/SimConnect.NET/Facilities/FacilitySubscription.cs new file mode 100644 index 0000000..c88f69f --- /dev/null +++ b/src/SimConnect.NET/Facilities/FacilitySubscription.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; +using System.Collections.Generic; + +namespace SimConnect.NET.Facilities +{ + /// + /// Represents an active subscription created through . + /// + public sealed class FacilitySubscription : IDisposable + { + private readonly FacilityManager manager; + private readonly Action> onEntered; + private readonly Action>? onExited; + private bool disposed; + + internal FacilitySubscription( + FacilityManager manager, + SimConnectFacilityListType type, + uint enteredRequestId, + uint exitedRequestId, + Action> onEntered, + Action>? onExited) + { + this.manager = manager; + this.Type = type; + this.EnteredRequestId = enteredRequestId; + this.ExitedRequestId = exitedRequestId; + this.onEntered = onEntered ?? throw new ArgumentNullException(nameof(onEntered)); + this.onExited = onExited; + } + + /// + /// Gets the facility type represented by the subscription. + /// + public SimConnectFacilityListType Type { get; } + + internal uint EnteredRequestId { get; } + + internal uint ExitedRequestId { get; } + + /// + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.disposed = true; + this.manager.RemoveSubscription(this); + GC.SuppressFinalize(this); + } + + internal void Dispatch(uint requestId, IReadOnlyList facilities) + { + if (requestId == this.EnteredRequestId) + { + this.onEntered(facilities); + } + else if (requestId == this.ExitedRequestId) + { + this.onExited?.Invoke(facilities); + } + } + } +} diff --git a/src/SimConnect.NET/Internal/ISimConnectFacilityApi.cs b/src/SimConnect.NET/Internal/ISimConnectFacilityApi.cs new file mode 100644 index 0000000..4a43d30 --- /dev/null +++ b/src/SimConnect.NET/Internal/ISimConnectFacilityApi.cs @@ -0,0 +1,125 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; + +namespace SimConnect.NET.Internal +{ + /// + /// Abstraction over the SimConnect facility P/Invoke surface. + /// + internal interface ISimConnectFacilityApi + { + /// Adds a field to a facility definition. + /// Active SimConnect handle. + /// Target facility definition identifier. + /// The field path to add. + /// The raw SimConnect result. + int AddToFacilityDefinition(IntPtr handle, uint definitionId, string fieldName); + + /// Requests facility data using a definition. + /// Active SimConnect handle. + /// Definition identifier. + /// Client request identifier. + /// Facility ICAO code. + /// Optional region code. + /// The raw SimConnect result. + int RequestFacilityData(IntPtr handle, uint definitionId, uint requestId, string icao, string region); + + /// Requests a facility list using the legacy API. + /// Active SimConnect handle. + /// Facility list type. + /// Client request identifier. + /// The raw SimConnect result. + int RequestFacilitiesList(IntPtr handle, uint type, uint requestId); + + /// Requests a facility list using the EX1 API. + /// Active SimConnect handle. + /// Facility list type. + /// Client request identifier. + /// The raw SimConnect result. + int RequestFacilitiesListEx1(IntPtr handle, uint type, uint requestId); + + /// Subscribes to facilities entering/exiting range. + /// Active SimConnect handle. + /// Facility list type. + /// Request ID for entries entering range. + /// Request ID for entries leaving range. + /// The raw SimConnect result. + int SubscribeToFacilitiesEx1(IntPtr handle, uint type, uint newInRangeRequestId, uint oldOutRangeRequestId); + + /// Unsubscribes from facility notifications. + /// Active SimConnect handle. + /// Facility list type. + /// Whether to unsubscribe from new-in-range events. + /// Whether to unsubscribe from out-of-range events. + /// The raw SimConnect result. + int UnsubscribeFromFacilitiesEx1(IntPtr handle, uint type, bool unsubscribeNewInRange, bool unsubscribeOldOutRange); + } + + /// + /// Default implementation that forwards calls to . + /// + internal sealed class SimConnectFacilityApi : ISimConnectFacilityApi + { + private SimConnectFacilityApi() + { + } + + /// Gets the shared singleton instance. + public static ISimConnectFacilityApi Instance { get; } = new SimConnectFacilityApi(); + + /// Adds a field to a facility definition. + /// Active SimConnect handle. + /// Target facility definition identifier. + /// The field path to add. + /// The raw SimConnect result. + public int AddToFacilityDefinition(IntPtr handle, uint definitionId, string fieldName) + => SimConnectNative.SimConnect_AddToFacilityDefinition(handle, definitionId, fieldName); + + /// Requests facility data using a definition. + /// Active SimConnect handle. + /// Definition identifier. + /// Client request identifier. + /// Facility ICAO code. + /// Optional region code. + /// The raw SimConnect result. + public int RequestFacilityData(IntPtr handle, uint definitionId, uint requestId, string icao, string region) + => SimConnectNative.SimConnect_RequestFacilityData(handle, definitionId, requestId, icao, region ?? string.Empty); + + /// Requests a facility list using the legacy API. + /// Active SimConnect handle. + /// Facility list type. + /// Client request identifier. + /// The raw SimConnect result. + public int RequestFacilitiesList(IntPtr handle, uint type, uint requestId) + => SimConnectNative.SimConnect_RequestFacilitiesList(handle, type, requestId); + + /// Requests a facility list using the EX1 API. + /// Active SimConnect handle. + /// Facility list type. + /// Client request identifier. + /// The raw SimConnect result. + public int RequestFacilitiesListEx1(IntPtr handle, uint type, uint requestId) + => SimConnectNative.SimConnect_RequestFacilitiesList_EX1(handle, type, requestId); + + /// Subscribes to facilities entering/exiting range. + /// Active SimConnect handle. + /// Facility list type. + /// Request ID for entries entering range. + /// Request ID for entries leaving range. + /// The raw SimConnect result. + public int SubscribeToFacilitiesEx1(IntPtr handle, uint type, uint newInRangeRequestId, uint oldOutRangeRequestId) + => SimConnectNative.SimConnect_SubscribeToFacilities_EX1(handle, type, newInRangeRequestId, oldOutRangeRequestId); + + /// Unsubscribes from facility notifications. + /// Active SimConnect handle. + /// Facility list type. + /// Whether to unsubscribe from new-in-range events. + /// Whether to unsubscribe from out-of-range events. + /// The raw SimConnect result. + public int UnsubscribeFromFacilitiesEx1(IntPtr handle, uint type, bool unsubscribeNewInRange, bool unsubscribeOldOutRange) + => SimConnectNative.SimConnect_UnsubscribeToFacilities_EX1(handle, type, unsubscribeNewInRange, unsubscribeOldOutRange); + } +} diff --git a/src/SimConnect.NET/Properties/AssemblyInfo.cs b/src/SimConnect.NET/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..08b378a --- /dev/null +++ b/src/SimConnect.NET/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SimConnect.NET.UnitTests")] diff --git a/src/SimConnect.NET/SimConnectClient.cs b/src/SimConnect.NET/SimConnectClient.cs index c5b6d3f..179d10d 100644 --- a/src/SimConnect.NET/SimConnectClient.cs +++ b/src/SimConnect.NET/SimConnectClient.cs @@ -9,6 +9,7 @@ using SimConnect.NET.AI; using SimConnect.NET.Aircraft; using SimConnect.NET.Events; +using SimConnect.NET.Facilities; using SimConnect.NET.InputEvents; using SimConnect.NET.Internal; using SimConnect.NET.SimVar; @@ -31,6 +32,7 @@ public sealed class SimConnectClient : IDisposable private SimObjectManager? simObjectManager; private InputEventManager? inputEventManager; private InputGroupManager? inputGroupManager; + private FacilityManager? facilityManager; private int reconnectAttempts; private Task? reconnectTask; private CancellationTokenSource? reconnectCancellation; @@ -193,6 +195,23 @@ public InputGroupManager InputGroups } } + /// + /// Gets the facility manager for querying and subscribing to facility data. + /// + /// Thrown when not connected to SimConnect. + public FacilityManager Facilities + { + get + { + if (!this.isConnected || this.facilityManager == null) + { + throw new InvalidOperationException("Not connected to SimConnect. Call ConnectAsync first."); + } + + return this.facilityManager; + } + } + /// /// Gets the SimConnect handle for advanced operations. /// @@ -254,6 +273,7 @@ await Task.Run( this.simObjectManager = new SimObjectManager(this); this.inputEventManager = new InputEventManager(this.simConnectHandle); this.inputGroupManager = new InputGroupManager(this.simConnectHandle); + this.facilityManager = new FacilityManager(this.simConnectHandle); this.messageLoopCancellation = new CancellationTokenSource(); this.messageProcessingTask = this.StartMessageProcessingLoopAsync(this.messageLoopCancellation.Token); @@ -276,12 +296,14 @@ public async Task DisconnectAsync() this.simVarManager?.Dispose(); this.inputEventManager?.Dispose(); this.inputGroupManager?.Dispose(); + this.facilityManager?.Dispose(); this.simObjectManager = null; this.isMSFS2024 = false; this.simVarManager = null; this.aircraftDataManager = null; this.inputEventManager = null; this.inputGroupManager = null; + this.facilityManager = null; if (this.messageLoopCancellation != null) { @@ -415,6 +437,15 @@ public async Task ProcessNextMessageAsync(CancellationToken cancellationTo case SimConnectRecvId.SubscribeInputEvent: this.inputEventManager?.ProcessReceivedData(ppData, pcbData); break; + case SimConnectRecvId.FacilityMinimalList: + this.facilityManager?.ProcessFacilityMinimalList(ppData); + break; + case SimConnectRecvId.FacilityData: + this.facilityManager?.ProcessFacilityData(ppData); + break; + case SimConnectRecvId.FacilityDataEnd: + this.facilityManager?.ProcessFacilityDataEnd(ppData); + break; case SimConnectRecvId.AirportList: case SimConnectRecvId.VorList: case SimConnectRecvId.NdbList: diff --git a/src/SimConnect.NET/Structs/SimConnectIcao.cs b/src/SimConnect.NET/Structs/SimConnectIcao.cs index 0ad0a96..06c8a2e 100644 --- a/src/SimConnect.NET/Structs/SimConnectIcao.cs +++ b/src/SimConnect.NET/Structs/SimConnectIcao.cs @@ -3,6 +3,7 @@ // using System.Runtime.InteropServices; +using System.Text; namespace SimConnect.NET { @@ -12,24 +13,82 @@ namespace SimConnect.NET [StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Ansi)] public struct SimConnectIcao { + private const int IcaoSize = 9; + private const int IdentSize = 9; + private const int RegionSize = 9; + private const int AirportSize = 5; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = IcaoSize)] + private byte[]? icao; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = IdentSize)] + private byte[]? ident; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = RegionSize)] + private byte[]? region; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = AirportSize)] + private byte[]? airport; + + /// + /// Gets the ICAO identifier (e.g. "KJFK"). + /// + public string Icao => Decode(this.icao); + /// - /// The type of the ICAO code. + /// Gets the facility ident (e.g. runway or navaid name). /// - public char Type; + public string Ident => Decode(this.ident); /// - /// The identity string (fixed 6 characters). + /// Gets the region (State/area) code. /// - public char Ident; + public string Region => Decode(this.region); /// - /// The region string (fixed 3 characters). + /// Gets the airport identifier if the facility belongs to an airport. /// - public char Region; + public string Airport => Decode(this.airport); /// - /// The airport string (fixed 5 characters). + /// Creates a new instance from strings. /// - public char Airport; + /// ICAO identifier. + /// Facility ident. + /// Region code. + /// Airport identifier. + /// A populated . + public static SimConnectIcao FromStrings(string? icao, string? ident = null, string? region = null, string? airport = null) + { + return new SimConnectIcao + { + icao = Encode(IcaoSize, icao), + ident = Encode(IdentSize, ident), + region = Encode(RegionSize, region), + airport = Encode(AirportSize, airport), + }; + } + + private static string Decode(byte[]? buffer) + { + if (buffer == null || buffer.Length == 0) + { + return string.Empty; + } + + return Encoding.ASCII.GetString(buffer).TrimEnd('\0', ' '); + } + + private static byte[] Encode(int size, string? value) + { + var buffer = new byte[size]; + if (!string.IsNullOrEmpty(value)) + { + var bytes = Encoding.ASCII.GetBytes(value); + Array.Copy(bytes, buffer, Math.Min(bytes.Length, size - 1)); + } + + return buffer; + } } } diff --git a/tests/SimConnect.NET.UnitTests/FacilityManagerTests.cs b/tests/SimConnect.NET.UnitTests/FacilityManagerTests.cs new file mode 100644 index 0000000..90301f0 --- /dev/null +++ b/tests/SimConnect.NET.UnitTests/FacilityManagerTests.cs @@ -0,0 +1,401 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using SimConnect.NET; +using SimConnect.NET.Facilities; +using SimConnect.NET.Internal; +using Xunit; + +namespace SimConnect.NET.UnitTests +{ + /// + /// Unit tests exercising the surface. + /// + public class FacilityManagerTests + { + /// + /// Verifies that custom definitions register every requested field. + /// + [Fact] + public void CreateDefinition_RegistersAllFields() + { + var api = new FakeFacilityApi(); + using var manager = new FacilityManager(new IntPtr(1), api); + + var definition = manager.CreateDefinition(builder => builder.AddField("Airport.Latitude").AddField("Airport.Longitude")); + + Assert.Equal(2, api.AddedFields.Count); + Assert.Equal(definition.DefinitionId, api.AddedFields[0].DefinitionId); + } + + /// + /// Ensures that minimal facility list requests complete when data arrives. + /// + /// A task that completes when the test has validated the behavior. + [Fact] + public async Task MinimalListRequest_CompletesWhenMessageArrives() + { + var api = new FakeFacilityApi(); + using var manager = new FacilityManager(new IntPtr(1), api); + + var requestTask = manager.RequestMinimalListAsync(SimConnectFacilityListType.Airport); + var requestId = api.MinimalRequests.Single().RequestId; + var facilities = new[] + { + new SimConnectFacilityMinimal + { + Icao = SimConnectIcao.FromStrings("KJFK"), + LatLonAlt = new SimConnectDataLatLonAlt { Latitude = 40.64, Longitude = -73.78, Altitude = 13 }, + }, + }; + + using (var message = FacilityMessageBuilder.CreateMinimalListMessage(requestId, facilities)) + { + manager.ProcessFacilityMinimalList(message.Pointer); + } + + var result = await requestTask; + Assert.Single(result); + Assert.Equal("KJFK", result[0].Icao.Icao); + } + + /// + /// Validates that facility data requests return all received payloads. + /// + /// A task that completes when the test has validated the behavior. + [Fact] + public async Task FacilityDataRequest_ReturnsPayloads() + { + var api = new FakeFacilityApi(); + using var manager = new FacilityManager(new IntPtr(1), api); + + var definition = manager.CreateDefinition(b => b.AddField("Airport.Name")); + var task = manager.RequestFacilityDataAsync(definition, "TEST"); + var request = api.DataRequests.Single(); + Assert.Equal("TEST", request.Icao); + + var payload = new byte[16]; + for (int i = 0; i < payload.Length; i++) + { + payload[i] = (byte)(i + 1); + } + + using (var message = FacilityMessageBuilder.CreateFacilityDataMessage(request.RequestId, SimConnectFacilityDataType.Airport, payload)) + { + manager.ProcessFacilityData(message.Pointer); + } + + var end = new SimConnectRecvFacilityDataEnd + { + Size = (uint)Marshal.SizeOf(), + Version = 1, + Id = (uint)SimConnectRecvId.FacilityDataEnd, + RequestId = request.RequestId, + }; + + IntPtr endPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(end, endPtr, false); + manager.ProcessFacilityDataEnd(endPtr); + } + finally + { + Marshal.FreeHGlobal(endPtr); + } + + var response = await task; + Assert.Single(response.Results); + Assert.Equal(payload, response.Results[0].Payload.ToArray()); + } + + /// + /// Ensures that subscription callbacks fire when facilities enter and exit range. + /// + [Fact] + public void Subscription_DispatchesCallbacks() + { + var api = new FakeFacilityApi(); + using var manager = new FacilityManager(new IntPtr(1), api); + + List entered = new(); + List exited = new(); + + using var subscription = manager.SubscribeToMinimalFacilities( + SimConnectFacilityListType.Airport, + facilities => entered.AddRange(facilities.Select(f => f.Icao.Icao)), + facilities => exited.AddRange(facilities.Select(f => f.Icao.Icao))); + + var entry = api.Subscriptions.Single(); + var facilities = new[] + { + new SimConnectFacilityMinimal + { + Icao = SimConnectIcao.FromStrings("SUB1"), + LatLonAlt = default, + }, + }; + + using (var enterMessage = FacilityMessageBuilder.CreateMinimalListMessage(entry.EnterRequestId, facilities)) + { + manager.ProcessFacilityMinimalList(enterMessage.Pointer); + } + + using (var exitMessage = FacilityMessageBuilder.CreateMinimalListMessage(entry.ExitRequestId, facilities)) + { + manager.ProcessFacilityMinimalList(exitMessage.Pointer); + } + + Assert.Equal(new[] { "SUB1" }, entered); + Assert.Equal(new[] { "SUB1" }, exited); + } + + /// + /// Provides helper methods to create simulated facility messages. + /// + private static class FacilityMessageBuilder + { + /// + /// Creates a simulated facility minimal list message. + /// + /// The request identifier associated with the list. + /// The facilities included in the message. + /// A disposable message handle. + public static FacilityMessageHandle CreateMinimalListMessage(uint requestId, IReadOnlyList facilities) + { + var header = new MinimalListHeader + { + Size = (uint)(MinimalListHeader.SizeInBytes + (Marshal.SizeOf() * facilities.Count)), + Version = 1, + Id = (uint)SimConnectRecvId.FacilityMinimalList, + RequestId = requestId, + ArraySize = (uint)facilities.Count, + EntryNumber = 0, + OutOf = 1, + }; + + var headerBytes = StructureToBytes(header); + var elementSize = Marshal.SizeOf(); + var totalSize = headerBytes.Length + (elementSize * facilities.Count); + var buffer = new byte[totalSize]; + Array.Copy(headerBytes, buffer, headerBytes.Length); + + if (facilities.Count > 0) + { + var elementPtr = Marshal.AllocHGlobal(elementSize); + try + { + for (int i = 0; i < facilities.Count; i++) + { + Marshal.StructureToPtr(facilities[i], elementPtr, false); + Marshal.Copy(elementPtr, buffer, headerBytes.Length + (i * elementSize), elementSize); + } + } + finally + { + Marshal.FreeHGlobal(elementPtr); + } + } + + return FacilityMessageHandle.Factory.FromBuffer(buffer); + } + + /// + /// Creates a simulated facility data message for the specified payload. + /// + /// The request identifier. + /// The facility data type. + /// The payload bytes to embed. + /// A disposable message handle. + public static FacilityMessageHandle CreateFacilityDataMessage(uint requestId, SimConnectFacilityDataType type, byte[] payload) + { + var header = new FacilityDataHeader + { + Size = (uint)(FacilityDataHeader.PayloadOffset + payload.Length), + Version = 1, + Id = (uint)SimConnectRecvId.FacilityData, + UserRequestId = requestId, + UniqueRequestId = 42, + ParentUniqueRequestId = 0, + Type = type, + IsListItem = 0, + ItemIndex = 0, + ListSize = 0, + Data = 0, + }; + + var headerBytes = StructureToBytes(header); + var totalSize = Math.Max(headerBytes.Length, FacilityDataHeader.PayloadOffset + payload.Length); + var buffer = new byte[totalSize]; + Array.Copy(headerBytes, buffer, headerBytes.Length); + Array.Copy(payload, 0, buffer, FacilityDataHeader.PayloadOffset, payload.Length); + + return FacilityMessageHandle.Factory.FromBuffer(buffer, FacilityDataHeader.PayloadOffset + payload.Length); + } + + /// + /// Converts a structure into a byte array for native marshalling tests. + /// + /// The structure type. + /// The value to copy. + /// A byte array containing the structure. + private static byte[] StructureToBytes(T value) + where T : struct + { + var size = Marshal.SizeOf(); + var ptr = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(value, ptr, false); + var buffer = new byte[size]; + Marshal.Copy(ptr, buffer, 0, size); + return buffer; + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct MinimalListHeader + { + public static readonly int SizeInBytes = Marshal.SizeOf(); + + public uint Size; + public uint Version; + public uint Id; + public uint RequestId; + public uint ArraySize; + public uint EntryNumber; + public uint OutOf; + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct FacilityDataHeader + { + public static readonly int PayloadOffset = Marshal.SizeOf() - sizeof(uint); + + public uint Size; + public uint Version; + public uint Id; + public uint UserRequestId; + public uint UniqueRequestId; + public uint ParentUniqueRequestId; + public SimConnectFacilityDataType Type; + public uint IsListItem; + public uint ItemIndex; + public uint ListSize; + public uint Data; + } + } + + /// + /// Disposable wrapper around unmanaged facility message buffers. + /// + private sealed class FacilityMessageHandle : IDisposable + { + private FacilityMessageHandle(IntPtr pointer, int length) + { + this.Pointer = pointer; + this.Length = length; + } + + /// + /// Gets the unmanaged pointer to the simulated message. + /// + public IntPtr Pointer { get; } + + /// + /// Gets the length of the unmanaged buffer. + /// + public int Length { get; } + + /// + public void Dispose() + { + if (this.Pointer != IntPtr.Zero) + { + Marshal.FreeHGlobal(this.Pointer); + } + } + + /// + /// Factory helpers for creating instances. + /// + public static class Factory + { + /// + /// Creates a disposable facility message handle from the provided buffer. + /// + /// The buffer to copy into unmanaged memory. + /// Optional size override when the buffer is larger than the payload. + /// A disposable facility message handle. + public static FacilityMessageHandle FromBuffer(byte[] buffer, int? sizeOverride = null) + { + var size = sizeOverride ?? buffer.Length; + var ptr = Marshal.AllocHGlobal(size); + Marshal.Copy(buffer, 0, ptr, size); + return new FacilityMessageHandle(ptr, size); + } + } + } + + /// + /// Fake facility API used to capture facility requests. + /// + private sealed class FakeFacilityApi : ISimConnectFacilityApi + { + public List<(uint DefinitionId, string Field)> AddedFields { get; } = new(); + + public List<(uint Type, uint RequestId)> MinimalRequests { get; } = new(); + + public List<(uint DefinitionId, uint RequestId, string Icao, string Region)> DataRequests { get; } = new(); + + public List<(uint Type, uint EnterRequestId, uint ExitRequestId)> Subscriptions { get; } = new(); + + public List<(uint Type, bool NewRange, bool OldRange)> Unsubscriptions { get; } = new(); + + public int AddToFacilityDefinition(IntPtr handle, uint definitionId, string fieldName) + { + this.AddedFields.Add((definitionId, fieldName)); + return 0; + } + + public int RequestFacilityData(IntPtr handle, uint definitionId, uint requestId, string icao, string region) + { + this.DataRequests.Add((definitionId, requestId, icao, region)); + return 0; + } + + public int RequestFacilitiesList(IntPtr handle, uint type, uint requestId) + { + return 0; + } + + public int RequestFacilitiesListEx1(IntPtr handle, uint type, uint requestId) + { + this.MinimalRequests.Add((type, requestId)); + return 0; + } + + public int SubscribeToFacilitiesEx1(IntPtr handle, uint type, uint newInRangeRequestId, uint oldOutRangeRequestId) + { + this.Subscriptions.Add((type, newInRangeRequestId, oldOutRangeRequestId)); + return 0; + } + + public int UnsubscribeFromFacilitiesEx1(IntPtr handle, uint type, bool unsubscribeNewInRange, bool unsubscribeOldOutRange) + { + this.Unsubscriptions.Add((type, unsubscribeNewInRange, unsubscribeOldOutRange)); + return 0; + } + } + } +} diff --git a/tests/SimConnect.NET.UnitTests/SimConnect.NET.UnitTests.csproj b/tests/SimConnect.NET.UnitTests/SimConnect.NET.UnitTests.csproj new file mode 100644 index 0000000..b7f8ca7 --- /dev/null +++ b/tests/SimConnect.NET.UnitTests/SimConnect.NET.UnitTests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + enable + false + latest + true + + + + + + + + + + +