From dcbbc07d2486fde5dd0cee8dc61e4bde1b06246f Mon Sep 17 00:00:00 2001 From: AussieScorcher Date: Sun, 10 Aug 2025 13:13:56 +0800 Subject: [PATCH 01/20] Refactor MainWindow and Introduce MVVM Pattern - Removed the MainWindow code-behind logic and migrated to a dedicated MainWindowViewModel. - Implemented ICommand for search and pagination functionality. - Introduced AirportRowViewModel for better handling of individual airport data. - Added PasswordBoxAssistant for two-way binding of PasswordBox. - Created NullOrEmptyToVisibilityConverter for visibility management based on string content. - Removed obsolete AirportService and ApiService classes. - Added NearestAirportService for fetching nearest airport data based on coordinates. - Improved API token handling and validation logic. - Enhanced error handling and user feedback mechanisms. --- App.xaml | 5 +- App.xaml.cs | 74 +- Application/IAirportRepository.cs | 11 + Application/ISettingsStore.cs | 15 + Application/SimulatorManager.cs | 86 ++ BARS-Client-V2.csproj | 7 + Domain/Airport.cs | 8 + Domain/FlightState.cs | 6 + Domain/IPointStateListener.cs | 15 + Domain/ISimulatorConnector.cs | 26 + Domain/Point.cs | 23 + .../InMemory/InMemoryAirportRepository.cs | 51 ++ .../AirportStreamMessageProcessor.cs | 244 ++++++ .../Networking/AirportWebSocketManager.cs | 321 +++++++ .../Networking/PointStateDispatcher.cs | 31 + Infrastructure/Settings/JsonSettingsStore.cs | 103 +++ .../Simulators/MSFS/MsfsPointController.cs | 186 ++++ .../Simulators/MSFS/MsfsSimulatorConnector.cs | 100 +++ MainWindow.xaml | 797 +++++++++++------- MainWindow.xaml.cs | 345 +------- .../Behaviors/PasswordBoxAssistant.cs | 73 ++ .../NullOrEmptyToVisibilityConverter.cs | 18 + .../ViewModels/MainWindowViewModel.cs | 348 ++++++++ Services/AirportService.cs | 219 ----- Services/ApiService.cs | 0 Services/NearestAirportService.cs | 88 ++ 26 files changed, 2351 insertions(+), 849 deletions(-) create mode 100644 Application/IAirportRepository.cs create mode 100644 Application/ISettingsStore.cs create mode 100644 Application/SimulatorManager.cs create mode 100644 Domain/Airport.cs create mode 100644 Domain/FlightState.cs create mode 100644 Domain/IPointStateListener.cs create mode 100644 Domain/ISimulatorConnector.cs create mode 100644 Domain/Point.cs create mode 100644 Infrastructure/InMemory/InMemoryAirportRepository.cs create mode 100644 Infrastructure/Networking/AirportStreamMessageProcessor.cs create mode 100644 Infrastructure/Networking/AirportWebSocketManager.cs create mode 100644 Infrastructure/Networking/PointStateDispatcher.cs create mode 100644 Infrastructure/Settings/JsonSettingsStore.cs create mode 100644 Infrastructure/Simulators/MSFS/MsfsPointController.cs create mode 100644 Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs create mode 100644 Presentation/Behaviors/PasswordBoxAssistant.cs create mode 100644 Presentation/Converters/NullOrEmptyToVisibilityConverter.cs create mode 100644 Presentation/ViewModels/MainWindowViewModel.cs delete mode 100644 Services/AirportService.cs delete mode 100644 Services/ApiService.cs create mode 100644 Services/NearestAirportService.cs diff --git a/App.xaml b/App.xaml index 144e218..11c735b 100644 --- a/App.xaml +++ b/App.xaml @@ -1,9 +1,8 @@  + xmlns:local="clr-namespace:BARS_Client_V2"> - + diff --git a/App.xaml.cs b/App.xaml.cs index 8a9341f..ba5b398 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,14 +1,80 @@ -using System.Configuration; -using System.Data; -using System.Windows; +using System.Windows; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Http; +using BARS_Client_V2.Domain; +using BARS_Client_V2.Application; // Contains SimulatorManager; no conflict if fully qualified below +using BARS_Client_V2.Presentation.ViewModels; +using BARS_Client_V2.Services; namespace BARS_Client_V2 { /// /// Interaction logic for App.xaml /// - public partial class App : Application + public partial class App : System.Windows.Application { + private IHost? _host; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + _host = Host.CreateDefaultBuilder() + .ConfigureLogging(lb => lb.AddConsole()) + .ConfigureServices(services => + { + // Register simulator connectors (order: real MSFS then mock fallback) + services.AddSingleton(); + // Repositories / stores + services.AddSingleton(); + services.AddSingleton(); + // Core manager + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); // background stream + // HTTP + nearest airport service + services.AddHttpClient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + // Point state listeners (add MsfsPointController which also runs background loop) + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + // ViewModels + services.AddSingleton(); + // Windows + services.AddTransient(); + }) + .Build(); + + _host.Start(); + + var mainWindow = _host.Services.GetRequiredService(); + var vm = _host.Services.GetRequiredService(); + mainWindow.DataContext = vm; + // Wire server connection events + var wsMgr = _host.Services.GetRequiredService(); + var processor = _host.Services.GetRequiredService(); + // Force creation of dispatcher so it subscribes before messages arrive + _ = _host.Services.GetRequiredService(); + wsMgr.Connected += () => vm.NotifyServerConnected(); + wsMgr.ConnectionError += code => vm.NotifyServerError(code); + wsMgr.MessageReceived += msg => { vm.NotifyServerMessage(); _ = processor.ProcessAsync(msg); }; + mainWindow.Show(); + } + + protected override async void OnExit(ExitEventArgs e) + { + if (_host != null) + { + await _host.StopAsync(); + _host.Dispose(); + } + base.OnExit(e); + } } } diff --git a/Application/IAirportRepository.cs b/Application/IAirportRepository.cs new file mode 100644 index 0000000..4dc8a5c --- /dev/null +++ b/Application/IAirportRepository.cs @@ -0,0 +1,11 @@ +using BARS_Client_V2.Domain; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace BARS_Client_V2.Application; + +public interface IAirportRepository +{ + Task<(IReadOnlyList Items, int TotalCount)> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default); +} diff --git a/Application/ISettingsStore.cs b/Application/ISettingsStore.cs new file mode 100644 index 0000000..68ff406 --- /dev/null +++ b/Application/ISettingsStore.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace BARS_Client_V2.Application; + +public interface ISettingsStore +{ + Task LoadAsync(); + Task SaveAsync(ClientSettings settings); +} + +public sealed record ClientSettings(string? ApiToken, IDictionary? AirportPackages = null) +{ + public static ClientSettings Empty => new(null, new Dictionary()); +} diff --git a/Application/SimulatorManager.cs b/Application/SimulatorManager.cs new file mode 100644 index 0000000..25bc64c --- /dev/null +++ b/Application/SimulatorManager.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BARS_Client_V2.Domain; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace BARS_Client_V2.Application; + +/// +/// Manages available simulator connectors and maintains current flight state. +/// Runs as background hosted service streaming state. +/// +public sealed class SimulatorManager : BackgroundService +{ + private readonly IEnumerable _connectors; + private readonly ILogger _logger; + private readonly object _lock = new(); + private ISimulatorConnector? _active; + private FlightState? _latest; + + public SimulatorManager(IEnumerable connectors, ILogger logger) + { + _connectors = connectors; + _logger = logger; + } + + public FlightState? LatestState { get { lock (_lock) return _latest; } } + public ISimulatorConnector? ActiveConnector { get { lock (_lock) return _active; } } + + public async Task ActivateAsync(string simulatorId, CancellationToken ct = default) + { + var connector = _connectors.FirstOrDefault(c => string.Equals(c.SimulatorId, simulatorId, StringComparison.OrdinalIgnoreCase)); + if (connector == null) return false; + if (connector == _active && connector.IsConnected) return true; + + if (_active != null && _active.IsConnected) + { + try { await _active.DisconnectAsync(ct); } catch (Exception ex) { _logger.LogWarning(ex, "Error disconnecting previous simulator"); } + } + + if (await connector.ConnectAsync(ct)) + { + lock (_lock) _active = connector; + _logger.LogInformation("Activated simulator {sim}", connector.DisplayName); + return true; + } + return false; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Auto select first connector for now + var first = _connectors.FirstOrDefault(); + if (first != null) + { + await ActivateAsync(first.SimulatorId, stoppingToken); + } + + while (!stoppingToken.IsCancellationRequested) + { + var active = ActiveConnector; + if (active == null || !active.IsConnected) + { + await Task.Delay(1000, stoppingToken); + continue; + } + try + { + await foreach (var raw in active.StreamRawAsync(stoppingToken)) + { + lock (_lock) _latest = new FlightState(raw.Latitude, raw.Longitude, raw.OnGround); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "Error streaming flight state"); + // small backoff + await Task.Delay(2000, stoppingToken); + } + } + } +} diff --git a/BARS-Client-V2.csproj b/BARS-Client-V2.csproj index 095b471..4b2d7dc 100644 --- a/BARS-Client-V2.csproj +++ b/BARS-Client-V2.csproj @@ -14,6 +14,13 @@ + + + + + + + True diff --git a/Domain/Airport.cs b/Domain/Airport.cs new file mode 100644 index 0000000..b170acd --- /dev/null +++ b/Domain/Airport.cs @@ -0,0 +1,8 @@ +namespace BARS_Client_V2.Domain; + +/// +/// Basic airport domain model containing ICAO code and available scenery packages. +/// +public sealed record SceneryPackage(string Name); + +public sealed record Airport(string ICAO, IReadOnlyList SceneryPackages); diff --git a/Domain/FlightState.cs b/Domain/FlightState.cs new file mode 100644 index 0000000..0c1e8fa --- /dev/null +++ b/Domain/FlightState.cs @@ -0,0 +1,6 @@ +namespace BARS_Client_V2.Domain; + +/// +/// Raw flight state (position + ground flag) before nearest-airport lookup. +/// +public sealed record FlightState(double Latitude, double Longitude, bool OnGround); diff --git a/Domain/IPointStateListener.cs b/Domain/IPointStateListener.cs new file mode 100644 index 0000000..63e5896 --- /dev/null +++ b/Domain/IPointStateListener.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; + +namespace BARS_Client_V2.Domain; + +/// +/// Consumer of point state updates (e.g. a simulator-specific controller). +/// +public interface IPointStateListener +{ + /// + /// Called for every point state update (initial + deltas). + /// Should be non-blocking; heavy work should be queued internally. + /// + void OnPointStateChanged(PointState state); +} diff --git a/Domain/ISimulatorConnector.cs b/Domain/ISimulatorConnector.cs new file mode 100644 index 0000000..d7f0110 --- /dev/null +++ b/Domain/ISimulatorConnector.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace BARS_Client_V2.Domain; + +/// +/// Raw data emitted directly from simulator before nearest-airport resolution. +/// +public sealed record RawFlightSample(double Latitude, double Longitude, bool OnGround); + +/// +/// Abstraction for any flight simulator connector. +/// +public interface ISimulatorConnector +{ + string SimulatorId { get; } + string DisplayName { get; } + bool IsConnected { get; } + Task ConnectAsync(CancellationToken ct = default); + Task DisconnectAsync(CancellationToken ct = default); + /// + /// Stream raw flight samples (lat, lon, onGround). Yields at sensible intervals or when state changes. + /// + IAsyncEnumerable StreamRawAsync(CancellationToken ct = default); +} diff --git a/Domain/Point.cs b/Domain/Point.cs new file mode 100644 index 0000000..4d98bb9 --- /dev/null +++ b/Domain/Point.cs @@ -0,0 +1,23 @@ +namespace BARS_Client_V2.Domain; + +/// +/// Metadata for a controllable airfield object (stopbar / light / etc.). +/// +public sealed record PointMetadata( + string Id, + string AirportId, + string Type, + string Name, + double Latitude, + double Longitude, + string? Directionality, + string? Orientation, + string? Color, + bool Elevated, + bool Ihp +); + +/// +/// Current dynamic state of a point (on/off) plus metadata. +/// +public sealed record PointState(PointMetadata Metadata, bool IsOn, long TimestampMs); diff --git a/Infrastructure/InMemory/InMemoryAirportRepository.cs b/Infrastructure/InMemory/InMemoryAirportRepository.cs new file mode 100644 index 0000000..ff804b7 --- /dev/null +++ b/Infrastructure/InMemory/InMemoryAirportRepository.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BARS_Client_V2.Application; +using BARS_Client_V2.Domain; + +namespace BARS_Client_V2.Infrastructure.InMemory; + +/// +/// Temporary in-memory airport repository until real backend integration. +/// +internal sealed class InMemoryAirportRepository : IAirportRepository +{ + private readonly List _airports; + + public InMemoryAirportRepository() + { + _airports = new List + { + new("KJFK", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("Drzewiecki Design KJFK"), }), + new("EGLL", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("IniBuilds EGLL"), }), + new("KLAX", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("IniBuilds KLAX"), }), + new("KSEA", new []{ new SceneryPackage("Asobo Default") }), + new("LFPG", new []{ new SceneryPackage("Asobo Default") }), + new("EDDF", new []{ new SceneryPackage("Aerosoft EDDF") }), + new("EDDM", new []{ new SceneryPackage("SimWings EDDM") }), + new("CYYZ", new []{ new SceneryPackage("FSimStudios CYYZ") }), + new("YSSY", new []{ new SceneryPackage("FlyTampa YSSY") }), + new("KSFO", new []{ new SceneryPackage("FlightBeam KSFO"), new SceneryPackage("Asobo Default") }), + }; + } + + public Task<(IReadOnlyList Items, int TotalCount)> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default) + { + IEnumerable q = _airports; + if (!string.IsNullOrWhiteSpace(search)) + { + search = search.Trim().ToUpperInvariant(); + q = q.Where(a => a.ICAO.Contains(search, StringComparison.OrdinalIgnoreCase) || a.SceneryPackages.Any(p => p.Name.Contains(search, StringComparison.OrdinalIgnoreCase))); + } + var total = q.Count(); + var items = q + .OrderBy(a => a.ICAO) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + return Task.FromResult(((IReadOnlyList)items, total)); + } +} diff --git a/Infrastructure/Networking/AirportStreamMessageProcessor.cs b/Infrastructure/Networking/AirportStreamMessageProcessor.cs new file mode 100644 index 0000000..102fa1a --- /dev/null +++ b/Infrastructure/Networking/AirportStreamMessageProcessor.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using BARS_Client_V2.Domain; +using Microsoft.Extensions.Logging; + +namespace BARS_Client_V2.Infrastructure.Networking; + +/// +/// Parses messages from and maintains an in-memory cache +/// of point metadata + state. Fires events when point state changes are received. +/// +internal sealed class AirportStreamMessageProcessor +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _points = new(); // key: point id -> last state + private readonly ConcurrentDictionary _metadata = new(); // key: point id -> metadata only (for quick lookup) + + public event Action? PointStateChanged; // fired for each STATE_UPDATE (after metadata fetch if needed) + public event Action? InitialStateLoaded; // airport ICAO when initial state processed + + public AirportStreamMessageProcessor(IHttpClientFactory httpFactory, ILogger logger) + { + _httpClient = httpFactory.CreateClient(); + _logger = logger; + } + + public PointState? TryGetPoint(string id) => _points.TryGetValue(id, out var ps) ? ps : null; + + /// + /// Entry point invoked by websocket manager for each raw JSON message. + /// + public async Task ProcessAsync(string json, CancellationToken ct = default) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return; + if (!root.TryGetProperty("type", out var typeProp)) return; + var type = typeProp.GetString(); + switch (type) + { + case "INITIAL_STATE": + await HandleInitialStateAsync(root, ct); + break; + case "STATE_UPDATE": + await HandleStateUpdateAsync(root, ct); + break; + case "HEARTBEAT_ACK": + // nothing to do; + break; + default: + _logger.LogDebug("Unhandled stream message type {type}", type); + break; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to process stream message"); + } + } + + private async Task HandleInitialStateAsync(JsonElement root, CancellationToken ct) + { + if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return; + if (!data.TryGetProperty("objects", out var objects) || objects.ValueKind != JsonValueKind.Array) return; + + // Collect raw entries first + var tempList = new System.Collections.Generic.List<(string id, bool state, long ts)>(); + int idx = 0; + foreach (var obj in objects.EnumerateArray()) + { + if (obj.ValueKind != JsonValueKind.Object) continue; + var id = obj.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) continue; + var state = obj.TryGetProperty("state", out var stateProp) && stateProp.ValueKind == JsonValueKind.True; + var ts = obj.TryGetProperty("timestamp", out var tsProp) && tsProp.TryGetInt64(out var lts) ? lts : 0L; + tempList.Add((id!, state, ts)); + if (idx++ < 5) // sample a few for visibility + { + _logger.LogInformation("Initial object {id} state={state} ts={ts}", id, state, ts); + } + } + + if (tempList.Count == 0) return; + _logger.LogInformation("INITIAL_STATE contained {count} objects", tempList.Count); + + // Determine which IDs lack metadata + var missing = new System.Collections.Generic.List(); + foreach (var entry in tempList) + { + if (!_metadata.ContainsKey(entry.id)) missing.Add(entry.id); + } + if (missing.Count > 0) + { + _logger.LogInformation("Fetching metadata batch for {missingCount} new points", missing.Count); + // Batch in chunks of 100 (API limit) + const int batchSize = 100; + for (int i = 0; i < missing.Count; i += batchSize) + { + var slice = missing.GetRange(i, Math.Min(batchSize, missing.Count - i)); + await FetchMetadataBatchAsync(slice, ct); + _logger.LogInformation("Fetched metadata batch size {batch}", slice.Count); + } + } + + // Now create states + foreach (var (id, state, ts) in tempList) + { + if (_metadata.TryGetValue(id, out var meta)) + { + var ps = new PointState(meta, state, ts); + _points[id] = ps; + try { PointStateChanged?.Invoke(ps); } catch { } + } + else + { + // Fallback single fetch if batch missed or failed + var ps = await EnsureMetadataAndCreateStateAsync(id, state, ts, ct); + if (ps != null) + { + _points[id] = ps; + try { PointStateChanged?.Invoke(ps); } catch { } + } + } + } + + var airport = root.TryGetProperty("airport", out var aProp) ? aProp.GetString() : null; + if (!string.IsNullOrWhiteSpace(airport)) + { + try { InitialStateLoaded?.Invoke(airport!); } catch { } + } + } + + private async Task HandleStateUpdateAsync(JsonElement root, CancellationToken ct) + { + if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return; + var id = data.TryGetProperty("objectId", out var idProp) ? idProp.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) return; + var newState = data.TryGetProperty("state", out var stateProp) && stateProp.ValueKind == JsonValueKind.True; + var ts = root.TryGetProperty("timestamp", out var tsProp) && tsProp.TryGetInt64(out var lts) ? lts : 0L; + var ps = await EnsureMetadataAndCreateStateAsync(id!, newState, ts, ct); + if (ps != null) + { + _points[id!] = ps; + _logger.LogInformation("STATE_UPDATE {id} -> {state} ts={ts}", id, newState, ts); + try { PointStateChanged?.Invoke(ps); } catch { } + } + } + + private async Task EnsureMetadataAndCreateStateAsync(string id, bool isOn, long ts, CancellationToken ct) + { + if (_metadata.TryGetValue(id, out var metaFromCache)) + { + return new PointState(metaFromCache, isOn, ts); + } + try + { + var url = $"https://v2.stopbars.com/points/{id}"; + using var resp = await _httpClient.GetAsync(url, ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("Point metadata fetch failed for {id} status {status}", id, resp.StatusCode); + return null; + } + var json = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + if (TryParsePointMetadata(doc.RootElement, out var meta)) + { + _metadata[id] = meta!; + return new PointState(meta!, isOn, ts); + } + return null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error fetching point metadata for {id}", id); + return null; + } + } + + private async Task FetchMetadataBatchAsync(System.Collections.Generic.IReadOnlyCollection ids, CancellationToken ct) + { + if (ids.Count == 0) return; + try + { + var url = $"https://v2.stopbars.com/points?ids={string.Join(",", ids)}"; // API expects comma separated + using var resp = await _httpClient.GetAsync(url, ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("Batch metadata fetch failed status {status}", resp.StatusCode); + return; + } + var json = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("points", out var pts) || pts.ValueKind != JsonValueKind.Array) return; + foreach (var p in pts.EnumerateArray()) + { + if (TryParsePointMetadata(p, out var meta)) + { + _metadata[meta!.Id] = meta!; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Batch metadata fetch error"); + } + } + + private bool TryParsePointMetadata(JsonElement root, out PointMetadata? meta) + { + try + { + var id = root.TryGetProperty("id", out var idp) ? idp.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) { meta = null; return false; } + string airportId = root.TryGetProperty("airportId", out var ap) ? ap.GetString() ?? string.Empty : string.Empty; + string type = root.TryGetProperty("type", out var tp) ? tp.GetString() ?? string.Empty : string.Empty; + string name = root.TryGetProperty("name", out var np) ? np.GetString() ?? string.Empty : string.Empty; + double lat = 0, lon = 0; + if (root.TryGetProperty("coordinates", out var coord) && coord.ValueKind == JsonValueKind.Object) + { + lat = coord.TryGetProperty("lat", out var latProp) && latProp.TryGetDouble(out var dlat) ? dlat : 0; + lon = coord.TryGetProperty("lng", out var lonProp) && lonProp.TryGetDouble(out var dlon) ? dlon : 0; + } + string? directionality = root.TryGetProperty("directionality", out var dir) ? dir.GetString() : null; + string? orientation = root.TryGetProperty("orientation", out var ori) ? ori.GetString() : null; + string? color = root.TryGetProperty("color", out var col) ? col.GetString() : null; + bool elevated = root.TryGetProperty("elevated", out var el) && el.ValueKind == JsonValueKind.True; + bool ihp = root.TryGetProperty("ihp", out var ihpProp) && ihpProp.ValueKind == JsonValueKind.True; + meta = new PointMetadata(id, airportId, type, name, lat, lon, directionality, orientation, color, elevated, ihp); + return true; + } + catch + { + meta = null; return false; + } + } +} diff --git a/Infrastructure/Networking/AirportWebSocketManager.cs b/Infrastructure/Networking/AirportWebSocketManager.cs new file mode 100644 index 0000000..93830b4 --- /dev/null +++ b/Infrastructure/Networking/AirportWebSocketManager.cs @@ -0,0 +1,321 @@ +using System; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using BARS_Client_V2.Application; +using BARS_Client_V2.Services; + +namespace BARS_Client_V2.Infrastructure.Networking; + +/// +/// Maintains a WebSocket connection to the backend airport stream endpoint when conditions are met. +/// Conditions: +/// - Simulator connected +/// - Aircraft on ground +/// - Nearby airport resolved (ICAO length 4) +/// - Valid API token (starts with BARS_) +/// Disconnects when any condition no longer holds or airport changes. +/// +internal sealed class AirportWebSocketManager : BackgroundService +{ + private readonly SimulatorManager _simManager; + private readonly INearestAirportService _nearestAirportService; + private readonly ISettingsStore _settingsStore; + private readonly ILogger _logger; + private readonly object _sync = new(); + + private ClientWebSocket? _ws; + private string? _connectedAirport; + private string? _apiToken; // cached + private DateTime _lastTokenLoadUtc = DateTime.MinValue; + private Task? _receiveLoopTask; + private CancellationTokenSource? _receiveCts; + private DateTime _nextConnectAttemptUtc = DateTime.MinValue; // backoff gate + private Task? _heartbeatTask; + private string? _tokenUsedForConnection; + + public string? ConnectedAirport { get { lock (_sync) return _connectedAirport; } } + public bool IsConnected { get { lock (_sync) return _ws?.State == WebSocketState.Open; } } + public event Action? MessageReceived; + public event Action? Connected; + public event Action? ConnectionError; // status code (e.g. 401, 403) + + public AirportWebSocketManager( + SimulatorManager simManager, + INearestAirportService nearestAirportService, + ISettingsStore settingsStore, + ILogger logger) + { + _simManager = simManager; + _nearestAirportService = nearestAirportService; + _settingsStore = settingsStore; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EvaluateAsync(stoppingToken); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "Error in airport WebSocket manager loop"); + } + await Task.Delay(2000, stoppingToken); + } + await DisconnectAsync("Service stopping"); + } + + private async Task EvaluateAsync(CancellationToken ct) + { + var flight = _simManager.LatestState; + var connector = _simManager.ActiveConnector; + if (flight == null || connector == null || !connector.IsConnected) + { + await DisconnectAsync("No active simulator"); + return; + } + + if (!flight.OnGround) + { + await DisconnectAsync("Airborne"); + return; + } + + string? icao = _nearestAirportService.GetCachedNearest(flight.Latitude, flight.Longitude); + if (icao == null) + { + try { icao = await _nearestAirportService.ResolveAndCacheAsync(flight.Latitude, flight.Longitude, ct); } catch { } + } + + if (string.IsNullOrWhiteSpace(icao) || icao.Length != 4) + { + await DisconnectAsync("No nearby airport"); + return; + } + + var token = await GetApiTokenAsync(ct); + if (!IsValidToken(token)) + { + await DisconnectAsync("Missing/invalid API token"); + return; + } + + lock (_sync) + { + if (_ws != null && _ws.State == WebSocketState.Open && + string.Equals(_connectedAirport, icao, StringComparison.OrdinalIgnoreCase) && + string.Equals(_tokenUsedForConnection, token, StringComparison.Ordinal)) + { + return; // already connected to desired airport with same token + } + } + + // Respect backoff window after failures (e.g. 403 when user not authorized/connected) + if (DateTime.UtcNow < _nextConnectAttemptUtc) + { + return; + } + + await ConnectAsync(icao, token!, ct); + } + + private async Task GetApiTokenAsync(CancellationToken ct) + { + // Always reload to react quickly to user changes (cheap IO) + try + { + var settings = await _settingsStore.LoadAsync(); + _apiToken = settings.ApiToken; + _lastTokenLoadUtc = DateTime.UtcNow; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load settings for API token"); + } + return _apiToken; + } + + private static bool IsValidToken(string? token) => !string.IsNullOrWhiteSpace(token) && token.StartsWith("BARS_", StringComparison.Ordinal); + + private async Task ConnectAsync(string icao, string token, CancellationToken ct) + { + await DisconnectAsync("Switching airport/token"); + var uri = new Uri($"wss://v2.stopbars.com/connect?airport={icao.ToUpperInvariant()}&key={token}"); + var ws = new ClientWebSocket(); + try + { + _logger.LogInformation("Connecting airport WebSocket for {icao}", icao); + await ws.ConnectAsync(uri, ct); + if (ws.State != WebSocketState.Open) + { + _logger.LogWarning("Airport WebSocket not open after connect attempt (state {state})", ws.State); + ws.Dispose(); + _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(10); // generic backoff + return; + } + lock (_sync) + { + _ws = ws; + _connectedAirport = icao; + _receiveCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _receiveLoopTask = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token)); + _tokenUsedForConnection = token; + _heartbeatTask = Task.Run(() => HeartbeatLoopAsync(_receiveCts.Token)); + } + _logger.LogInformation("Airport WebSocket connected for {icao}", icao); + _nextConnectAttemptUtc = DateTime.MinValue; // reset on success + try { Connected?.Invoke(); } catch { } + } + catch (OperationCanceledException) + { + ws.Dispose(); + } + catch (WebSocketException wex) + { + _logger.LogWarning(wex, "Airport WebSocket connect failed for {icao}: {msg}", icao, wex.Message); + ws.Dispose(); + // If 403 (user not connected to VATSIM / not authorized) apply longer backoff to avoid spam + if (wex.Message.Contains("403")) + { + _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(10); + try { ConnectionError?.Invoke(403); } catch { } + } + else + { + _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(5); + if (wex.Message.Contains("401")) + { + try { ConnectionError?.Invoke(401); } catch { } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error connecting airport WebSocket for {icao}", icao); + ws.Dispose(); + _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(5); + try { ConnectionError?.Invoke(0); } catch { } + } + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var localWs = _ws; + if (localWs == null) return; + var buffer = new byte[8192]; + try + { + while (!ct.IsCancellationRequested && localWs.State == WebSocketState.Open) + { + var sb = new StringBuilder(); + WebSocketReceiveResult? result; + do + { + result = await localWs.ReceiveAsync(buffer, ct); + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogInformation("Airport WebSocket closed by server: {status} {desc}", result.CloseStatus, result.CloseStatusDescription); + await DisconnectAsync("Server closed"); + return; + } + if (result.MessageType == WebSocketMessageType.Text) + { + sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + } while (!result.EndOfMessage); + + if (sb.Length > 0) + { + var msg = sb.ToString(); + try { MessageReceived?.Invoke(msg); } catch { } + } + } + } + catch (OperationCanceledException) { } + catch (WebSocketException wex) + { + _logger.LogWarning(wex, "Airport WebSocket receive error"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in Airport WebSocket receive loop"); + } + finally + { + await DisconnectAsync("Receive loop ended"); + } + } + + private async Task DisconnectAsync(string reason) + { + ClientWebSocket? ws; + CancellationTokenSource? rcts; + lock (_sync) + { + ws = _ws; + rcts = _receiveCts; + _ws = null; + _receiveCts = null; + _receiveLoopTask = null; + _heartbeatTask = null; + if (_connectedAirport != null) + { + _logger.LogInformation("Disconnecting airport WebSocket ({airport}) - {reason}", _connectedAirport, reason); + } + _connectedAirport = null; + _tokenUsedForConnection = null; + } + try { rcts?.Cancel(); } catch { } + if (ws != null) + { + try + { + if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived) + { + // Attempt to send CLOSE message before closing websocket + try + { + var payload = Encoding.UTF8.GetBytes("{ \"type\": \"CLOSE\" }"); + using var sendCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await ws.SendAsync(payload, WebSocketMessageType.Text, true, sendCts.Token); + } + catch { } + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, reason, cts.Token); + } + } + catch { } + finally { ws.Dispose(); } + } + } + + private async Task HeartbeatLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(TimeSpan.FromSeconds(60), ct); } catch { break; } + if (ct.IsCancellationRequested) break; + ClientWebSocket? ws; + lock (_sync) ws = _ws; + if (ws == null || ws.State != WebSocketState.Open) continue; + try + { + var hb = Encoding.UTF8.GetBytes("{ \"type\": \"HEARTBEAT\" }"); + using var sendCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await ws.SendAsync(hb, WebSocketMessageType.Text, true, sendCts.Token); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Heartbeat send failed"); + } + } + } +} diff --git a/Infrastructure/Networking/PointStateDispatcher.cs b/Infrastructure/Networking/PointStateDispatcher.cs new file mode 100644 index 0000000..e5e50e1 --- /dev/null +++ b/Infrastructure/Networking/PointStateDispatcher.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using BARS_Client_V2.Domain; +using Microsoft.Extensions.Logging; + +namespace BARS_Client_V2.Infrastructure.Networking; + +/// +/// Subscribes once to AirportStreamMessageProcessor and forwards to all registered IPointStateListener instances. +/// +internal sealed class PointStateDispatcher +{ + private readonly IEnumerable _listeners; + private readonly ILogger _logger; + + public PointStateDispatcher(AirportStreamMessageProcessor processor, IEnumerable listeners, ILogger logger) + { + _listeners = listeners; + _logger = logger; + processor.PointStateChanged += OnPointStateChanged; + _logger.LogInformation("PointStateDispatcher initialized with {count} listeners", _listeners.Count()); + } + + private void OnPointStateChanged(PointState ps) + { + foreach (var l in _listeners) + { + try { l.OnPointStateChanged(ps); } catch (Exception ex) { _logger.LogDebug(ex, "Listener threw"); } + } + } +} diff --git a/Infrastructure/Settings/JsonSettingsStore.cs b/Infrastructure/Settings/JsonSettingsStore.cs new file mode 100644 index 0000000..f5844c7 --- /dev/null +++ b/Infrastructure/Settings/JsonSettingsStore.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using BARS_Client_V2.Application; + +namespace BARS_Client_V2.Infrastructure.Settings; + +internal sealed class JsonSettingsStore : ISettingsStore +{ + private readonly string _path; + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + // Additional entropy for DPAPI to slightly harden against trivial copy (still tied to user/machine scope) + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("BARS.Client.V2|ApiToken|v1"); + + private sealed class Persisted + { + public string? ApiToken { get; set; } + public Dictionary? AirportPackages { get; set; } + } + + public JsonSettingsStore() + { + var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var folder = Path.Combine(root, "BARS", "Client"); + Directory.CreateDirectory(folder); + _path = Path.Combine(folder, "settings.json"); + } + + public async Task LoadAsync() + { + if (!File.Exists(_path)) return ClientSettings.Empty; + try + { + var json = await File.ReadAllTextAsync(_path); + var p = JsonSerializer.Deserialize(json, Options); + if (p == null) return ClientSettings.Empty; + + string? token = null; + + // Prefer encrypted token if present + if (!string.IsNullOrWhiteSpace(p.ApiToken)) + { + try + { + var protectedBytes = Convert.FromBase64String(p.ApiToken); + var unprotected = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser); + token = Encoding.UTF8.GetString(unprotected); + } + catch + { + // If decryption fails, fall back to legacy plaintext if available + token = p.ApiToken; + } + } + else + { + // Legacy plaintext migration path + token = p.ApiToken; + } + + return new ClientSettings(token, p.AirportPackages ?? new()); + } + catch + { + return ClientSettings.Empty; + } + } + + public async Task SaveAsync(ClientSettings settings) + { + var p = new Persisted + { + AirportPackages = settings.AirportPackages != null ? new Dictionary(settings.AirportPackages) : new() + }; + + if (!string.IsNullOrWhiteSpace(settings.ApiToken)) + { + try + { + var plaintextBytes = Encoding.UTF8.GetBytes(settings.ApiToken); + var protectedBytes = ProtectedData.Protect(plaintextBytes, Entropy, DataProtectionScope.CurrentUser); + p.ApiToken = Convert.ToBase64String(protectedBytes); + } + catch + { + // Fallback: if encryption fails for some reason, we persist nothing rather than plaintext. + p.ApiToken = null; + } + } + var json = JsonSerializer.Serialize(p, Options); + await File.WriteAllTextAsync(_path, json); + } +} diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs new file mode 100644 index 0000000..436a4cc --- /dev/null +++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BARS_Client_V2.Domain; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace BARS_Client_V2.Infrastructure.Simulators.Msfs; + +/// +/// Queues point state changes and (eventually) reflects them inside MSFS by spawning / updating custom SimObjects. +/// Currently contains stubs for spawn/despawn until concrete SimObject titles & WASM variables are defined. +/// +internal sealed class MsfsPointController : BackgroundService, IPointStateListener +{ + private readonly ILogger _logger; + private readonly ISimulatorConnector _connector; // rely on same DI instance (assumed MSFS) + private readonly ConcurrentQueue _queue = new(); + private readonly Dictionary _spawned = new(); // pointId -> placeholder handle + private readonly SemaphoreSlim _semaphore = new(1, 1); + + private readonly int _maxObjects; + private readonly int _spawnPerSecond; + private DateTime _nextSpawnWindow = DateTime.UtcNow; + private int _spawnedThisWindow; + + // Stats + private long _totalReceived; + private long _totalSpawnAttempts; + private long _totalSpawned; + private long _totalDespawned; + private long _totalDeferredRate; + private long _totalSkippedCap; + private DateTime _lastSummary = DateTime.UtcNow; + + public MsfsPointController(IEnumerable connectors, ILogger logger, MsfsPointControllerOptions? options = null) + { + // Select the MSFS connector (first one with SimulatorId == MSFS or just first) + _connector = connectors.FirstOrDefault(c => string.Equals(c.SimulatorId, "MSFS", StringComparison.OrdinalIgnoreCase)) + ?? connectors.First(); + _logger = logger; + options ??= new MsfsPointControllerOptions(); + _maxObjects = options.MaxObjects; + _spawnPerSecond = options.SpawnPerSecond; + } + + public void OnPointStateChanged(PointState state) + { + _queue.Enqueue(state); + var qLen = _queue.Count; + var meta = state.Metadata; + Interlocked.Increment(ref _totalReceived); + _logger.LogInformation("[Recv] {id} on={on} type={type} name={name} airport={apt} lat={lat:F6} lon={lon:F6} queue={q}", + meta.Id, state.IsOn, meta.Type, meta.Name, meta.AirportId, meta.Latitude, meta.Longitude, qLen); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("MsfsPointController started (max={max} rate/s={rate})", _maxObjects, _spawnPerSecond); + while (!stoppingToken.IsCancellationRequested) + { + try + { + if (!_connector.IsConnected) + { + await Task.Delay(1000, stoppingToken); + continue; + } + if (_queue.TryDequeue(out var ps)) + { + _logger.LogTrace("Dequeued {id} on={on}", ps.Metadata.Id, ps.IsOn); + await ProcessAsync(ps, stoppingToken); + } + else + { + await Task.Delay(50, stoppingToken); // idle backoff + } + + // Periodic summary every 30s + if ((DateTime.UtcNow - _lastSummary) > TimeSpan.FromSeconds(30)) + { + _lastSummary = DateTime.UtcNow; + _logger.LogInformation("[Summary] received={rec} spawned={sp} active={active} despawned={des} deferredRate={def} skippedCap={cap} queue={q}", + _totalReceived, _totalSpawned, _spawned.Count, _totalDespawned, _totalDeferredRate, _totalSkippedCap, _queue.Count); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error in MsfsPointController loop"); + await Task.Delay(500, stoppingToken); + } + } + } + + private async Task ProcessAsync(PointState ps, CancellationToken ct) + { + var id = ps.Metadata.Id; + var exists = _spawned.ContainsKey(id); + if (ps.IsOn) + { + if (!exists) + { + if (_spawned.Count >= _maxObjects) + { + // Skip spawning to respect cap + Interlocked.Increment(ref _totalSkippedCap); + _logger.LogWarning("[SpawnSkip:Cap] {id} cap={cap} active={active}", id, _maxObjects, _spawned.Count); + return; + } + if (!CanSpawnNow()) + { + // requeue later to respect rate limit + Interlocked.Increment(ref _totalDeferredRate); + _logger.LogDebug("[SpawnDefer:Rate] {id} windowRemaining={rem}", id, Math.Max(0, _spawnPerSecond - _spawnedThisWindow)); + _queue.Enqueue(ps); + return; + } + var handle = await SpawnStubAsync(ps, ct); + if (handle != null) + { + _spawned[id] = handle; + Interlocked.Increment(ref _totalSpawned); + _logger.LogInformation("[Spawn] {id} name={name} type={type} active={count}", id, ps.Metadata.Name, ps.Metadata.Type, _spawned.Count); + } + } + else + { + // Future: update existing object light state (stub) + _logger.LogDebug("[Update] {id} state={state}", id, ps.IsOn); + } + } + else + { + if (exists) + { + if (_spawned.TryGetValue(id, out var handle)) + { + await DespawnStubAsync(handle, ct); + _spawned.Remove(id); + Interlocked.Increment(ref _totalDespawned); + _logger.LogInformation("[Despawn] {id} remaining={count}", id, _spawned.Count); + } + } + } + } + + private bool CanSpawnNow() + { + var now = DateTime.UtcNow; + if (now > _nextSpawnWindow) + { + _nextSpawnWindow = now.AddSeconds(1); + _spawnedThisWindow = 0; + } + if (_spawnedThisWindow < _spawnPerSecond) + { + _spawnedThisWindow++; + return true; + } + return false; + } + + private Task SpawnStubAsync(PointState ps, CancellationToken ct) + { + // Placeholder: integrate SimConnect AICreateSimulatedObject with custom SimObject title. + _logger.LogTrace("Spawn stub for {id} at {lat}/{lon} state={state}", ps.Metadata.Id, ps.Metadata.Latitude, ps.Metadata.Longitude, ps.IsOn); + return Task.FromResult(new { id = ps.Metadata.Id }); + } + + private Task DespawnStubAsync(object handle, CancellationToken ct) + { + // Placeholder: integrate SimConnect AIDeleteObject. + _logger.LogTrace("Despawn stub for {handle}", handle); + return Task.CompletedTask; + } +} + +internal sealed class MsfsPointControllerOptions +{ + public int MaxObjects { get; init; } = 1000; + public int SpawnPerSecond { get; init; } = 15; +} diff --git a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs new file mode 100644 index 0000000..a5fb2f4 --- /dev/null +++ b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using BARS_Client_V2.Domain; +using Microsoft.Extensions.Logging; +using SimConnect.NET; + +namespace BARS_Client_V2.Infrastructure.Simulators.Msfs; + +/// +/// MSFS Connection implementation using SimConnect.NET. +/// +public sealed class MsfsSimulatorConnector : ISimulatorConnector, IDisposable +{ + private readonly ILogger _logger; + private SimConnectClient? _client; + private const int PollDelayMs = 15_000; + + public MsfsSimulatorConnector(ILogger logger) + { + _logger = logger; + } + + public string SimulatorId => "MSFS"; + public string DisplayName => "Microsoft Flight Simulator"; + public bool IsConnected => _client?.IsConnected == true; + + public async Task ConnectAsync(CancellationToken ct = default) + { + if (IsConnected) return true; + try + { + var client = new SimConnectClient("BARS Client"); + await client.ConnectAsync(); + if (client.IsConnected) + { + _client = client; + _logger.LogInformation("Connected to MSFS via SimConnect.NET"); + } + else + { + client.Dispose(); + _logger.LogWarning("Failed to connect to MSFS (client not connected after ConnectAsync)"); + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "MSFS connection attempt failed"); + } + return IsConnected; + } + + public Task DisconnectAsync(CancellationToken ct = default) + { + var client = Interlocked.Exchange(ref _client, null); + if (client != null) + { + try { client.Dispose(); } + catch (Exception ex) { _logger.LogDebug(ex, "Error disposing SimConnect client"); } + } + return Task.CompletedTask; + } + + public async IAsyncEnumerable StreamRawAsync([EnumeratorCancellation] CancellationToken ct = default) + { + if (!IsConnected) yield break; + while (!ct.IsCancellationRequested && IsConnected) + { + var sample = await TryGetSampleAsync(ct); + if (sample is RawFlightSample s) yield return s; + try { await Task.Delay(PollDelayMs, ct); } catch { yield break; } + } + } + + private async Task TryGetSampleAsync(CancellationToken ct) + { + var client = _client; + if (client == null) return null; + try + { + var svm = client.SimVars; + if (svm == null) return null; + double lat = await svm.GetAsync("PLANE LATITUDE", "degrees"); + double lon = await svm.GetAsync("PLANE LONGITUDE", "degrees"); + bool onGround = (await svm.GetAsync("SIM ON GROUND", "bool")) == 1; + return new RawFlightSample(lat, lon, onGround); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogDebug(ex, "MSFS sample retrieval failed (will retry)"); + return null; + } + } + + public void Dispose() => _ = DisconnectAsync(); +} diff --git a/MainWindow.xaml b/MainWindow.xaml index edde467..e159564 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -5,103 +5,129 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:BARS_Client_V2" mc:Ignorable="d" - Title="BARS Client" Height="650" Width="600" + Title="BARS Client" + Height="674" + Width="600" Background="#1E1E1E" FontFamily="Segoe UI" WindowStartupLocation="CenterScreen" ResizeMode="NoResize"> - + - + + + From="0.0" + To="1.0" + Duration="0:0:0.3"> - + - + + From="0,20,0,0" + To="0,0,0,0" + Duration="0:0:0.3"> - + - + - + - + - + - + - - + - - + - - + @@ -406,115 +525,174 @@ - + - + - + - - + + - - - - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - + + - + - - - - - -