diff --git a/.gitignore b/.gitignore
index 2945593..c6d1409 100644
--- a/.gitignore
+++ b/.gitignore
@@ -364,3 +364,6 @@ appsettings.*.json
.Trashes
ehthumbs.db
Thumbs.db
+
+# Build
+dist/
\ No newline at end of file
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..a31dc22 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -1,14 +1,98 @@
-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;
+using System.Windows.Threading;
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.ClearProviders();
+ lb.AddConsole(); // Console (visible if app started from console / debug output window)
+ lb.AddDebug(); // VS Debug Output window
+ lb.AddEventSourceLogger(); // ETW / PerfView if needed
+ lb.SetMinimumLevel(LogLevel.Trace);
+ })
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService()); // background stream
+ services.AddHttpClient();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddHostedService();
+ services.AddSingleton();
+ services.AddSingleton(sp =>
+ {
+ var connectors = sp.GetServices();
+ var logger = sp.GetRequiredService>();
+ var hub = sp.GetRequiredService();
+ var simManager = sp.GetRequiredService();
+ return new BARS_Client_V2.Infrastructure.Simulators.Msfs.MsfsPointController(connectors, logger, hub, simManager, null);
+ });
+ services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddSingleton();
+ services.AddTransient();
+ })
+ .Build();
+
+
+ var mainWindow = _host.Services.GetRequiredService();
+ var vm = _host.Services.GetRequiredService();
+ mainWindow.DataContext = vm;
+ var wsMgr = _host.Services.GetRequiredService();
+ var hub = _host.Services.GetRequiredService();
+ wsMgr.AttachHub(hub);
+ wsMgr.Connected += () => vm.NotifyServerConnected();
+ wsMgr.ConnectionError += code => vm.NotifyServerError(code);
+ wsMgr.MessageReceived += msg => { vm.NotifyServerMessage(); _ = hub.ProcessAsync(msg); };
+ var pointController = _host.Services.GetRequiredService();
+ wsMgr.Disconnected += reason => { pointController.Suspend(); _ = pointController.DespawnAllAsync(); };
+ wsMgr.Connected += () => pointController.Resume();
+ mainWindow.Show();
+ Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, async () =>
+ {
+ try
+ {
+ if (_host != null)
+ {
+ await _host.StartAsync();
+ }
+ }
+ catch
+ {
+ // Swallow to avoid UI crash;
+ }
+ });
+ }
+
+ protected override async void OnExit(ExitEventArgs e)
+ {
+ if (_host != null)
+ {
+ try { await _host.StopAsync(); } catch { }
+ _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..479c9d1
--- /dev/null
+++ b/Application/SimulatorManager.cs
@@ -0,0 +1,93 @@
+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;
+
+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)
+ {
+ var first = _connectors.FirstOrDefault();
+ if (first != null)
+ {
+ await ActivateAsync(first.SimulatorId, stoppingToken);
+ }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var active = ActiveConnector;
+ if (active == null || !active.IsConnected)
+ {
+ // Attempt reconnect periodically when disconnected
+ if (first != null)
+ {
+ try
+ {
+ await ActivateAsync(first.SimulatorId, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Reconnect attempt failed");
+ }
+ }
+ await Task.Delay(2000, 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..909362e 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -8,12 +8,40 @@
enable
true
appicon.ico
+ BARS Client
+ BARS Client
+ 2.0.0
+ 2.0.0
+ 2.0.0
+ BARS-Client
+
+ false
+ true
+ false
+ none
+ true
+ false
+ false
+ win-x64
+
+
+
+
+
+
+
+
+
+
+
+
+
True
diff --git a/Domain/Airport.cs b/Domain/Airport.cs
new file mode 100644
index 0000000..52a30f2
--- /dev/null
+++ b/Domain/Airport.cs
@@ -0,0 +1,5 @@
+namespace BARS_Client_V2.Domain;
+
+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..2e88e7b
--- /dev/null
+++ b/Domain/FlightState.cs
@@ -0,0 +1,3 @@
+namespace BARS_Client_V2.Domain;
+
+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..72d487d
--- /dev/null
+++ b/Domain/IPointStateListener.cs
@@ -0,0 +1,8 @@
+using System.Threading.Tasks;
+
+namespace BARS_Client_V2.Domain;
+
+public interface IPointStateListener
+{
+ void OnPointStateChanged(PointState state);
+}
diff --git a/Domain/ISimulatorConnector.cs b/Domain/ISimulatorConnector.cs
new file mode 100644
index 0000000..dd08445
--- /dev/null
+++ b/Domain/ISimulatorConnector.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BARS_Client_V2.Domain;
+
+public sealed record RawFlightSample(double Latitude, double Longitude, bool OnGround);
+
+public interface ISimulatorConnector
+{
+ string SimulatorId { get; }
+ string DisplayName { get; }
+ bool IsConnected { get; }
+ Task ConnectAsync(CancellationToken ct = default);
+ Task DisconnectAsync(CancellationToken ct = default);
+ IAsyncEnumerable StreamRawAsync(CancellationToken ct = default);
+}
diff --git a/Domain/Point.cs b/Domain/Point.cs
new file mode 100644
index 0000000..ee0c4ad
--- /dev/null
+++ b/Domain/Point.cs
@@ -0,0 +1,17 @@
+namespace BARS_Client_V2.Domain;
+
+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
+);
+
+public sealed record PointState(PointMetadata Metadata, bool IsOn, long TimestampMs);
diff --git a/Infrastructure/Networking/AirportStateHub.cs b/Infrastructure/Networking/AirportStateHub.cs
new file mode 100644
index 0000000..12a2fd2
--- /dev/null
+++ b/Infrastructure/Networking/AirportStateHub.cs
@@ -0,0 +1,473 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using BARS_Client_V2.Domain;
+using Microsoft.Extensions.Logging;
+using BARS_Client_V2.Services;
+
+namespace BARS_Client_V2.Infrastructure.Networking;
+
+internal sealed class AirportStateHub
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _metadata = new(); // pointId -> metadata
+ private readonly ConcurrentDictionary _states = new(); // pointId -> current state
+ private readonly ConcurrentDictionary> _layouts = new(); // pointId -> lights
+ private readonly SemaphoreSlim _mapLock = new(1, 1);
+ private string? _mapAirport; // airport code currently loaded
+ private DateTime _lastSnapshotUtc = DateTime.MinValue;
+ private readonly TimeSpan _snapshotStaleAfter = TimeSpan.FromSeconds(25); // if no snapshot / updates for this long, re-request
+ private DateTime _lastUpdateUtc = DateTime.MinValue;
+ private readonly Timer _reconcileTimer;
+ private volatile bool _requestInFlight;
+ private DateTime _lastSnapshotRequestUtc = DateTime.MinValue;
+ private readonly TimeSpan _snapshotRequestMinInterval = TimeSpan.FromSeconds(20);
+
+ public AirportStateHub(IHttpClientFactory httpFactory, ILogger logger)
+ {
+ _httpClient = httpFactory.CreateClient();
+ _logger = logger;
+ _reconcileTimer = new Timer(_ => ReconcileLoop(), null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
+ // React to scenery package changes while connected so users don't need to restart the client.
+ try { SceneryService.Instance.PackageChanged += OnSceneryPackageChanged; } catch { }
+ }
+
+ public event Action? MapLoaded; // airport
+ public event Action? PointStateChanged; // fired for initial + updates
+ public event Action? OutboundPacketRequested; // (airport, rawJson)
+
+ public bool TryGetPoint(string id, out PointState state) => _states.TryGetValue(id, out state!);
+ public bool TryGetLightLayout(string id, out IReadOnlyList lights)
+ {
+ if (_layouts.TryGetValue(id, out var list)) { lights = list; return true; }
+ lights = Array.Empty();
+ return false;
+ }
+
+ 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_SNAPSHOT":
+ await HandleSnapshotAsync(root, ct);
+ break;
+ case "STATE_UPDATE":
+ HandleStateUpdate(root);
+ break;
+ case "HEARTBEAT_ACK":
+ break;
+ default:
+ _logger.LogTrace("Unhandled message type {type}", type);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "AirportStateHub message parse failed");
+ }
+ }
+
+ ///
+ /// Sends a STOPBAR_CROSSING packet over the airport websocket for the currently loaded airport.
+ /// Server expects the objectId (BarsId) of the stopbar line being crossed.
+ ///
+ /// Bars object id of the stopbar line that was crossed.
+ public void SendStopbarCrossing(string objectId)
+ {
+ if (string.IsNullOrWhiteSpace(objectId)) return;
+ var packet = JsonSerializer.Serialize(new { type = "STOPBAR_CROSSING", data = new { objectId = objectId } });
+ try { OutboundPacketRequested?.Invoke(_mapAirport ?? string.Empty, packet); } catch { }
+ _logger.LogInformation("Sent STOPBAR_CROSSING objectId={id}", objectId);
+ }
+
+ private async Task HandleSnapshotAsync(JsonElement root, CancellationToken ct)
+ {
+ if (!root.TryGetProperty("airport", out var aProp) || aProp.ValueKind != JsonValueKind.String) return;
+ var airport = aProp.GetString();
+ if (string.IsNullOrWhiteSpace(airport)) return;
+ await EnsureMapLoadedAsync(airport!, 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;
+ int applied = 0;
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ 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;
+ seen.Add(id!);
+ var on = obj.TryGetProperty("state", out var stp) && stp.ValueKind == JsonValueKind.True;
+ var ts = obj.TryGetProperty("timestamp", out var tsp) && tsp.TryGetInt64(out var lts) ? lts : 0L;
+ if (!_metadata.TryGetValue(id!, out var meta))
+ {
+ meta = new PointMetadata(id!, airport!, "", id!, 0, 0, null, null, null, false, false);
+ _metadata[id!] = meta;
+ }
+ var ps = new PointState(meta, on, ts);
+ _states[id!] = ps;
+ applied++;
+ try { PointStateChanged?.Invoke(ps); } catch { }
+ }
+ _lastSnapshotUtc = DateTime.UtcNow;
+ _lastUpdateUtc = _lastSnapshotUtc;
+ // Remove orphan states not present in snapshot (object deleted server-side)
+ var removed = 0;
+ foreach (var existing in _states.Keys.ToList())
+ {
+ if (!seen.Contains(existing))
+ {
+ if (_states.TryRemove(existing, out _)) removed++;
+ }
+ }
+ if (removed > 0)
+ {
+ _logger.LogInformation("Snapshot removed {removed} stale objects for {apt}", removed, airport);
+ }
+ _logger.LogInformation("STATE_SNAPSHOT applied objects={applied} removed={removed} airport={apt}", applied, removed, airport);
+ }
+
+ private async Task HandleInitialStateAsync(JsonElement root, CancellationToken ct)
+ {
+ if (!root.TryGetProperty("airport", out var aProp) || aProp.ValueKind != JsonValueKind.String) return;
+ var airport = aProp.GetString();
+ if (string.IsNullOrWhiteSpace(airport)) return;
+ await EnsureMapLoadedAsync(airport!, 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;
+ int count = 0;
+ int ignoredUnknown = 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 on = obj.TryGetProperty("state", out var stp) && stp.ValueKind == JsonValueKind.True;
+ var ts = obj.TryGetProperty("timestamp", out var tsp) && tsp.TryGetInt64(out var lts) ? lts : 0L;
+ if (!_metadata.TryGetValue(id!, out var meta))
+ {
+ // Ignore objects not present in map to avoid spawning at (0,0). We'll request a snapshot soon if map is outdated.
+ ignoredUnknown++;
+ continue;
+ }
+ var ps = new PointState(meta, on, ts);
+ _states[id!] = ps;
+ count++;
+ try { PointStateChanged?.Invoke(ps); } catch { }
+ }
+ _lastUpdateUtc = DateTime.UtcNow;
+ _logger.LogInformation("INITIAL_STATE processed {count} points (ignoredUnknown={ignored}) for {apt}", count, ignoredUnknown, airport);
+ if (ignoredUnknown > 0)
+ {
+ // Force snapshot sooner (maybe map changed). Bump lastSnapshot to trigger reconcile check.
+ _lastSnapshotUtc = DateTime.MinValue;
+ }
+ }
+
+ private void HandleStateUpdate(JsonElement root)
+ {
+ 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 on = data.TryGetProperty("state", out var stp) && stp.ValueKind == JsonValueKind.True;
+ var ts = root.TryGetProperty("timestamp", out var tsp) && tsp.TryGetInt64(out var lts) ? lts : 0L;
+ if (!_metadata.TryGetValue(id!, out var meta))
+ {
+ // Skip updates for unknown objects rather than creating placeholder at (0,0)
+ _logger.LogTrace("Skipping update for unknown object {id}", id);
+ return;
+ }
+ var ps = new PointState(meta, on, ts);
+ _states[id!] = ps;
+ _lastUpdateUtc = DateTime.UtcNow;
+ try { PointStateChanged?.Invoke(ps); } catch { }
+ }
+
+ private async Task EnsureMapLoadedAsync(string airport, CancellationToken ct)
+ {
+ if (string.Equals(_mapAirport, airport, StringComparison.OrdinalIgnoreCase)) return;
+ await _mapLock.WaitAsync(ct);
+ try
+ {
+ if (string.Equals(_mapAirport, airport, StringComparison.OrdinalIgnoreCase)) return;
+ await LoadMapInternalAsync(airport, ct);
+ }
+ finally
+ {
+ _mapLock.Release();
+ }
+ }
+
+ ///
+ /// Force reload current airport map after scenery package change.
+ ///
+ private async void OnSceneryPackageChanged(string icao, string newPackage)
+ {
+ try
+ {
+ // Only reload if we're currently on that airport
+ if (!string.Equals(_mapAirport, icao, StringComparison.OrdinalIgnoreCase)) return;
+ _logger.LogInformation("Scenery package changed for {apt} -> {pkg}; reloading map", icao, newPackage);
+ await _mapLock.WaitAsync();
+ try
+ {
+ // Clear current map caches and state, then load again using the new selection
+ _metadata.Clear();
+ _layouts.Clear();
+ _states.Clear();
+ _lastSnapshotUtc = DateTime.MinValue;
+ _lastUpdateUtc = DateTime.MinValue;
+ await LoadMapInternalAsync(icao, CancellationToken.None);
+ // Immediately request a fresh snapshot so clients rebuild using the new layout
+ _ = RequestSnapshotAsync(icao);
+ }
+ finally { _mapLock.Release(); }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to hot-reload map for {apt} after package change", icao);
+ }
+ }
+
+ private async Task LoadMapInternalAsync(string airport, CancellationToken ct)
+ {
+ // Determine currently selected scenery package for this airport (if any). If none selected yet, auto-select first available.
+ string package = string.Empty;
+ List? airportPackages = null; // cache list for fallback retry
+ try
+ {
+ package = SceneryService.Instance.GetSelectedPackage(airport);
+ var all = await SceneryService.Instance.GetAvailablePackagesAsync();
+ if (all.TryGetValue(airport, out var pkgList) && pkgList.Count > 0)
+ {
+ airportPackages = pkgList.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+ if (string.IsNullOrWhiteSpace(package))
+ {
+ if (airportPackages == null || airportPackages.Count == 0)
+ {
+ _logger.LogWarning("No packages found for airport {apt} when attempting to auto-select; aborting map load", airport);
+ return;
+ }
+ package = airportPackages.First();
+ SceneryService.Instance.SetSelectedPackage(airport, package);
+ _logger.LogInformation("Auto-selected first package '{pkg}' for airport {apt}", package, airport);
+ }
+ else
+ {
+ // Resolve selection to one of the available package names (case-insensitive, supports substring like "2024").
+ if (airportPackages != null && airportPackages.Count > 0)
+ {
+ var originalSelection = package;
+ var exact = airportPackages.FirstOrDefault(p => string.Equals(p, originalSelection, StringComparison.OrdinalIgnoreCase));
+ if (!string.IsNullOrEmpty(exact))
+ {
+ package = exact; // normalize casing
+ }
+ else
+ {
+ var partial = airportPackages.FirstOrDefault(p => p.IndexOf(originalSelection, StringComparison.OrdinalIgnoreCase) >= 0);
+ if (!string.IsNullOrEmpty(partial)) package = partial;
+ }
+ // If still not matched, fall back to first available.
+ if (!airportPackages.Contains(package, StringComparer.OrdinalIgnoreCase))
+ {
+ var fallback = airportPackages.First();
+ _logger.LogWarning("Previously selected package '{old}' for {apt} no longer available; falling back to '{fb}'", originalSelection, airport, fallback);
+ package = fallback;
+ try { SceneryService.Instance.SetSelectedPackage(airport, package); } catch { }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed determining package for airport {apt}", airport);
+ return;
+ }
+
+ async Task TryFetchAsync(string pkg, bool isRetry)
+ {
+ var safePkgInner = Uri.EscapeDataString(pkg);
+ var urlInner = $"https://v2.stopbars.com/maps/{airport}/packages/{safePkgInner}/latest";
+ _logger.LogInformation("Fetching airport XML map {apt} package={pkg} url={url} retry={retry}", airport, pkg, urlInner, isRetry);
+ using var respInner = await _httpClient.GetAsync(urlInner, ct);
+ if (!respInner.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Airport map fetch failed {status} apt={apt} package={pkg} retry={retry}", respInner.StatusCode, airport, pkg, isRetry);
+ if (!isRetry && respInner.StatusCode == HttpStatusCode.NotFound && airportPackages != null && airportPackages.Count > 0)
+ {
+ var first = airportPackages.First();
+ if (!string.Equals(first, pkg, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Retrying map fetch with fallback first package '{fb}' for {apt}", first, airport);
+ try { SceneryService.Instance.SetSelectedPackage(airport, first); } catch { }
+ package = first;
+ return await TryFetchAsync(first, true);
+ }
+ }
+ return false;
+ }
+ var xmlInner = await respInner.Content.ReadAsStringAsync(ct);
+ try
+ {
+ var docInner = XDocument.Parse(xmlInner);
+ ParseMap(docInner, airport);
+ _mapAirport = airport;
+ _lastSnapshotUtc = DateTime.MinValue; // force fresh snapshot soon
+ try { MapLoaded?.Invoke(airport); } catch { }
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error parsing airport map {apt} package={pkg}", airport, pkg);
+ return false;
+ }
+ }
+
+ await TryFetchAsync(package, false);
+ }
+
+ private void ParseMap(XDocument doc, string airport)
+ {
+ var root = doc.Root;
+ if (root == null || root.Name.LocalName != "BarsLights") return;
+ int barsObjectElements = 0; // raw BarsObject element count (including duplicates)
+ int uniquePointIds = 0; // unique ids encountered
+ int duplicateMerged = 0; // number of BarsObject elements that were merged into an existing id
+ int lightCount = 0; // total lights (after merge, counting every processed)
+
+ foreach (var obj in root.Elements("BarsObject"))
+ {
+ barsObjectElements++;
+ var id = obj.Attribute("id")?.Value;
+ if (string.IsNullOrWhiteSpace(id)) continue;
+ var type = obj.Attribute("type")?.Value ?? string.Empty;
+ var objProps = obj.Element("Properties");
+ var color = objProps?.Element("Color")?.Value;
+ var orientation = objProps?.Element("Orientation")?.Value;
+
+ // Parse lights for this element
+ var newLights = new List();
+ foreach (var le in obj.Elements("Light"))
+ {
+ var posText = le.Element("Position")?.Value;
+ if (!TryParseLatLon(posText, out var lat, out var lon)) continue;
+ double? hdg = null;
+ var headingStr = le.Element("Heading")?.Value;
+ if (double.TryParse(headingStr, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hdgVal)) hdg = hdgVal;
+ var lColor = le.Element("Properties")?.Element("Color")?.Value ?? color;
+ int? stateId = null; if (int.TryParse(le.Attribute("stateId")?.Value, out var sidVal)) stateId = sidVal;
+ int? offStateId = null; if (int.TryParse(le.Attribute("offStateId")?.Value, out var offSidVal)) offStateId = offSidVal;
+ newLights.Add(new LightLayout(lat, lon, hdg, lColor, stateId, offStateId));
+ }
+
+ if (_layouts.TryGetValue(id!, out var existingLights))
+ {
+ // Merge duplicate definition: append lights
+ existingLights.AddRange(newLights);
+ duplicateMerged++;
+ // Recompute representative lat/lon across ALL lights now associated with this id
+ if (existingLights.Count > 0)
+ {
+ var avgLat = existingLights.Average(l => l.Latitude);
+ var avgLon = existingLights.Average(l => l.Longitude);
+ if (_metadata.TryGetValue(id!, out var existingMeta))
+ {
+ _metadata[id!] = existingMeta with { Latitude = avgLat, Longitude = avgLon, Type = type, Orientation = orientation, Color = color };
+ }
+ }
+ _logger.LogDebug("Merged duplicate BarsObject id={id} totalLights={cnt}", id, existingLights.Count);
+ }
+ else
+ {
+ // First time we see this id
+ uniquePointIds++;
+ if (newLights.Count > 0)
+ {
+ _layouts[id!] = newLights;
+ }
+ double repLat = 0, repLon = 0;
+ if (newLights.Count > 0)
+ {
+ repLat = newLights.Average(l => l.Latitude);
+ repLon = newLights.Average(l => l.Longitude);
+ }
+ var meta = new PointMetadata(id!, airport, type, id!, repLat, repLon, null, orientation, color, false, false);
+ _metadata[id!] = meta;
+ }
+
+ lightCount += newLights.Count;
+ }
+
+ _logger.LogInformation("Parsed map {apt} BarsObjects={raw} uniquePoints={uniq} duplicatesMerged={dups} lights={lights}", airport, barsObjectElements, uniquePointIds, duplicateMerged, lightCount);
+ }
+
+ private bool TryParseLatLon(string? csv, out double lat, out double lon)
+ {
+ lat = lon = 0;
+ if (string.IsNullOrWhiteSpace(csv)) return false;
+ var parts = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length != 2) return false;
+ var ok1 = double.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out lat);
+ var ok2 = double.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out lon);
+ return ok1 && ok2;
+ }
+
+ public sealed record LightLayout(double Latitude, double Longitude, double? Heading, string? Color, int? StateId, int? OffStateId);
+
+ private void ReconcileLoop()
+ {
+ try
+ {
+ if (_mapAirport == null) return; // not connected yet
+ var now = DateTime.UtcNow;
+ var sinceUpdate = now - _lastUpdateUtc;
+ if (sinceUpdate > _snapshotStaleAfter && !_requestInFlight)
+ {
+ _ = RequestSnapshotAsync(_mapAirport); // fire and forget
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "ReconcileLoop failed");
+ }
+ }
+
+ private Task RequestSnapshotAsync(string airport)
+ {
+ if (_requestInFlight) return Task.CompletedTask;
+ if ((DateTime.UtcNow - _lastSnapshotRequestUtc) < _snapshotRequestMinInterval) return Task.CompletedTask;
+ _requestInFlight = true;
+ try
+ {
+ // The websocket layer should allow sending raw text frames. We'll emit a GET_STATE packet.
+ var packet = $"{{ \"type\": \"GET_STATE\", \"airport\": \"{airport}\", \"timestamp\": {DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()} }}";
+ _lastSnapshotRequestUtc = DateTime.UtcNow;
+ _logger.LogInformation("Requesting state snapshot for {apt}", airport);
+ try { OutboundPacketRequested?.Invoke(airport, packet); } catch { }
+ }
+ finally
+ {
+ _requestInFlight = false;
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/Infrastructure/Networking/AirportWebSocketManager.cs b/Infrastructure/Networking/AirportWebSocketManager.cs
new file mode 100644
index 0000000..31e4c3c
--- /dev/null
+++ b/Infrastructure/Networking/AirportWebSocketManager.cs
@@ -0,0 +1,331 @@
+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;
+
+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 event Action? Disconnected; // reason
+
+ public AirportWebSocketManager(
+ SimulatorManager simManager,
+ INearestAirportService nearestAirportService,
+ ISettingsStore settingsStore,
+ ILogger logger)
+ {
+ _simManager = simManager;
+ _nearestAirportService = nearestAirportService;
+ _settingsStore = settingsStore;
+ _logger = logger;
+ }
+
+ public void AttachHub(AirportStateHub hub)
+ {
+ hub.OutboundPacketRequested += (airport, rawJson) =>
+ {
+ try { _ = SendRawAsync(rawJson); } catch { }
+ };
+ }
+
+ 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 Task SendRawAsync(string raw)
+ {
+ ClientWebSocket? ws;
+ lock (_sync) ws = _ws;
+ if (ws == null || ws.State != WebSocketState.Open) return Task.CompletedTask;
+ var payload = System.Text.Encoding.UTF8.GetBytes(raw);
+ return ws.SendAsync(payload, WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+
+ 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(); }
+ }
+ try { Disconnected?.Invoke(reason); } catch { }
+ }
+
+ 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/HttpAirportRepository.cs b/Infrastructure/Networking/HttpAirportRepository.cs
new file mode 100644
index 0000000..d28d077
--- /dev/null
+++ b/Infrastructure/Networking/HttpAirportRepository.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using BARS_Client_V2.Application;
+using BARS_Client_V2.Domain;
+
+namespace BARS_Client_V2.Infrastructure.Networking;
+
+// Fetches approved contributions and builds a list of airports with their available scenery packages.
+internal sealed class HttpAirportRepository : IAirportRepository
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly JsonSerializerOptions _jsonOptions;
+
+ public HttpAirportRepository(IHttpClientFactory httpClientFactory)
+ {
+ _httpClientFactory = httpClientFactory;
+ _jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+ }
+
+ private sealed record ContributionDto(
+ string id,
+ string userId,
+ string userDisplayName,
+ string airportIcao,
+ string packageName,
+ string submittedXml,
+ string? notes,
+ DateTime submissionDate,
+ string status,
+ string? rejectionReason,
+ DateTime? decisionDate
+ );
+
+ private sealed record ContributionsResponse(List contributions, long total, int page, long limit, int totalPages);
+
+ public async Task<(IReadOnlyList Items, int TotalCount)> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default)
+ {
+ // We fetch the full approved list (server default limit is huge per provided sample) and do client side paging.
+ // If the endpoint later supports server-side paging + filtering we can shift to query params.
+ var client = _httpClientFactory.CreateClient();
+ using var req = new HttpRequestMessage(HttpMethod.Get, "https://v2.stopbars.com/contributions?status=approved");
+ using var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
+ resp.EnsureSuccessStatusCode();
+ await using var stream = await resp.Content.ReadAsStreamAsync(ct);
+ var data = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, ct)
+ ?? new ContributionsResponse(new List(), 0, 1, 0, 0);
+
+ // Group by airport -> collect distinct package names
+ var grouped = data.contributions
+ .GroupBy(c => c.airportIcao.Trim().ToUpperInvariant())
+ .Select(g => new Airport(
+ g.Key,
+ g.Select(c => c.packageName)
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => new SceneryPackage(p.Trim()))
+ .DistinctBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList()))
+ .ToList();
+
+ if (!string.IsNullOrWhiteSpace(search))
+ {
+ var s = search.Trim();
+ grouped = grouped.Where(a => a.ICAO.Contains(s, StringComparison.OrdinalIgnoreCase) || a.SceneryPackages.Any(p => p.Name.Contains(s, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+ }
+
+ var total = grouped.Count;
+ var items = grouped
+ .OrderBy(a => a.ICAO, StringComparer.OrdinalIgnoreCase)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToList();
+
+ return (items, total);
+ }
+}
diff --git a/Infrastructure/Settings/JsonSettingsStore.cs b/Infrastructure/Settings/JsonSettingsStore.cs
new file mode 100644
index 0000000..278fdaf
--- /dev/null
+++ b/Infrastructure/Settings/JsonSettingsStore.cs
@@ -0,0 +1,98 @@
+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
+ };
+
+ 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;
+
+ 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
+ {
+ token = p.ApiToken;
+ }
+ }
+ else
+ {
+ 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
+ {
+ 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..8bad947
--- /dev/null
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -0,0 +1,1038 @@
+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 BARS_Client_V2.Infrastructure.Networking;
+using BARS_Client_V2.Application;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SimConnect.NET.AI;
+
+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; // assumed MSFS
+ private readonly AirportStateHub _hub;
+ private readonly SimulatorManager _simManager;
+ private readonly ConcurrentQueue _queue = new();
+ private readonly ConcurrentDictionary _latestStates = new();
+ private readonly ConcurrentDictionary> _layoutCache = new();
+ private readonly System.Threading.SemaphoreSlim _spawnConcurrency = new(1, 1);
+ // Track stateId for each spawned SimObject (objectId -> stateId) so we don't rely on ContainerTitle which proved unreliable.
+ private readonly ConcurrentDictionary _objectStateIds = new();
+
+ // Config
+ private readonly int _maxObjects;
+ private readonly int _spawnPerSecond;
+ private readonly int _idleDelayMs;
+ private readonly int _disconnectedDelayMs;
+ private readonly int _errorBackoffMs;
+ private readonly double _spawnRadiusMeters;
+ private readonly TimeSpan _proximitySweepInterval;
+ private DateTime _nextProximitySweepUtc = DateTime.UtcNow;
+ private readonly bool _dynamicPruneEnabled;
+
+ // Rate tracking
+ private readonly object _rateLock = new();
+ private TimeSpan _perSpawnInterval = TimeSpan.FromMilliseconds(100);
+ private DateTime _nextAllowedSpawnUtc = DateTime.MinValue;
+
+ // Stats
+ private long _totalReceived;
+ private long _totalSpawnAttempts;
+ private long _totalDespawned;
+ private long _totalDeferredRate;
+ private long _totalSkippedCap;
+ private DateTime _lastSummary = DateTime.UtcNow;
+
+ private volatile bool _suspended;
+
+ // Stopbar crossing detection
+ private double? _prevLat;
+ private double? _prevLon;
+ private readonly ConcurrentDictionary _stopbarSegments = new();
+ private readonly ConcurrentDictionary _crossDebounceUntil = new();
+ private readonly TimeSpan _crossDebounceWindow = TimeSpan.FromSeconds(5);
+
+ // Failure/backoff
+ private readonly ConcurrentDictionary _spawnFailures = new();
+ private readonly TimeSpan _failureCooldown = TimeSpan.FromSeconds(10);
+ private const int FailureThresholdForCooldown = 3;
+ private readonly ConcurrentDictionary _nextAttemptUtc = new();
+ private readonly ConcurrentDictionary _hardCooldownUntil = new();
+
+ public MsfsPointController(IEnumerable connectors,
+ ILogger logger,
+ AirportStateHub hub,
+ SimulatorManager simManager,
+ MsfsPointControllerOptions? options = null)
+ {
+ _connector = connectors.FirstOrDefault(c => c.SimulatorId.Equals("MSFS", StringComparison.OrdinalIgnoreCase)) ?? connectors.First();
+ _logger = logger;
+ options ??= new MsfsPointControllerOptions();
+ _hub = hub;
+ _simManager = simManager;
+ _hub.PointStateChanged += OnPointStateChanged;
+ _hub.MapLoaded += OnMapLoaded;
+ _maxObjects = options.MaxObjects;
+ _spawnPerSecond = options.SpawnPerSecond;
+ _idleDelayMs = options.IdleDelayMs;
+ _disconnectedDelayMs = options.DisconnectedDelayMs;
+ _errorBackoffMs = options.ErrorBackoffMs;
+ _spawnRadiusMeters = options.SpawnRadiusMeters;
+ _proximitySweepInterval = TimeSpan.FromSeconds(options.ProximitySweepSeconds);
+ _dynamicPruneEnabled = options.DynamicPruneEnabled;
+ // Initialize smooth per-spawn pacing (avoid bursty spawns that can overwhelm SimConnect)
+ if (options.SpawnPerSecond <= 0)
+ {
+ // Treat <=0 as unlimited; keep a very small interval to avoid tight loops
+ _perSpawnInterval = TimeSpan.Zero;
+ }
+ else
+ {
+ // Space spawns evenly: e.g., 10/s -> 100ms between spawns
+ _perSpawnInterval = TimeSpan.FromSeconds(1.0 / Math.Max(1, options.SpawnPerSecond));
+ }
+ }
+
+ public void OnPointStateChanged(PointState state)
+ {
+ _latestStates[state.Metadata.Id] = state;
+ if (_suspended) return; // cache only
+ _queue.Enqueue(state);
+ var total = Interlocked.Increment(ref _totalReceived);
+ if (total <= 5 || (total % 500) == 0)
+ {
+ var m = state.Metadata;
+ _logger.LogInformation("[RecvSample] id={id} on={on} type={type} apt={apt} lat={lat:F6} lon={lon:F6} total={tot}",
+ m.Id, state.IsOn, m.Type, m.AirportId, m.Latitude, m.Longitude, total);
+ }
+ else if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ var m = state.Metadata;
+ _logger.LogTrace("[Recv] id={id} on={on} type={type} apt={apt} lat={lat:F6} lon={lon:F6}",
+ m.Id, state.IsOn, m.Type, m.AirportId, m.Latitude, m.Longitude);
+ }
+ }
+
+ ///
+ /// Temporarily suspend all spawning/despawning activity (except explicit DespawnAllAsync) and clear queued work.
+ /// Used when the upstream server / VATSIM disconnects so we freeze visual state instead of thrashing.
+ ///
+ public void Suspend()
+ {
+ _suspended = true;
+ while (_queue.TryDequeue(out _)) { }
+ _logger.LogInformation("[Suspend] MsfsPointController suspended; activeLights={lights}", TotalActiveLightCount());
+ }
+
+ ///
+ /// Resume normal spawning/despawning operations. Re-enqueues current ON states so they reconcile.
+ ///
+ public void Resume()
+ {
+ if (!_suspended) return;
+ _suspended = false;
+ int requeued = 0;
+ foreach (var kv in _latestStates) if (kv.Value.IsOn) { _queue.Enqueue(kv.Value); requeued++; }
+ _logger.LogInformation("[Resume] MsfsPointController resumed; requeuedActiveOn={requeued} queue={q}", requeued, _queue.Count);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("MsfsPointController started (manager-driven mode) max={max} rate/s={rate}", _maxObjects, _spawnPerSecond);
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ if (!_connector.IsConnected)
+ {
+ if ((_totalReceived % 25) == 0) _logger.LogDebug("[Loop] Waiting for simulator connection.");
+ await Task.Delay(_disconnectedDelayMs, stoppingToken);
+ continue;
+ }
+ if (_suspended)
+ {
+ await Task.Delay(_idleDelayMs * 5, stoppingToken);
+ continue;
+ }
+ // Stopbar crossing detection based on latest aircraft movement
+ var flightForCross = _simManager.LatestState;
+ if (flightForCross != null) { try { DetectStopbarCrossings(flightForCross); } catch (Exception ex) { _logger.LogDebug(ex, "DetectStopbarCrossings failed"); } }
+ if (_queue.TryDequeue(out var ps))
+ {
+ await ProcessAsync(ps, stoppingToken);
+ }
+ else
+ {
+ await Task.Delay(_idleDelayMs, stoppingToken);
+ }
+ if (DateTime.UtcNow >= _nextProximitySweepUtc)
+ {
+ _nextProximitySweepUtc = DateTime.UtcNow + _proximitySweepInterval;
+ try { await ProximitySweepAsync(stoppingToken); } catch (Exception ex) { _logger.LogDebug(ex, "ProximitySweep failed"); }
+ }
+ if ((DateTime.UtcNow - _lastSummary) > TimeSpan.FromSeconds(30))
+ {
+ _lastSummary = DateTime.UtcNow;
+ _logger.LogInformation("[Summary] received={rec} spawnAttempts={spAtt} activeLights={active} despawned={des} deferredRate={def} skippedCap={cap} queue={q}",
+ _totalReceived, _totalSpawnAttempts, TotalActiveLightCount(), _totalDespawned, _totalDeferredRate, _totalSkippedCap, _queue.Count);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Loop error");
+ try { await Task.Delay(_errorBackoffMs, stoppingToken); } catch { }
+ }
+ }
+ }
+
+ private void DetectStopbarCrossings(FlightState flight)
+ {
+ var currLat = flight.Latitude;
+ var currLon = flight.Longitude;
+ if (!_prevLat.HasValue || !_prevLon.HasValue)
+ {
+ _prevLat = currLat; _prevLon = currLon; return;
+ }
+ var prevLat = _prevLat!.Value; var prevLon = _prevLon!.Value;
+ // If aircraft barely moved, skip
+ if (DistanceMeters(prevLat, prevLon, currLat, currLon) < 1.0) { _prevLat = currLat; _prevLon = currLon; return; }
+
+ // Consider only nearby stopbars whose state is OFF (dropped)
+ foreach (var kv in _latestStates)
+ {
+ var ps = kv.Value;
+ if (ps.IsOn) continue; // we only report when dropped
+ var type = ps.Metadata.Type ?? string.Empty;
+ if (!type.Contains("STOP", StringComparison.OrdinalIgnoreCase) || !type.Contains("BAR", StringComparison.OrdinalIgnoreCase)) continue;
+ // Debounce this object id if recently reported
+ if (_crossDebounceUntil.TryGetValue(ps.Metadata.Id, out var until) && DateTime.UtcNow < until) continue;
+
+ // Quick distance gate to avoid scanning far objects
+ var dCurr = DistanceMeters(currLat, currLon, ps.Metadata.Latitude, ps.Metadata.Longitude);
+ if (dCurr > 200) continue; // 200m radius heuristic
+
+ var seg = GetOrBuildStopbarSegment(ps.Metadata.Id, ps);
+ if (seg == null) continue;
+ var (aLat, aLon, bLat, bLon) = seg.Value;
+ if (Crosses(prevLat, prevLon, currLat, currLon, aLat, aLon, bLat, bLon))
+ {
+ _crossDebounceUntil[ps.Metadata.Id] = DateTime.UtcNow + _crossDebounceWindow;
+ _hub.SendStopbarCrossing(ps.Metadata.Id);
+ _logger.LogInformation("[StopbarCrossing] objectId={id} pos=({lat:F6},{lon:F6})", ps.Metadata.Id, currLat, currLon);
+ }
+ }
+
+ _prevLat = currLat; _prevLon = currLon;
+ }
+
+ private (double LatA, double LonA, double LatB, double LonB)? GetOrBuildStopbarSegment(string pointId, PointState ps)
+ {
+ if (_stopbarSegments.TryGetValue(pointId, out var seg)) return seg;
+ if (!_hub.TryGetLightLayout(pointId, out var lights) || lights.Count < 2) return null;
+ // Choose the two lights with maximum separation as segment endpoints
+ double best = -1; (double la, double lo, double lb, double lob) bestPair = default;
+ for (int i = 0; i < lights.Count; i++)
+ {
+ for (int j = i + 1; j < lights.Count; j++)
+ {
+ var di = DistanceMeters(lights[i].Latitude, lights[i].Longitude, lights[j].Latitude, lights[j].Longitude);
+ if (di > best)
+ {
+ best = di; bestPair = (lights[i].Latitude, lights[i].Longitude, lights[j].Latitude, lights[j].Longitude);
+ }
+ }
+ }
+ if (best <= 0) return null;
+ var result = (bestPair.la, bestPair.lo, bestPair.lb, bestPair.lob);
+ _stopbarSegments[pointId] = result;
+ return result;
+ }
+
+ private static bool Crosses(double pLat0, double pLon0, double pLat1, double pLon1, double aLat, double aLon, double bLat, double bLon)
+ {
+ // Project to a local flat plane using simple equirectangular approximation around the stopbar midpoint for small distances.
+ var midLat = (aLat + bLat) * 0.5;
+ (double x, double y) P(double lat, double lon)
+ {
+ double x = (lon - aLon) * Math.Cos(midLat * Math.PI / 180.0) * 111320.0; // meters per deg lon
+ double y = (lat - aLat) * 110540.0; // meters per deg lat
+ return (x, y);
+ }
+ var p0 = P(pLat0, pLon0);
+ var p1 = P(pLat1, pLon1);
+ var a = (0.0, 0.0);
+ var b = P(bLat, bLon);
+
+ // Orientation signs relative to AB
+ static double Orient((double x, double y) a, (double x, double y) b, (double x, double y) p)
+ => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
+
+ var s0 = Orient(a, b, p0);
+ var s1 = Orient(a, b, p1);
+
+ // If signs are same or either is extremely close to zero, consider near-miss. We'll require sign flip and proximity.
+ if (s0 == 0 || s1 == 0) return false;
+ if (Math.Sign(s0) == Math.Sign(s1)) return false;
+
+ // Ensure the perpendicular projection falls within segment extents and distance within tolerance
+ static double Dot((double x, double y) u, (double x, double y) v) => u.x * v.x + u.y * v.y;
+ static (double x, double y) Sub((double x, double y) u, (double x, double y) v) => (u.x - v.x, u.y - v.y);
+ var ab = Sub(b, a);
+ var ap0 = Sub(p0, a);
+ var ap1 = Sub(p1, a);
+ double abLen2 = Dot(ab, ab);
+ if (abLen2 < 1) return false;
+ // Closest approach from movement segment to AB
+ // Compute intersection t on AB using average of projections from both endpoints (heuristic)
+ var t0 = Math.Clamp(Dot(ap0, ab) / abLen2, 0, 1);
+ var t1 = Math.Clamp(Dot(ap1, ab) / abLen2, 0, 1);
+ var t = 0.5 * (t0 + t1);
+ var closest = (x: a.Item1 + ab.x * t, y: a.Item2 + ab.y * t);
+ // Distance from movement segment to closest point
+ double DistPointToSeg((double x, double y) p, (double x, double y) u, (double x, double y) v)
+ {
+ var uv = Sub(v, u);
+ var up = Sub(p, u);
+ var tproj = Math.Clamp(Dot(up, uv) / (Dot(uv, uv) + 1e-6), 0, 1);
+ var proj = (x: u.x + uv.x * tproj, y: u.y + uv.y * tproj);
+ var dx = p.x - proj.x; var dy = p.y - proj.y; return Math.Sqrt(dx * dx + dy * dy);
+ }
+ var dist = DistPointToSeg(closest, p0, p1);
+ const double tolMeters = 12.0; // crossing tolerance
+ return dist <= tolMeters;
+ }
+
+ private async Task ProcessAsync(PointState ps, CancellationToken ct)
+ {
+ if (_suspended) return;
+ var id = ps.Metadata.Id;
+ var layouts = GetOrBuildLayouts(ps);
+ if (layouts.Count == 0) return;
+ var flight = _simManager.LatestState;
+ if (ps.IsOn && _nextAttemptUtc.TryGetValue(id, out var next) && DateTime.UtcNow < next) { if (_latestStates.TryGetValue(id, out var latest) && (next - DateTime.UtcNow).TotalMilliseconds < _idleDelayMs * 4) _queue.Enqueue(latest); return; }
+ if (ps.IsOn && _spawnFailures.TryGetValue(id, out var fi)) { var since = DateTime.UtcNow - fi.LastFailureUtc; if (fi.Failures >= FailureThresholdForCooldown && since < _failureCooldown) return; }
+ if (ps.IsOn && _hardCooldownUntil.TryGetValue(id, out var hardUntil) && DateTime.UtcNow < hardUntil) return;
+ ClassifyPointObjects(id, out var placeholders, out var variants);
+ _logger.LogTrace("[ProcessState] {id} on={on} placeholders={ph}/{need} variants={varCnt}/{need}", id, ps.IsOn, placeholders.Count, layouts.Count, variants.Count, layouts.Count);
+
+ // Guard: if we somehow have exploded variants count, trim extras (runaway protection)
+ int runawayLimit = layouts.Count * 3;
+ if (variants.Count > runawayLimit)
+ {
+ var excess = variants.Skip(layouts.Count).ToList(); // keep first layout.Count (arbitrary order)
+ _logger.LogWarning("[Runaway] {id} variants={varCnt} expected={exp} trimming={trim}", id, variants.Count, layouts.Count, excess.Count);
+ await RemoveObjectsAsync(excess, id, ct, "[RunawayTrim]");
+ ClassifyPointObjects(id, out placeholders, out variants); // refresh
+ }
+
+ if (!ps.IsOn)
+ {
+ // OFF: Ensure per-light off variant (offStateId) if provided; otherwise fallback to placeholder (stateId=0).
+ await EnsureOffStateAsync(id, layouts, variants, placeholders, ct);
+ return;
+ }
+
+ // ON: spawn ON variants first, then remove OFF variants/placeholders once desired counts are satisfied.
+ await EnsureOnStateAsync(id, layouts, ct);
+ }
+
+ private async Task SpawnBatchAsync(string pointId, IReadOnlyList layouts, int maxToSpawn, bool isPlaceholder, CancellationToken ct)
+ {
+ if (maxToSpawn <= 0) return;
+ int spawned = 0;
+ for (int i = 0; i < layouts.Count && spawned < maxToSpawn; i++)
+ {
+ if (TotalActiveLightCount() >= _maxObjects)
+ {
+ bool freed = false;
+ if (_dynamicPruneEnabled)
+ {
+ try { freed = await EnsureCapacityForSpawnAsync(pointId, 1, ct); } catch (Exception ex) { _logger.LogDebug(ex, "[PruneError]"); }
+ }
+ if (!freed && TotalActiveLightCount() >= _maxObjects)
+ {
+ Interlocked.Increment(ref _totalSkippedCap);
+ if (_latestStates.TryGetValue(pointId, out var latestCap)) _queue.Enqueue(latestCap);
+ break;
+ }
+ }
+ await WaitForSpawnSlotAsync(ct);
+ var layout = layouts[i];
+ int? variantState = layout.StateId;
+ if (!isPlaceholder)
+ {
+ // Ensure we don't accidentally spawn placeholders for ON lights when StateId missing
+ if (!variantState.HasValue || variantState == 0) variantState = 1; // default variant state
+ }
+ var desired = isPlaceholder ? layout with { StateId = 0 } : layout with { StateId = variantState };
+ try
+ {
+ var handle = await SpawnLightAsync(pointId, desired, ct);
+ Interlocked.Increment(ref _totalSpawnAttempts);
+ if (handle == null) { RegisterSpawnFailure(pointId); break; }
+ _spawnFailures.TryRemove(pointId, out _);
+ var sid = desired.StateId ?? 0;
+ _objectStateIds[handle.ObjectId] = sid;
+ spawned++;
+ _logger.LogTrace("[Spawned] {id} placeholder={ph} stateId={sid} obj={obj}", pointId, isPlaceholder, sid, handle.ObjectId);
+ }
+ catch (Exception ex)
+ {
+ RegisterSpawnFailure(pointId);
+ _logger.LogDebug(ex, "[SpawnError:Batch] {id}", pointId);
+ break;
+ }
+ }
+ }
+
+ private async Task RemoveObjectsAsync(List objects, string pointId, CancellationToken ct, string contextTag)
+ {
+ foreach (var obj in objects)
+ {
+ try { await DespawnLightAsync(obj, ct); Interlocked.Increment(ref _totalDespawned); _objectStateIds.TryRemove(obj.ObjectId, out _); }
+ catch (Exception ex) { _logger.LogTrace(ex, "{tag} {id} obj={objId}", contextTag, pointId, obj.ObjectId); }
+ }
+ _logger.LogDebug("{tag} {id} removed={count} activeLights={active}", contextTag, pointId, objects.Count, TotalActiveLightCount());
+ }
+
+ private void TryCompleteOverlap(string pointId) { }
+
+ private async void OnMapLoaded(string _)
+ {
+ // Scenery package or map layout changed for the current airport.
+ // Clear caches and all active sim objects. Do NOT re-enqueue old states here; a fresh snapshot will arrive.
+ try
+ {
+ _stopbarSegments.Clear();
+ _layoutCache.Clear();
+ await DespawnAllAsync();
+ // Drop cached point states to avoid respawning with old package
+ _latestStates.Clear();
+ while (_queue.TryDequeue(out var __)) { }
+ _logger.LogInformation("[MapReload] Cleared caches, states, and all spawned lights; awaiting fresh snapshot");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "[MapReload] Failed to hot-reload after map change");
+ }
+ }
+
+ private async Task WaitForSpawnSlotAsync(CancellationToken ct)
+ {
+ if (_spawnPerSecond <= 0 || _perSpawnInterval <= TimeSpan.Zero) return;
+ var now = DateTime.UtcNow;
+ TimeSpan delay;
+ lock (_rateLock)
+ {
+ if (_nextAllowedSpawnUtc < now)
+ {
+ _nextAllowedSpawnUtc = now;
+ }
+ delay = _nextAllowedSpawnUtc - now;
+ _nextAllowedSpawnUtc = _nextAllowedSpawnUtc + _perSpawnInterval;
+ }
+ if (delay > TimeSpan.Zero)
+ {
+ try { await Task.Delay(delay, ct).ConfigureAwait(false); } catch (TaskCanceledException) { }
+ }
+ }
+
+ private async Task SpawnLightAsync(string pointId, LightLayout layout, CancellationToken ct)
+ {
+ if (_connector is not MsfsSimulatorConnector msfs || !msfs.IsConnected) return null;
+ var clientField = typeof(MsfsSimulatorConnector).GetField("_client", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var client = clientField?.GetValue(msfs) as SimConnect.NET.SimConnectClient;
+ var mgr = client?.AIObjects;
+ if (mgr == null) return null;
+ await _spawnConcurrency.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ return await mgr.CreateObjectAsync(ResolveModel(layout.StateId), new SimConnect.NET.SimConnectDataInitPosition
+ {
+ Latitude = layout.Latitude,
+ Longitude = layout.Longitude,
+ Altitude = 50,
+ Heading = layout.Heading ?? 0,
+ Pitch = 0,
+ Bank = 0,
+ OnGround = 1,
+ Airspeed = 0
+ }, userData: pointId, cancellationToken: ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "[Connector.Spawn.Fail] point={pointId} stateId={sid}", pointId, layout.StateId);
+ throw;
+ }
+ finally { _spawnConcurrency.Release(); }
+ }
+
+ // Overload that tags the UserData with a specific slot index for per-slot handover: "{pointId}|{slotIndex}"
+ private async Task SpawnLightAsync(string pointId, LightLayout layout, int slotIndex, CancellationToken ct)
+ {
+ if (slotIndex < 0) return await SpawnLightAsync(pointId, layout, ct);
+ if (_connector is not MsfsSimulatorConnector msfs || !msfs.IsConnected) return null;
+ var clientField = typeof(MsfsSimulatorConnector).GetField("_client", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var client = clientField?.GetValue(msfs) as SimConnect.NET.SimConnectClient;
+ var mgr = client?.AIObjects;
+ if (mgr == null) return null;
+ await _spawnConcurrency.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var tag = $"{pointId}|{slotIndex}";
+ return await mgr.CreateObjectAsync(ResolveModel(layout.StateId), new SimConnect.NET.SimConnectDataInitPosition
+ {
+ Latitude = layout.Latitude,
+ Longitude = layout.Longitude,
+ Altitude = 50,
+ Heading = layout.Heading ?? 0,
+ Pitch = 0,
+ Bank = 0,
+ OnGround = 1,
+ Airspeed = 0
+ }, userData: tag, cancellationToken: ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "[Connector.Spawn.Fail] point={pointId} stateId={sid}", pointId, layout.StateId);
+ throw;
+ }
+ finally { _spawnConcurrency.Release(); }
+ }
+
+ private static bool TryGetUserPointAndSlot(SimObject o, out string? pointId, out int? slotIndex)
+ {
+ pointId = null; slotIndex = null;
+ if (o.UserData is string s && !string.IsNullOrEmpty(s))
+ {
+ var sep = s.IndexOf('|');
+ if (sep < 0)
+ {
+ pointId = s; return true;
+ }
+ else
+ {
+ pointId = s.Substring(0, sep);
+ if (int.TryParse(s.Substring(sep + 1), out var idx)) slotIndex = idx;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private async Task RemoveWrongForSlotAsync(string pointId, int desiredState, int slotIndex, CancellationToken ct, string contextTag)
+ {
+ var mgr = GetManager(); if (mgr == null) return;
+ var toRemove = new List();
+ foreach (var o in mgr.ManagedObjects.Values)
+ {
+ if (!o.IsActive) continue;
+ if (!TryGetUserPointAndSlot(o, out var pid, out var idx)) continue;
+ if (!string.Equals(pid, pointId, StringComparison.Ordinal)) continue;
+ if (!idx.HasValue || idx.Value != slotIndex) continue; // act only on the specific slot
+ var sid = ResolveObjectState(o);
+ if (sid != desiredState) toRemove.Add(o);
+ }
+ if (toRemove.Count > 0)
+ {
+ await RemoveObjectsAsync(toRemove, pointId, ct, contextTag);
+ }
+ }
+
+ private async Task EnsureOffStateAsync(string pointId, IReadOnlyList layouts, List variants, List placeholders, CancellationToken ct)
+ {
+ // Determine desired off-state ids per layout
+ var desiredByIndex = layouts.Select(l => l.OffStateId ?? 0).ToList();
+ var desiredSet = new HashSet(desiredByIndex);
+
+ // Build current list of objects for this point with resolved stateIds
+ var mgr = GetManager();
+ var current = mgr == null ? new List<(SimObject Obj, int State)>()
+ : mgr.ManagedObjects.Values
+ .Where(o => o.IsActive && TryGetUserPointAndSlot(o, out var pid, out var _slot) && string.Equals(pid, pointId, StringComparison.Ordinal))
+ .Select(o => (Obj: o, State: ResolveObjectState(o)))
+ .ToList();
+
+ // Count current per-state
+ var counts = new Dictionary();
+ foreach (var c in current) { if (!counts.TryAdd(c.State, 1)) counts[c.State]++; }
+
+ // Determine desired counts per state
+ var desiredCounts = new Dictionary();
+ foreach (var s in desiredByIndex) { var v = s; if (!desiredCounts.TryAdd(v, 1)) desiredCounts[v]++; }
+
+ // Spawn missing OFF objects iterating layouts for positions and states (spawn-first ordering)
+ for (int i = 0; i < layouts.Count; i++)
+ {
+ var desiredState = desiredByIndex[i];
+ var haveCount = counts.TryGetValue(desiredState, out var cv) ? cv : 0;
+ var wantCount = desiredCounts[desiredState];
+ if (haveCount >= wantCount) continue; // enough of this variant exists overall
+
+ // Capacity and rate checks
+ if (TotalActiveLightCount() >= _maxObjects)
+ {
+ bool freed = false;
+ if (_dynamicPruneEnabled)
+ {
+ try { freed = await EnsureCapacityForSpawnAsync(pointId, 1, ct); } catch (Exception ex) { _logger.LogDebug(ex, "[PruneError]"); }
+ }
+ if (!freed && TotalActiveLightCount() >= _maxObjects)
+ {
+ Interlocked.Increment(ref _totalSkippedCap);
+ if (_latestStates.TryGetValue(pointId, out var latestCap)) _queue.Enqueue(latestCap);
+ break;
+ }
+ }
+ await WaitForSpawnSlotAsync(ct);
+
+ var layout = layouts[i] with { StateId = desiredState };
+ try
+ {
+ var handle = await SpawnLightAsync(pointId, layout, i, ct);
+ Interlocked.Increment(ref _totalSpawnAttempts);
+ if (handle == null) { RegisterSpawnFailure(pointId); break; }
+ _spawnFailures.TryRemove(pointId, out _);
+ _objectStateIds[handle.ObjectId] = desiredState;
+ if (!counts.TryAdd(desiredState, 1)) counts[desiredState]++;
+ _logger.LogTrace("[OffSync:Spawned] {id} stateId={sid} obj={obj}", pointId, desiredState, handle.ObjectId);
+ // Immediately hand over this slot to prevent z-fighting
+ await RemoveWrongForSlotAsync(pointId, desiredState, i, ct, "[OffSync:Swap]");
+ }
+ catch (Exception ex)
+ {
+ RegisterSpawnFailure(pointId);
+ _logger.LogDebug(ex, "[OffSync:SpawnError] {id}", pointId);
+ break;
+ }
+ }
+
+ // If we now have all desired OFF objects, remove any wrong-state remnants (ON variants etc.)
+ bool satisfied = desiredCounts.All(kv => counts.TryGetValue(kv.Key, out var cv) && cv >= kv.Value);
+ if (satisfied)
+ {
+ var mgr2 = GetManager();
+ if (mgr2 != null)
+ {
+ var removeWrong = mgr2.ManagedObjects.Values
+ .Where(o => o.IsActive && TryGetUserPointAndSlot(o, out var pid, out var _slot) && string.Equals(pid, pointId, StringComparison.Ordinal))
+ .Where(o => { var sid = _objectStateIds.TryGetValue(o.ObjectId, out var sidv) ? sidv : ResolveObjectState(o); return !desiredSet.Contains(sid); })
+ .ToList();
+ if (removeWrong.Count > 0)
+ {
+ await RemoveObjectsAsync(removeWrong, pointId, ct, "[OffSync:RemoveWrong]");
+ }
+ }
+ }
+ else
+ {
+ if (_latestStates.TryGetValue(pointId, out var latestOff)) _queue.Enqueue(latestOff);
+ }
+ }
+
+ private async Task EnsureOnStateAsync(string pointId, IReadOnlyList layouts, CancellationToken ct)
+ {
+ // Determine desired ON-state ids per layout (fallback to 1 if missing/zero)
+ var desiredByIndex = layouts.Select(l =>
+ {
+ var s = l.StateId.HasValue && l.StateId.Value != 0 ? l.StateId.Value : 1;
+ return s;
+ }).ToList();
+ var desiredSet = new HashSet(desiredByIndex);
+
+ // Build current list of objects for this point with resolved stateIds
+ var mgr = GetManager();
+ var current = mgr == null ? new List<(SimObject Obj, int State)>()
+ : mgr.ManagedObjects.Values
+ .Where(o => o.IsActive && TryGetUserPointAndSlot(o, out var pid, out var _slot) && string.Equals(pid, pointId, StringComparison.Ordinal))
+ .Select(o => (Obj: o, State: ResolveObjectState(o)))
+ .ToList();
+
+ // Count current per-state
+ var counts = new Dictionary();
+ foreach (var c in current) { if (!counts.TryAdd(c.State, 1)) counts[c.State]++; }
+
+ // Determine desired counts per state
+ var desiredCounts = new Dictionary();
+ foreach (var s in desiredByIndex) { if (!desiredCounts.TryAdd(s, 1)) desiredCounts[s]++; }
+
+ // Spawn missing ON objects iterating layouts for positions and states (spawn-first ordering)
+ for (int i = 0; i < layouts.Count; i++)
+ {
+ var desiredState = desiredByIndex[i];
+ var haveCount = counts.TryGetValue(desiredState, out var cv) ? cv : 0;
+ var wantCount = desiredCounts[desiredState];
+ if (haveCount >= wantCount) continue; // enough of this variant exists overall
+
+ // Capacity and rate checks
+ if (TotalActiveLightCount() >= _maxObjects)
+ {
+ bool freed = false;
+ if (_dynamicPruneEnabled)
+ {
+ try { freed = await EnsureCapacityForSpawnAsync(pointId, 1, ct); } catch (Exception ex) { _logger.LogDebug(ex, "[PruneError]"); }
+ }
+ if (!freed && TotalActiveLightCount() >= _maxObjects)
+ {
+ Interlocked.Increment(ref _totalSkippedCap);
+ if (_latestStates.TryGetValue(pointId, out var latestCap)) _queue.Enqueue(latestCap);
+ break;
+ }
+ }
+ await WaitForSpawnSlotAsync(ct);
+
+ var layout = layouts[i];
+ var spawnLayout = layout with { StateId = desiredState };
+ try
+ {
+ var handle = await SpawnLightAsync(pointId, spawnLayout, i, ct);
+ Interlocked.Increment(ref _totalSpawnAttempts);
+ if (handle == null) { RegisterSpawnFailure(pointId); break; }
+ _spawnFailures.TryRemove(pointId, out _);
+ _objectStateIds[handle.ObjectId] = desiredState;
+ if (!counts.TryAdd(desiredState, 1)) counts[desiredState]++;
+ _logger.LogTrace("[OnSync:Spawned] {id} stateId={sid} obj={obj}", pointId, desiredState, handle.ObjectId);
+ // Immediately hand over this slot to prevent z-fighting
+ await RemoveWrongForSlotAsync(pointId, desiredState, i, ct, "[OnSync:Swap]");
+ }
+ catch (Exception ex)
+ {
+ RegisterSpawnFailure(pointId);
+ _logger.LogDebug(ex, "[OnSync:SpawnError] {id}", pointId);
+ break;
+ }
+ }
+
+ // If we now have all desired ON objects, remove any wrong-state remnants (OFF variants/placeholders)
+ bool satisfied = desiredCounts.All(kv => counts.TryGetValue(kv.Key, out var cv) && cv >= kv.Value);
+ if (satisfied)
+ {
+ var mgr2 = GetManager();
+ if (mgr2 != null)
+ {
+ var removeWrong = mgr2.ManagedObjects.Values
+ .Where(o => o.IsActive && TryGetUserPointAndSlot(o, out var pid, out var _slot) && string.Equals(pid, pointId, StringComparison.Ordinal))
+ .Where(o => { var sid = _objectStateIds.TryGetValue(o.ObjectId, out var sidv) ? sidv : ResolveObjectState(o); return !desiredSet.Contains(sid); })
+ .ToList();
+ if (removeWrong.Count > 0)
+ {
+ await RemoveObjectsAsync(removeWrong, pointId, ct, "[OnSync:RemoveWrong]");
+ }
+ }
+ }
+ else
+ {
+ if (_latestStates.TryGetValue(pointId, out var latestOn)) _queue.Enqueue(latestOn);
+ }
+ }
+
+ private Task DespawnLightAsync(SimObject simObject, CancellationToken ct)
+ {
+ if (_connector is not MsfsSimulatorConnector msfs) return Task.CompletedTask;
+ var clientField = typeof(MsfsSimulatorConnector).GetField("_client", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var client = clientField?.GetValue(msfs) as SimConnect.NET.SimConnectClient;
+ var mgr = client?.AIObjects;
+ if (mgr == null) return Task.CompletedTask;
+ return mgr.RemoveObjectAsync(simObject, ct);
+ }
+
+
+ private int TotalActiveLightCount()
+ {
+ var mgr = GetManager();
+ if (mgr == null) return 0;
+ return mgr.ManagedObjects.Values.Count(o => o.IsActive && o.ContainerTitle.StartsWith("BARS_Light_", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private sealed record LightLayout(double Latitude, double Longitude, double? Heading, string? Color, int? StateId, int? OffStateId);
+
+ private IReadOnlyList GetOrBuildLayouts(PointState ps) => _layoutCache.GetOrAdd(ps.Metadata.Id, _ =>
+ {
+ IReadOnlyList raw;
+ if (!_hub.TryGetLightLayout(ps.Metadata.Id, out var hubLights) || hubLights.Count == 0)
+ raw = new List { new AirportStateHub.LightLayout(ps.Metadata.Latitude, ps.Metadata.Longitude, null, ps.Metadata.Color, null, null) };
+ else raw = hubLights;
+ return (IReadOnlyList)raw.Select(l => new LightLayout(l.Latitude, l.Longitude, l.Heading, l.Color, l.StateId, l.OffStateId)).ToList();
+ });
+
+ // group spawning logic removed in manager-driven mode
+
+ private void RegisterSpawnFailure(string pointId)
+ {
+ var now = DateTime.UtcNow;
+ var updated = _spawnFailures.AddOrUpdate(pointId,
+ _ => (1, now),
+ (_, prev) => (prev.Failures + 1, now));
+
+ // Dynamic backoff now exponential: 2^n * 400ms capped at 15s (pre threshold)
+ var backoffMs = (int)Math.Min(Math.Pow(2, updated.Failures) * 400, 15000);
+ if (updated.Failures >= FailureThresholdForCooldown)
+ {
+ // ensure at least failureCooldown (e.g. 10s) after threshold reached, escalate cap to 30s
+ backoffMs = Math.Max(backoffMs, (int)_failureCooldown.TotalMilliseconds);
+ backoffMs = Math.Min(backoffMs, 30000);
+ }
+ var next = now.AddMilliseconds(backoffMs);
+ _nextAttemptUtc[pointId] = next;
+
+ if (updated.Failures == FailureThresholdForCooldown)
+ _logger.LogWarning("[SpawnFail:BackoffStart] {id} failures={fail} backoffMs={ms}", pointId, updated.Failures, backoffMs);
+ else if (updated.Failures > FailureThresholdForCooldown)
+ _logger.LogTrace("[SpawnFail:Backoff] {id} failures={fail} backoffMs={ms}", pointId, updated.Failures, backoffMs);
+ else if (updated.Failures == 1)
+ _logger.LogDebug("[SpawnFail] {id} firstFailure backoffMs={ms}", pointId, backoffMs);
+
+ // Escalate to hard cooldown if failures very high (likely persistent model issue)
+ if (updated.Failures == 6)
+ {
+ var hardUntil = now.AddMinutes(1);
+ _hardCooldownUntil[pointId] = hardUntil;
+ _logger.LogWarning("[SpawnFail:HardCooldownStart] {id} failures={fail} pauseUntil={until:O}", pointId, updated.Failures, hardUntil);
+ }
+ }
+
+ // Overlap despawn removed in simplified implementation
+
+ private async Task DespawnPointAsync(string pointId, CancellationToken ct)
+ {
+ var mgr = GetManager();
+ if (mgr == null) return;
+ var list = mgr.ManagedObjects.Values.Where(o => o.IsActive && TryGetUserPointAndSlot(o, out var pid, out var _slot) && string.Equals(pid, pointId, StringComparison.Ordinal)).ToList();
+ if (list.Count == 0) return;
+ _logger.LogDebug("[DespawnPointStart] {id} count={count}", pointId, list.Count);
+ foreach (var obj in list)
+ {
+ try { await DespawnLightAsync(obj, ct); Interlocked.Increment(ref _totalDespawned); _objectStateIds.TryRemove(obj.ObjectId, out _); }
+ catch (Exception ex) { _logger.LogTrace(ex, "[DespawnPointError] {id} obj={objId}", pointId, obj.ObjectId); }
+ }
+ _logger.LogInformation("[DespawnPoint] {id} removed={removed} activeLights={active}", pointId, list.Count, TotalActiveLightCount());
+ }
+
+ // Perform ordering & pruning based on aircraft proximity.
+ private Task ProximitySweepAsync(CancellationToken ct)
+ {
+ var flight = _simManager.LatestState;
+ if (flight == null) return Task.CompletedTask;
+ // Build active point set via manager
+ var activePointIds = new HashSet(StringComparer.Ordinal);
+ var mgr = GetManager();
+ if (mgr != null)
+ {
+ foreach (var o in mgr.ManagedObjects.Values)
+ {
+ if (!o.IsActive) continue;
+ if (TryGetUserPointAndSlot(o, out var pid, out var _slot) && pid != null)
+ activePointIds.Add(pid);
+ }
+ }
+ // Radius-based despawn removed: keep all previously spawned objects; rely on global caps for safety.
+ // Identify spawn candidates
+ var candidates = new List<(PointState State, double Dist)>();
+ foreach (var kv in _latestStates)
+ {
+ var st = kv.Value;
+ if (!st.IsOn) continue;
+ var dist = DistanceMeters(flight.Latitude, flight.Longitude, st.Metadata.Latitude, st.Metadata.Longitude);
+ // Distance requirement removed; include all ON points (distance retained only for ordering)
+ var (objs, _) = GetPointObjects(st.Metadata.Id);
+ var layouts = GetOrBuildLayouts(st);
+ if (objs.Count >= layouts.Count) continue;
+ candidates.Add((st, dist));
+ }
+ if (candidates.Count == 0) return Task.CompletedTask;
+ // Order by distance (closest first)
+ foreach (var c in candidates.OrderBy(c => c.Dist))
+ {
+ if (ct.IsCancellationRequested) break;
+ if (TotalActiveLightCount() >= _maxObjects) break;
+ _queue.Enqueue(c.State); // enqueue for ProcessAsync which will respect cap & rate
+ }
+ _logger.LogTrace("[ProximityEnqueue] added={count} queue={q}", candidates.Count, _queue.Count);
+ return Task.CompletedTask;
+ }
+
+ private static double DistanceMeters(double lat1, double lon1, double lat2, double lon2)
+ {
+ // Haversine formula
+ const double R = 6371000; // meters
+ double dLat = DegreesToRadians(lat2 - lat1);
+ double dLon = DegreesToRadians(lon2 - lon1);
+ double a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
+ Math.Cos(DegreesToRadians(lat1)) * Math.Cos(DegreesToRadians(lat2)) *
+ Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
+ double c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
+ return R * c;
+ }
+
+ private static double DegreesToRadians(double deg) => deg * Math.PI / 180.0;
+
+ private void ResyncActivePointsAfterLayout()
+ {
+ int queued = 0;
+ foreach (var kv in _latestStates)
+ {
+ var ps = kv.Value;
+ if (!ps.IsOn) continue;
+ if (!_hub.TryGetLightLayout(ps.Metadata.Id, out var layout) || layout.Count == 0) continue;
+ var (objs, _) = GetPointObjects(ps.Metadata.Id);
+ if (objs.Count >= layout.Count) continue;
+ _queue.Enqueue(ps);
+ queued++;
+ }
+ if (queued > 0) _logger.LogInformation("Resync queued {count} active points for full layout spawn", queued);
+ }
+
+ ///
+ /// Despawn all currently active SimObjects immediately (e.g. on server disconnect) without altering cached states.
+ /// New incoming states will respawn as needed.
+ ///
+ public async Task DespawnAllAsync(CancellationToken ct = default)
+ {
+ var mgr = GetManager();
+ if (mgr == null)
+ {
+ _logger.LogInformation("[DespawnAll] AI manager not available");
+ return;
+ }
+ var ours = mgr.ManagedObjects.Values.Where(o => o.IsActive && o.ContainerTitle.StartsWith("BARS_Light_", StringComparison.OrdinalIgnoreCase)).ToList();
+ if (ours.Count == 0)
+ {
+ _logger.LogInformation("[DespawnAll] No active lights to remove");
+ return;
+ }
+ _logger.LogInformation("[DespawnAllStart] lights={lights}", ours.Count);
+ foreach (var obj in ours)
+ {
+ try { await DespawnLightAsync(obj, ct); Interlocked.Increment(ref _totalDespawned); _objectStateIds.TryRemove(obj.ObjectId, out _); }
+ catch (Exception ex) { _logger.LogTrace(ex, "[DespawnAllError] obj={id}", obj.ObjectId); }
+ }
+ _logger.LogInformation("[DespawnAll] removedLights={removed} activeLights={active}", ours.Count, TotalActiveLightCount());
+ }
+
+ private (List Objects, int Count) GetPointObjects(string pointId)
+ {
+ var mgr = GetManager();
+ if (mgr == null) return (new List(), 0);
+ var list = mgr.ManagedObjects.Values.Where(o => o.IsActive && TryGetUserPointAndSlot(o, out var pid, out var _slot) && string.Equals(pid, pointId, StringComparison.Ordinal)).ToList();
+ return (list, list.Count);
+ }
+
+ private SimObjectManager? GetManager()
+ {
+ if (_connector is not MsfsSimulatorConnector msfs) return null;
+ var clientField = typeof(MsfsSimulatorConnector).GetField("_client", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var client = clientField?.GetValue(msfs) as SimConnect.NET.SimConnectClient;
+ return client?.AIObjects;
+ }
+
+ private int ResolveObjectState(SimObject o)
+ {
+ if (_objectStateIds.TryGetValue(o.ObjectId, out var sid)) return sid;
+ // Fallback: attempt parse from title tail e.g. BARS_Light_21
+ try
+ {
+ var title = o.ContainerTitle ?? string.Empty;
+ var tail = title.Split('_').LastOrDefault();
+ if (int.TryParse(tail, out var parsed)) return parsed;
+ }
+ catch { }
+ return 0; // default placeholder assumption
+ }
+
+ private static string ResolveModel(int? stateId)
+ {
+ if (!stateId.HasValue) return "BARS_Light_0";
+ var s = stateId.Value; if (s < 0) s = 0; return $"BARS_Light_{s}";
+ }
+ private async Task EnsureCapacityForSpawnAsync(string priorityPointId, int requiredSlots, CancellationToken ct)
+ {
+ var flight = _simManager.LatestState;
+ if (flight == null) return false;
+ if (TotalActiveLightCount() + requiredSlots < _maxObjects) return true; // already enough
+ var mgr = GetManager();
+ if (mgr == null) return false;
+
+ // Build distinct active point set with object counts
+ var pointCounts = new Dictionary(StringComparer.Ordinal);
+ foreach (var o in mgr.ManagedObjects.Values)
+ {
+ if (!o.IsActive) continue;
+ if (!TryGetUserPointAndSlot(o, out var pid, out var _slot) || pid == null) continue;
+ if (!pointCounts.TryAdd(pid, 1)) pointCounts[pid]++;
+ }
+ if (pointCounts.Count == 0) return false;
+ // Build distance list
+ var distances = new List<(string PointId, double Dist, int Count)>();
+ foreach (var kv in pointCounts)
+ {
+ if (!_latestStates.TryGetValue(kv.Key, out var ps)) continue; // stale
+ var d = DistanceMeters(flight.Latitude, flight.Longitude, ps.Metadata.Latitude, ps.Metadata.Longitude);
+ distances.Add((kv.Key, d, kv.Value));
+ }
+ if (distances.Count == 0) return false;
+
+ // Order farthest first, but never prune the priority point
+ foreach (var item in distances.OrderByDescending(d => d.Dist))
+ {
+ if (item.PointId == priorityPointId) continue;
+ if (TotalActiveLightCount() + requiredSlots < _maxObjects) break;
+ _logger.LogTrace("[PruneBegin] freeing point={id} dist={dist:F0}m count={count} active={active}/{cap}", item.PointId, item.Dist, item.Count, TotalActiveLightCount(), _maxObjects);
+ try { await DespawnPointAsync(item.PointId, ct); }
+ catch (Exception ex) { _logger.LogDebug(ex, "[PruneFail] point={id}", item.PointId); }
+ }
+
+ var success = TotalActiveLightCount() + requiredSlots <= _maxObjects;
+ if (success) _logger.LogTrace("[PruneSuccess] priority={prio} needed={need} active={active}/{cap}", priorityPointId, requiredSlots, TotalActiveLightCount(), _maxObjects);
+ else _logger.LogDebug("[PruneInsufficient] priority={prio} needed={need} active={active}/{cap}", priorityPointId, requiredSlots, TotalActiveLightCount(), _maxObjects);
+ return success;
+ }
+
+ private void ClassifyPointObjects(string pointId, out List placeholders, out List variants)
+ {
+ placeholders = new List();
+ variants = new List();
+ var (objs, _) = GetPointObjects(pointId);
+ foreach (var o in objs)
+ {
+ int sid;
+ if (!_objectStateIds.TryGetValue(o.ObjectId, out sid))
+ {
+ // Fallback: attempt parse from title tail
+ sid = 0;
+ try
+ {
+ var title = o.ContainerTitle ?? string.Empty;
+ var tail = title.Split('_').LastOrDefault();
+ if (int.TryParse(tail, out var parsed)) sid = parsed; else sid = 0; // default placeholder assumption
+ }
+ catch { sid = 0; }
+ }
+ if (sid == 0) placeholders.Add(o); else variants.Add(o);
+ }
+ }
+}
+
+internal sealed class MsfsPointControllerOptions
+{
+ public int MaxObjects { get; init; } = 900;
+ public int SpawnPerSecond { get; init; } = 10;
+ public int IdleDelayMs { get; init; } = 10;
+ public int DisconnectedDelayMs { get; init; } = 500;
+ public int ErrorBackoffMs { get; init; } = 200;
+ public int OverlapDespawnDelayMs { get; init; } = 1000;
+ public double SpawnRadiusMeters { get; init; } = 8000;
+ public int ProximitySweepSeconds { get; init; } = 5;
+ public bool DynamicPruneEnabled { get; init; } = true;
+}
diff --git a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
new file mode 100644
index 0000000..ebe3893
--- /dev/null
+++ b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
@@ -0,0 +1,353 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using BARS_Client_V2.Domain;
+using Microsoft.Extensions.Logging;
+using SimConnect.NET;
+using SimConnect.NET.AI;
+using SimConnect.NET.SimVar;
+
+namespace BARS_Client_V2.Infrastructure.Simulators.Msfs;
+
+public sealed class MsfsSimulatorConnector : ISimulatorConnector, IDisposable
+{
+ private readonly ILogger _logger;
+ private SimConnectClient? _client;
+ private const int PollDelayMs = 500; // faster polling for precise stopbar crossing detection
+ private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(20);
+ private readonly SemaphoreSlim _connectGate = new(1, 1);
+ private double? _cachedGroundAltFeet;
+ private DateTime _cachedGroundAltAt;
+ private static readonly TimeSpan GroundAltCacheDuration = TimeSpan.FromSeconds(5);
+ private readonly ConcurrentDictionary _lateAttachedPoints = new();
+ // Track successful creations so late attach logic can correlate
+ private readonly ConcurrentDictionary _createdObjectIds = new();
+ // Avoid tearing down the connection on a single transient timeout
+ private int _consecutiveSampleErrors;
+ private const int MaxConsecutiveSampleErrorsBeforeDisconnect = 5;
+
+ public MsfsSimulatorConnector(ILogger logger) => _logger = logger;
+
+ public string SimulatorId => "MSFS";
+ public string DisplayName
+ {
+ get
+ {
+ var is2024 = IsMsfs2024;
+ if (is2024 == true) return "Microsoft Flight Simulator 2024";
+ if (is2024 == false) return "Microsoft Flight Simulator 2020";
+ return "Microsoft Flight Simulator"; // unknown (not yet connected)
+ }
+ }
+ public bool IsConnected => _client?.IsConnected == true;
+ ///
+ /// Indicates whether the connected MSFS instance is the 2024 version. Null if not connected or undetermined.
+ /// Relies on SimConnectClient.IsMSFS2024 (exposed by SimConnect.NET) as hinted by user.
+ ///
+ public bool? IsMsfs2024 => _client?.IsMSFS2024;
+
+ public async Task ConnectAsync(CancellationToken ct = default)
+ {
+ if (IsConnected) return true;
+
+ await _connectGate.WaitAsync(ct);
+ try
+ {
+ if (IsConnected) return true;
+ int attempt = 0;
+ while (!ct.IsCancellationRequested && !IsConnected)
+ {
+ attempt++;
+ try
+ {
+ _logger.LogInformation("MSFS connect attempt {attempt}...", attempt);
+ var client = new SimConnectClient("BARS Client");
+ await client.ConnectAsync();
+ if (client.IsConnected)
+ {
+ _client = client;
+ _logger.LogInformation("Connected to MSFS via SimConnect.NET after {attempt} attempt(s)", attempt);
+ break;
+ }
+ else
+ {
+ client.Dispose();
+ _logger.LogWarning("MSFS connect attempt {attempt} failed (not connected after ConnectAsync)", attempt);
+ }
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "MSFS connection attempt {attempt} failed", attempt);
+ }
+
+ if (!IsConnected)
+ {
+ try
+ {
+ _logger.LogInformation("Retrying MSFS connection in {delaySeconds} seconds", (int)RetryDelay.TotalSeconds);
+ await Task.Delay(RetryDelay, ct);
+ }
+ catch (OperationCanceledException) { throw; }
+ }
+ }
+ }
+ finally
+ {
+ _connectGate.Release();
+ }
+ 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)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ if (!IsConnected)
+ {
+ // Stop streaming so manager can observe disconnect and trigger reconnection.
+ yield break;
+ }
+
+ 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;
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ // Keep SimVar requests snappy so a slow sim doesn't block the stream loop
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(3));
+ var tkn = timeoutCts.Token;
+
+ var latTask = svm.GetAsync("PLANE LATITUDE", "degrees", cancellationToken: tkn);
+ var lonTask = svm.GetAsync("PLANE LONGITUDE", "degrees", cancellationToken: tkn);
+ var grnTask = svm.GetAsync("SIM ON GROUND", "bool", cancellationToken: tkn);
+
+ await Task.WhenAll(latTask, lonTask, grnTask).ConfigureAwait(false);
+
+ var lat = latTask.Result;
+ var lon = lonTask.Result;
+ var onGround = grnTask.Result == 1;
+
+ // success -> reset error budget
+ _consecutiveSampleErrors = 0;
+ return new RawFlightSample(lat, lon, onGround);
+ }
+ catch (OperationCanceledException oce)
+ {
+ if (ct.IsCancellationRequested) throw; // external cancellation – bubble up
+ // Per-request timeout or transient cancellation – treat as soft miss
+ var n = Interlocked.Increment(ref _consecutiveSampleErrors);
+ _logger.LogDebug(oce, "MSFS sample timed out/cancelled (#{count}/{max}) – will retry without disconnect", n, MaxConsecutiveSampleErrorsBeforeDisconnect);
+ if (n >= MaxConsecutiveSampleErrorsBeforeDisconnect)
+ {
+ _logger.LogWarning("MSFS sample repeatedly failing ({count} in a row) – disposing client to recover", n);
+ try { await DisconnectAsync(); } catch { }
+ _consecutiveSampleErrors = 0;
+ }
+ return null;
+ }
+ catch (TimeoutException tex)
+ {
+ // Some SimConnect.NET versions throw TimeoutException directly
+ var n = Interlocked.Increment(ref _consecutiveSampleErrors);
+ _logger.LogDebug(tex, "MSFS sample TimeoutException (#{count}/{max}) – will retry without immediate disconnect", n, MaxConsecutiveSampleErrorsBeforeDisconnect);
+ if (n >= MaxConsecutiveSampleErrorsBeforeDisconnect)
+ {
+ _logger.LogWarning("MSFS sample repeatedly timing out ({count} in a row) – disposing client to recover", n);
+ try { await DisconnectAsync(); } catch { }
+ _consecutiveSampleErrors = 0;
+ }
+ return null;
+ }
+ catch (Exception ex)
+ {
+ // Treat other exceptions as transient, escalate only after several occurrences
+ var n = Interlocked.Increment(ref _consecutiveSampleErrors);
+ _logger.LogDebug(ex, "MSFS sample retrieval error (#{count}/{max})", n, MaxConsecutiveSampleErrorsBeforeDisconnect);
+ if (n >= MaxConsecutiveSampleErrorsBeforeDisconnect)
+ {
+ _logger.LogWarning(ex, "MSFS sample repeatedly failing – disposing client to recover");
+ try { await DisconnectAsync(); } catch { }
+ _consecutiveSampleErrors = 0;
+ }
+ return null;
+ }
+ }
+
+ public void Dispose() => _ = DisconnectAsync();
+
+ internal async Task SpawnLightAsync(string pointId, double lat, double lon, double? heading, int? stateId, CancellationToken ct)
+ {
+ if (!IsConnected) return null;
+ var client = _client;
+ if (client == null) return null;
+ var mgr = client.AIObjects;
+ if (mgr == null) return null; // defensive: library should provide this when connected
+ try
+ {
+ if (_lateAttachedPoints.ContainsKey(pointId))
+ {
+ _logger.LogTrace("[Connector.Spawn.SkipLate] point={pointId} already late-attached", pointId);
+ return null;
+ }
+ double altitudeFeet;
+ var now = DateTime.UtcNow;
+ if (_cachedGroundAltFeet.HasValue && (now - _cachedGroundAltAt) < GroundAltCacheDuration)
+ {
+ altitudeFeet = _cachedGroundAltFeet.Value;
+ }
+ else
+ {
+ try
+ {
+ altitudeFeet = await client.SimVars.GetAsync("PLANE ALTITUDE", "feet", cancellationToken: ct).ConfigureAwait(false);
+ _cachedGroundAltFeet = altitudeFeet;
+ _cachedGroundAltAt = now;
+ }
+ catch
+ {
+ altitudeFeet = 50; // fallback nominal
+ }
+ }
+ var pos = new SimConnectDataInitPosition
+ {
+ Latitude = lat,
+ Longitude = lon,
+ Altitude = altitudeFeet,
+ Pitch = 0,
+ Bank = 0,
+ Heading = heading ?? 0,
+ OnGround = 1,
+ Airspeed = 0
+ };
+ var model = ResolveModelVariant(stateId);
+ _logger.LogTrace("[Connector.Spawn] point={pointId} model={model} lat={lat:F6} lon={lon:F6} hdg={hdg:F1} stateId={sid}", pointId, model, lat, lon, heading ?? 0, stateId);
+ SimObject simObj;
+ try
+ {
+ simObj = await mgr.CreateObjectAsync(model, pos, userData: pointId, cancellationToken: ct).ConfigureAwait(false);
+ }
+ catch (Exception createEx)
+ {
+ _logger.LogWarning(createEx, "[Connector.Spawn.CreateFail] point={pointId} model={model} stateId={sid}", pointId, model, stateId);
+ throw; // propagate to outer catch -> late attach fallback
+ }
+ _logger.LogInformation("[Connector.Spawned] point={pointId} model={model} objectId={obj} stateIdInit={sid} activeCount={count}", pointId, model, simObj.ObjectId, stateId, mgr.ActiveObjectCount);
+ // Record association for late attach correlation / diagnostics
+ _createdObjectIds[pointId] = unchecked((int)simObj.ObjectId);
+ return simObj;
+ }
+ catch (OperationCanceledException oce)
+ {
+ if (ct.IsCancellationRequested) throw; // external cancel
+ _logger.LogWarning(oce, "[Connector.Spawn.Timeout] point={pointId} probable creation timeout; will watch for late object", pointId);
+ _ = Task.Run(() => TryLateAttachAsync(pointId, lat, lon, client, CancellationToken.None));
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "[Connector.Spawn.Fail] point={pointId} stateId={sid}", pointId, stateId);
+ _ = Task.Run(() => TryLateAttachAsync(pointId, lat, lon, client, CancellationToken.None));
+ return null;
+ }
+ }
+
+ internal async Task DespawnLightAsync(SimObject simObject, CancellationToken ct)
+ {
+ var client = _client;
+ var mgr = client?.AIObjects;
+ if (mgr == null) return;
+ try { await mgr.RemoveObjectAsync(simObject, ct).ConfigureAwait(false); }
+ catch (Exception ex) { _logger.LogDebug(ex, "DespawnLightAsync failed {obj}", simObject.ObjectId); }
+ }
+
+ private async Task TryLateAttachAsync(string pointId, double lat, double lon, SimConnectClient client, CancellationToken cancellationToken)
+ {
+ if (!_lateAttachedPoints.TryAdd(pointId, false)) return; // already attempting
+ try
+ {
+ var mgr = client.AIObjects;
+ const int maxSeconds = 30;
+ for (int i = 0; i < maxSeconds && !cancellationToken.IsCancellationRequested; i++)
+ {
+ await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
+ // If create eventually succeeded normally, association already recorded
+ if (_createdObjectIds.ContainsKey(pointId))
+ {
+ _lateAttachedPoints[pointId] = true;
+ _logger.LogTrace("[Connector.LateAttach.Skip] point={pointId} normalSpawnRecorded", pointId);
+ return;
+ }
+ // Attempt to locate by userData if library exposes it; fall back to positional proximity heuristic
+ var candidates = mgr.ManagedObjects.Values.Where(o => o.IsActive).ToList();
+ SimObject? match = null;
+ foreach (var c in candidates)
+ {
+ try
+ {
+ if (c.UserData is string ud && string.Equals(ud, pointId, StringComparison.Ordinal))
+ {
+ match = c; break;
+ }
+ }
+ catch { }
+ }
+ // (Position-based heuristic removed; SimObject.Position not available in current API)
+ if (match != null)
+ {
+ _lateAttachedPoints[pointId] = true;
+ _createdObjectIds[pointId] = unchecked((int)match.ObjectId);
+ _logger.LogInformation("[Connector.LateAttach] point={pointId} objectId={obj}", pointId, match.ObjectId);
+ return;
+ }
+ }
+ _logger.LogDebug("[Connector.LateAttach.None] point={pointId} no matching object found", pointId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "[Connector.LateAttach.Error] point={pointId}", pointId);
+ }
+ finally
+ {
+ // Allow future attempts if we never succeeded
+ if (!_lateAttachedPoints.TryGetValue(pointId, out var success) || !success)
+ _lateAttachedPoints.TryRemove(pointId, out _);
+ }
+ }
+
+ // (Haversine helper removed – no longer needed after heuristic removal)
+
+ private static string ResolveModelVariant(int? stateId)
+ {
+ if (!stateId.HasValue) return "BARS_Light_0"; // default off variant model
+ var s = stateId.Value;
+ if (s < 0) s = 0;
+ return $"BARS_Light_{s}";
+ }
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index edde467..b86d286 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,175 @@
-
+
-
+
-
+
-
-
+
+
-
-
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
-
-
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
-
+
-
+
-
+
-
-
+
-
+
-
-
+
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
-
+
+
@@ -572,18 +784,32 @@
-
+
-
+
-
-
-
-
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 7e5adc4..4b219c4 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -1,342 +1,13 @@
-using System;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Media;
-using System.Windows.Media.Animation;
-using BARS_Client_V2.Services;
-using System.Linq;
+using System.Windows;
-namespace BARS_Client_V2
-{ public partial class MainWindow : Window
- {
- private ObservableCollection airportItems = new();
- private ObservableCollection filteredAirportItems = new();
- private readonly SceneryService _sceneryService;
- // Pagination properties
- private int _currentPage = 1;
- private int _itemsPerPage = 8;
- private int _totalPages = 1;
- private ObservableCollection _currentPageItems = new();
- private string _apiToken = string.Empty; public MainWindow()
- {
- InitializeComponent();
- _sceneryService = SceneryService.Instance;
- LoadApiToken(); // Load API token from settings
- InitializeAirportsCollectionAsync();
- InitializeAirportsAsync();
-
- // Hook up search button and text box enter key
- SearchButton.Click += SearchButton_Click;
- SearchAirportsTextBox.KeyDown += (s, e) => {
- if (e.Key == System.Windows.Input.Key.Enter)
- SearchButton_Click(s, e);
- };
-
- // Hook up pagination buttons
- PreviousPageButton.Click += PreviousPageButton_Click;
- NextPageButton.Click += NextPageButton_Click;
-
- // Hook up API token save button
- SaveTokenButton.Click += SaveTokenButton_Click;
-
- // Hook up window loaded event to populate UI after initialization
- this.Loaded += MainWindow_Loaded;
- }
- ///
- /// Window loaded event handler - populate UI elements after window is fully loaded
- ///
- private void MainWindow_Loaded(object sender, RoutedEventArgs e)
- {
- // Populate API token textbox with the stored value after window is fully loaded
- if (!string.IsNullOrEmpty(_apiToken))
- {
- ApiTokenTextBox.Text = _apiToken;
- }
- }///
- /// Loads the API token from application settings
- ///
- private void LoadApiToken()
- {
- try
- {
- _apiToken = Properties.Settings.Default.apiToken ?? string.Empty;
- Console.WriteLine("API Token loaded from settings");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Error loading API token: {ex.Message}");
- _apiToken = string.Empty;
- }
- }
- ///
- /// Saves the API token to application settings
- ///
- /// The API token to save
- public void SaveApiToken(string token)
- {
- if (string.IsNullOrEmpty(token))
- {
- // Don't save empty tokens
- return;
- }
-
- try
- {
- // Trim any whitespace from the token
- token = token.Trim();
-
- // Only save if token is not empty after trimming
- if (!string.IsNullOrEmpty(token))
- {
- _apiToken = token;
- Properties.Settings.Default.apiToken = token;
- Properties.Settings.Default.Save();
- Console.WriteLine("API Token saved to settings");
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Error saving API token: {ex.Message}");
- MessageBox.Show($"Error saving API token: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
- }
- }
-
- ///
- /// Gets the current API token
- ///
- /// The current API token
- public string GetApiToken()
- {
- return _apiToken;
- }
-
- private void SearchButton_Click(object sender, RoutedEventArgs e)
- {
- ApplySearch(SearchAirportsTextBox.Text);
- }
-
- private async Task InitializeAirportsAsync()
- {
- await AirportService.Instance.InitializeAirportDatabaseAsync();
- }
-
- private void ApplySearch(string searchText)
- {
- // If search is empty, show all airports
- if (string.IsNullOrWhiteSpace(searchText))
- {
- filteredAirportItems = new ObservableCollection(airportItems);
- }
- else
- {
- searchText = searchText.Trim().ToUpperInvariant();
-
- // Filter by ICAO code or package name
- filteredAirportItems.Clear();
- foreach (var airport in airportItems)
- {
- bool matchesIcao = airport.ICAO.ToUpperInvariant().Contains(searchText);
- bool matchesPackage = airport.SceneryPackages.Any(p =>
- p.ToUpperInvariant().Contains(searchText));
-
- if (matchesIcao || matchesPackage)
- {
- filteredAirportItems.Add(airport);
- }
- }
- }
-
- // Reset to first page after search
- _currentPage = 1;
- UpdatePagination();
- }
-
- private void UpdatePagination()
- {
- // Calculate total pages
- _totalPages = (int)Math.Ceiling(filteredAirportItems.Count / (double)_itemsPerPage);
- if (_totalPages == 0) _totalPages = 1;
-
- // Make sure current page is valid
- if (_currentPage < 1) _currentPage = 1;
- if (_currentPage > _totalPages) _currentPage = _totalPages;
-
- // Update page display
- PageInfoText.Text = $"Page {_currentPage} of {_totalPages}";
-
- // Enable/disable navigation buttons
- PreviousPageButton.IsEnabled = _currentPage > 1;
- NextPageButton.IsEnabled = _currentPage < _totalPages;
-
- // Get items for current page
- _currentPageItems.Clear();
- var pagedItems = filteredAirportItems
- .Skip((_currentPage - 1) * _itemsPerPage)
- .Take(_itemsPerPage);
-
- foreach (var item in pagedItems)
- {
- _currentPageItems.Add(item);
- }
-
- // Update the UI with paged results
- AirportsItemsControl.ItemsSource = _currentPageItems;
- }
-
- private void PreviousPageButton_Click(object sender, RoutedEventArgs e)
- {
- if (_currentPage > 1)
- {
- _currentPage--;
- UpdatePagination();
- }
- }
-
- private void NextPageButton_Click(object sender, RoutedEventArgs e)
- {
- if (_currentPage < _totalPages)
- {
- _currentPage++;
- UpdatePagination();
- }
- }
-
- private async void InitializeAirportsCollectionAsync()
- {
- try
- {
- // Get available packages for all airports from the service
- var availablePackages = await _sceneryService.GetAvailablePackagesAsync();
-
- // Clear any existing items
- airportItems.Clear();
-
- // Create airport items for each airport returned from the service
- foreach (var airportEntry in availablePackages)
- {
- // Skip if no packages are available
- if (airportEntry.Value.Count == 0)
- continue;
-
- var airport = new AirportItem(airportEntry.Key, _sceneryService)
- {
- SceneryPackages = new ObservableCollection(airportEntry.Value)
- };
-
- // Set the selected package from saved settings or select the first one as default
- var savedPackage = _sceneryService.GetSelectedPackage(airportEntry.Key);
- airport.SelectedPackage = !string.IsNullOrEmpty(savedPackage) ? savedPackage : airportEntry.Value[0];
- airportItems.Add(airport);
- }
-
- // Sort airports alphabetically by ICAO
- airportItems = new ObservableCollection(airportItems.OrderBy(a => a.ICAO));
-
- // Initialize filtered collection with all items
- filteredAirportItems = new ObservableCollection(airportItems);
-
- // Set up initial pagination
- UpdatePagination();
-
- // Print the count to debug
- Console.WriteLine($"Loaded {airportItems.Count} airports");
- }
- catch (Exception ex)
- {
- MessageBox.Show($"Error loading airport scenery packages: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
- }
- }
- ///
- /// Event handler for the Save Token button
- ///
- private void SaveTokenButton_Click(object sender, RoutedEventArgs e)
- {
- // Get token from textbox and trim it
- string token = ApiTokenTextBox.Text?.Trim() ?? string.Empty;
-
- // Check if token is valid
- if (!string.IsNullOrWhiteSpace(token))
- {
- // Save valid token
- SaveApiToken(token);
- MessageBox.Show("API Token saved successfully!", "Success", MessageBoxButton.OK, MessageBoxImage.Information);
- }
- else
- {
- MessageBox.Show("API Token cannot be empty.", "Warning", MessageBoxButton.OK, MessageBoxImage.Warning);
- }
- }
- }
+namespace BARS_Client_V2;
- public class AirportItem : INotifyPropertyChanged
+public partial class MainWindow : Window
+{
+ public MainWindow()
{
- private readonly SceneryService _sceneryService;
- private string _icao = string.Empty;
- private ObservableCollection _sceneryPackages = new();
- private string _selectedPackage = string.Empty;
-
- public AirportItem(string icao, SceneryService sceneryService)
- {
- _sceneryService = sceneryService;
- ICAO = icao;
- PropertyChanged += AirportItem_PropertyChanged;
- }
-
- private void AirportItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
- {
- if (e.PropertyName == nameof(SelectedPackage) && !string.IsNullOrEmpty(SelectedPackage))
- {
- _sceneryService.SetSelectedPackage(ICAO, SelectedPackage);
- }
- }
-
- public string ICAO
- {
- get => _icao;
- set
- {
- if (_icao != value)
- {
- _icao = value;
- OnPropertyChanged(nameof(ICAO));
- }
- }
- }
-
- public ObservableCollection SceneryPackages
- {
- get => _sceneryPackages;
- set
- {
- if (_sceneryPackages != value)
- {
- _sceneryPackages = value;
- OnPropertyChanged(nameof(SceneryPackages));
- }
- }
- }
-
- public string SelectedPackage
- {
- get => _selectedPackage;
- set
- {
- if (_selectedPackage != value)
- {
- _selectedPackage = value;
- OnPropertyChanged(nameof(SelectedPackage));
- }
- }
- }
-
- public event PropertyChangedEventHandler? PropertyChanged;
-
- protected virtual void OnPropertyChanged(string propertyName)
- {
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
- }
+ // Ignore this error in vscode.
+ InitializeComponent();
}
+
}
\ No newline at end of file
diff --git a/Presentation/Behaviors/PasswordBoxAssistant.cs b/Presentation/Behaviors/PasswordBoxAssistant.cs
new file mode 100644
index 0000000..84e655d
--- /dev/null
+++ b/Presentation/Behaviors/PasswordBoxAssistant.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace BARS_Client_V2.Presentation.Behaviors;
+
+// Enables two-way binding of PasswordBox.Password (which is not a DP by default)
+public static class PasswordBoxAssistant
+{
+ public static readonly DependencyProperty BoundPasswordProperty = DependencyProperty.RegisterAttached(
+ "BoundPassword",
+ typeof(string),
+ typeof(PasswordBoxAssistant),
+ new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnBoundPasswordChanged));
+
+ public static readonly DependencyProperty BindPasswordProperty = DependencyProperty.RegisterAttached(
+ "BindPassword",
+ typeof(bool),
+ typeof(PasswordBoxAssistant),
+ new PropertyMetadata(false, OnBindPasswordChanged));
+
+ private static readonly DependencyProperty UpdatingPasswordProperty = DependencyProperty.RegisterAttached(
+ "UpdatingPassword",
+ typeof(bool),
+ typeof(PasswordBoxAssistant),
+ new PropertyMetadata(false));
+
+ public static string GetBoundPassword(DependencyObject dp) => (string)dp.GetValue(BoundPasswordProperty);
+ public static void SetBoundPassword(DependencyObject dp, string value) => dp.SetValue(BoundPasswordProperty, value);
+
+ public static bool GetBindPassword(DependencyObject dp) => (bool)dp.GetValue(BindPasswordProperty);
+ public static void SetBindPassword(DependencyObject dp, bool value) => dp.SetValue(BindPasswordProperty, value);
+
+ private static bool GetUpdatingPassword(DependencyObject dp) => (bool)dp.GetValue(UpdatingPasswordProperty);
+ private static void SetUpdatingPassword(DependencyObject dp, bool value) => dp.SetValue(UpdatingPasswordProperty, value);
+
+ private static void OnBoundPasswordChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not PasswordBox box) return;
+ if (!GetBindPassword(box)) return;
+
+ // Avoid recursive update
+ if (GetUpdatingPassword(box)) return;
+
+ var newPassword = e.NewValue as string ?? string.Empty;
+ if (box.Password != newPassword)
+ {
+ box.Password = newPassword;
+ }
+ }
+
+ private static void OnBindPasswordChanged(DependencyObject dp, DependencyPropertyChangedEventArgs e)
+ {
+ if (dp is not PasswordBox box) return;
+
+ if ((bool)e.OldValue)
+ {
+ box.PasswordChanged -= HandlePasswordChanged;
+ }
+ if ((bool)e.NewValue)
+ {
+ box.PasswordChanged += HandlePasswordChanged;
+ }
+ }
+
+ private static void HandlePasswordChanged(object sender, RoutedEventArgs e)
+ {
+ if (sender is not PasswordBox box) return;
+ SetUpdatingPassword(box, true);
+ SetBoundPassword(box, box.Password);
+ SetUpdatingPassword(box, false);
+ }
+}
diff --git a/Presentation/Converters/NullOrEmptyToVisibilityConverter.cs b/Presentation/Converters/NullOrEmptyToVisibilityConverter.cs
new file mode 100644
index 0000000..0ae3c06
--- /dev/null
+++ b/Presentation/Converters/NullOrEmptyToVisibilityConverter.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace BARS_Client_V2;
+
+public sealed class NullOrEmptyToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ var s = value as string;
+ return string.IsNullOrEmpty(s) ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/Presentation/ViewModels/MainWindowViewModel.cs b/Presentation/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..d858cbb
--- /dev/null
+++ b/Presentation/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,363 @@
+using System;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Timers;
+using Timer = System.Timers.Timer; // Disambiguate from System.Threading.Timer
+using BARS_Client_V2.Application;
+using BARS_Client_V2.Domain;
+using BARS_Client_V2.Services;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using System.Linq;
+
+namespace BARS_Client_V2.Presentation.ViewModels;
+
+public class MainWindowViewModel : INotifyPropertyChanged
+{
+ private readonly SimulatorManager _simManager;
+ private readonly Timer _uiPoll;
+ private string _closestAirport = "Unknown";
+ private bool _onGround;
+ private string _simulatorName = "Not Connected";
+ private bool _simConnected;
+ private double _latitude;
+ private double _longitude;
+ private readonly IAirportRepository _airportRepo;
+ private readonly ISettingsStore _settingsStore;
+ private readonly int _pageSize = 7;
+ private int _currentPage = 1;
+ private int _totalCount;
+ private string? _searchText;
+ private string? _apiToken;
+ private string? _originalApiToken; // tracks last saved (sanitized) token
+ private string _status = "Ready";
+ private bool _isBusy;
+ private bool _serverConnected; // backend websocket
+ private DateTime _lastServerMessageUtc;
+
+ public ObservableCollection Airports { get; } = new();
+
+ public string ClosestAirport { get => _closestAirport; private set { if (value != _closestAirport) { _closestAirport = value; OnPropertyChanged(); } } }
+
+ public bool OnGround
+ {
+ get => _onGround;
+ set { if (value != _onGround) { _onGround = value; OnPropertyChanged(); OnPropertyChanged(nameof(OnGroundText)); } }
+ }
+
+ public string OnGroundText => OnGround ? "On Ground" : "Airborne";
+ public string SimulatorName { get => _simulatorName; set { if (value != _simulatorName) { _simulatorName = value; OnPropertyChanged(); } } }
+ public bool SimulatorConnected { get => _simConnected; set { if (value != _simConnected) { _simConnected = value; OnPropertyChanged(); OnPropertyChanged(nameof(SimulatorConnectionText)); OnPropertyChanged(nameof(SimulatorStatusColor)); } } }
+ public string SimulatorConnectionText => SimulatorConnected ? "Connected" : "Disconnected";
+ public string SimulatorStatusColor => SimulatorConnected ? "LimeGreen" : "Gray";
+ public bool ServerConnected { get => _serverConnected; private set { if (value != _serverConnected) { _serverConnected = value; OnPropertyChanged(); OnPropertyChanged(nameof(ServerStatusText)); OnPropertyChanged(nameof(ServerStatusColor)); } } }
+ public string ServerStatusText => ServerConnected ? "Connected" : (string.IsNullOrEmpty(ServerStatusDetail) ? "Disconnected" : ServerStatusDetail);
+ public string ServerStatusColor => ServerConnected ? "LimeGreen" : "Gray";
+ public string ServerStatusDetail { get; private set; } = ""; // optional reason
+ public double Latitude { get => _latitude; set { if (value != _latitude) { _latitude = value; OnPropertyChanged(); } } }
+ public double Longitude { get => _longitude; set { if (value != _longitude) { _longitude = value; OnPropertyChanged(); } } }
+
+ public ObservableCollection LogLines { get; } = new();
+
+ private readonly INearestAirportService _nearestService;
+
+ public string? SearchText { get => _searchText; set { if (value != _searchText) { _searchText = value; OnPropertyChanged(); } } }
+ public string? ApiToken
+ {
+ get => _apiToken;
+ set
+ {
+ var sanitized = SanitizeToken(value);
+ if (sanitized != _apiToken)
+ {
+ _apiToken = sanitized;
+ OnPropertyChanged();
+ (SaveTokenCommand as DelegateCommand)?.RaiseCanExecuteChanged();
+ }
+ }
+ }
+ public string Status { get => _status; private set { if (value != _status) { _status = value; OnPropertyChanged(); } } }
+ public bool IsBusy { get => _isBusy; private set { if (value != _isBusy) { _isBusy = value; OnPropertyChanged(); } } }
+ public int CurrentPage { get => _currentPage; private set { if (value != _currentPage) { _currentPage = value; OnPropertyChanged(); OnPropertyChanged(nameof(PageInfo)); UpdatePagingCommands(); } } }
+ public int TotalCount { get => _totalCount; private set { if (value != _totalCount) { _totalCount = value; OnPropertyChanged(); OnPropertyChanged(nameof(PageInfo)); UpdatePagingCommands(); } } }
+ public string PageInfo => $"Page {CurrentPage} of {Math.Max(1, (int)Math.Ceiling(TotalCount / (double)_pageSize))}";
+
+ // Commands (simple DelegateCommand implementation inline)
+ public ICommand SearchCommand { get; }
+ public ICommand NextPageCommand { get; }
+ public ICommand PrevPageCommand { get; }
+ public ICommand SaveTokenCommand { get; }
+
+ public MainWindowViewModel(SimulatorManager simManager, INearestAirportService nearestService, IAirportRepository airportRepository, ISettingsStore settingsStore)
+ {
+ _simManager = simManager;
+ _nearestService = nearestService;
+ _airportRepo = airportRepository;
+ _settingsStore = settingsStore;
+ _uiPoll = new Timer(1000);
+ _uiPoll.Elapsed += (_, _) => RefreshFromState();
+ _uiPoll.Start();
+
+ // Periodically evaluate server connection staleness (if no messages for 30s, mark disconnected)
+ var serverTimer = new Timer(5000);
+ serverTimer.Elapsed += (_, _) =>
+ {
+ // Heartbeat is every 60s; allow >60s (90s) before marking disconnected to avoid flicker.
+ if (_lastServerMessageUtc != DateTime.MinValue && (DateTime.UtcNow - _lastServerMessageUtc) > TimeSpan.FromSeconds(90))
+ {
+ ServerConnected = false;
+ }
+ };
+ serverTimer.Start();
+
+ SearchCommand = new DelegateCommand(async _ => await RunSearchAsync(resetPage: true));
+ NextPageCommand = new DelegateCommand(async _ => { CurrentPage++; await RunSearchAsync(); }, _ => CanChangePage(+1));
+ PrevPageCommand = new DelegateCommand(async _ => { CurrentPage--; await RunSearchAsync(); }, _ => CanChangePage(-1));
+ SaveTokenCommand = new DelegateCommand(async _ => await SaveSettingsAsync(), _ => CanSaveToken());
+
+ // Kick off async load of settings + initial data
+ _ = InitializeAsync();
+ }
+
+ private async Task InitializeAsync()
+ {
+ var settings = await _settingsStore.LoadAsync();
+ // Sanitize and store original token baseline
+ _originalApiToken = SanitizeToken(settings.ApiToken);
+ _apiToken = _originalApiToken; // set backing field directly to avoid redundant raise
+ OnPropertyChanged(nameof(ApiToken));
+ (SaveTokenCommand as DelegateCommand)?.RaiseCanExecuteChanged();
+ _savedPackages = settings.AirportPackages ?? new Dictionary();
+ await RunSearchAsync(resetPage: true);
+ }
+
+ private IDictionary _savedPackages = new Dictionary();
+
+ private bool CanChangePage(int delta)
+ {
+ var newPage = CurrentPage + delta;
+ var totalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)_pageSize));
+ return newPage >= 1 && newPage <= totalPages;
+ }
+
+ private void UpdatePagingCommands()
+ {
+ (NextPageCommand as DelegateCommand)?.RaiseCanExecuteChanged();
+ (PrevPageCommand as DelegateCommand)?.RaiseCanExecuteChanged();
+ }
+
+ private async Task RunSearchAsync(bool resetPage = false)
+ {
+ if (IsBusy) return;
+ try
+ {
+ IsBusy = true;
+ Status = "Searching...";
+ if (resetPage) CurrentPage = 1;
+ var (items, total) = await _airportRepo.SearchAsync(SearchText, CurrentPage, _pageSize);
+ TotalCount = total;
+ Airports.Clear();
+ foreach (var a in items)
+ {
+ var row = new AirportRowViewModel(a);
+ if (_savedPackages.TryGetValue(a.ICAO, out var pkgName))
+ {
+ var match = a.SceneryPackages.FirstOrDefault(p => p.Name == pkgName);
+ if (match != null) row.SelectedPackage = match;
+ }
+ // Auto-select first package if none stored/selected (always at least one per requirements)
+ if (row.SelectedPackage == null && a.SceneryPackages.Count > 0)
+ {
+ row.SelectedPackage = a.SceneryPackages.First();
+ if (!_savedPackages.ContainsKey(a.ICAO))
+ {
+ _savedPackages[a.ICAO] = row.SelectedPackage.Name;
+ // Fire and forget save (debounced vs per-change not critical given infrequent list rebuild)
+ _ = _settingsStore.SaveAsync(new ClientSettings(ApiToken, _savedPackages));
+ }
+ // AirportRowOnPropertyChanged handler will persist selection via PropertyChanged event
+ }
+ row.PropertyChanged += AirportRowOnPropertyChanged;
+ Airports.Add(row);
+ }
+ Status = $"Loaded {Airports.Count} airports";
+ // Refresh command enable states after data load/page change
+ UpdatePagingCommands();
+ }
+ catch (System.Exception ex)
+ {
+ Status = "Error loading airports";
+ LogLines.Add(ex.Message);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private async Task SaveSettingsAsync()
+ {
+ // Persist current selections (all visible) and last selected (most recently changed or first with a selection)
+ var firstSelected = Airports.FirstOrDefault(a => a.SelectedPackage != null);
+ var icao = firstSelected?.ICAO;
+ var pkg = firstSelected?.SelectedPackage?.Name;
+ if (_apiToken != null)
+ {
+ var resanitized = SanitizeToken(_apiToken);
+ if (resanitized != _apiToken)
+ {
+ _apiToken = resanitized;
+ OnPropertyChanged(nameof(ApiToken));
+ (SaveTokenCommand as DelegateCommand)?.RaiseCanExecuteChanged();
+ }
+ }
+
+ if (!IsValidToken(ApiToken))
+ {
+ Status = "API Token must start with 'BARS_'";
+ LogLines.Add(Status);
+ return;
+ }
+
+ await _settingsStore.SaveAsync(new ClientSettings(ApiToken, _savedPackages));
+ Status = "Settings saved";
+ // Update baseline so save button disables until another change
+ _originalApiToken = _apiToken;
+ (SaveTokenCommand as DelegateCommand)?.RaiseCanExecuteChanged();
+ }
+
+ private void AirportRowOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(AirportRowViewModel.SelectedPackage) && sender is AirportRowViewModel row && row.SelectedPackage != null)
+ {
+ _savedPackages[row.ICAO] = row.SelectedPackage.Name;
+ // Fire and forget save to persist selection quickly without blocking UI
+ _ = _settingsStore.SaveAsync(new ClientSettings(ApiToken, _savedPackages));
+ // Also notify SceneryService so the running session hot-reloads the map for this airport (if connected)
+ try { SceneryService.Instance.SetSelectedPackage(row.ICAO, row.SelectedPackage.Name); } catch { }
+ }
+ }
+
+ private void RefreshFromState()
+ {
+ var state = _simManager.LatestState;
+ var connector = _simManager.ActiveConnector;
+ if (state != null)
+ {
+ OnGround = state.OnGround;
+ Latitude = state.Latitude;
+ Longitude = state.Longitude;
+ var cached = _nearestService.GetCachedNearest(Latitude, Longitude);
+ if (cached != null)
+ {
+ ClosestAirport = cached;
+ }
+ else
+ {
+ _ = _nearestService.ResolveAndCacheAsync(Latitude, Longitude)
+ .ContinueWith(t =>
+ {
+ if (t.Status == System.Threading.Tasks.TaskStatus.RanToCompletion && t.Result != null)
+ {
+ ClosestAirport = t.Result;
+ }
+ });
+ }
+ }
+ if (connector != null && connector.IsConnected)
+ {
+ SimulatorName = connector.DisplayName;
+ SimulatorConnected = true;
+ }
+ else
+ {
+ SimulatorName = "Not Connected";
+ SimulatorConnected = false;
+ }
+ }
+
+ // Called externally when a backend websocket message received (wire from AirportWebSocketManager)
+ public void NotifyServerMessage()
+ {
+ _lastServerMessageUtc = DateTime.UtcNow;
+ if (!ServerConnected)
+ {
+ ServerConnected = true;
+ }
+ if (!string.IsNullOrEmpty(ServerStatusDetail))
+ {
+ ServerStatusDetail = string.Empty; OnPropertyChanged(nameof(ServerStatusDetail)); OnPropertyChanged(nameof(ServerStatusText));
+ }
+ }
+
+ public void NotifyServerConnected()
+ {
+ ServerConnected = true;
+ ServerStatusDetail = string.Empty; OnPropertyChanged(nameof(ServerStatusDetail)); OnPropertyChanged(nameof(ServerStatusText));
+ _lastServerMessageUtc = DateTime.UtcNow;
+ }
+
+ public void NotifyServerError(int code)
+ {
+ ServerConnected = false;
+ ServerStatusDetail = code switch
+ {
+ 401 => "Invalid API token",
+ 403 => "Not connected to VATSIM",
+ _ => "Server unavailable"
+ };
+ OnPropertyChanged(nameof(ServerStatusDetail));
+ OnPropertyChanged(nameof(ServerStatusText));
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+
+ private static string? SanitizeToken(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) return null;
+ // Remove all whitespace characters anywhere in the string
+ var cleaned = new string(raw.Where(c => !char.IsWhiteSpace(c)).ToArray());
+ return cleaned;
+ }
+
+ private static bool IsValidToken(string? token)
+ {
+ if (string.IsNullOrEmpty(token)) return true; // Allow empty token (user may clear it)
+ return token.StartsWith("BARS_", StringComparison.Ordinal);
+ }
+
+ private bool CanSaveToken()
+ {
+ // Only allow save if the token value has changed from last saved AND is valid (or user is clearing a previously non-empty token)
+ var current = ApiToken;
+ var changed = current != _originalApiToken;
+ if (!changed) return false;
+ // Allow clearing (current null/empty) or valid token format
+ return string.IsNullOrWhiteSpace(current) || IsValidToken(current);
+ }
+}
+
+public sealed class AirportRowViewModel : INotifyPropertyChanged
+{
+ private readonly BARS_Client_V2.Domain.Airport _airport;
+ private BARS_Client_V2.Domain.SceneryPackage? _selected;
+ public string ICAO => _airport.ICAO;
+ public IReadOnlyList SceneryPackages => _airport.SceneryPackages;
+ public BARS_Client_V2.Domain.SceneryPackage? SelectedPackage { get => _selected; set { if (value != _selected) { _selected = value; OnPropertyChanged(); } } }
+ public AirportRowViewModel(BARS_Client_V2.Domain.Airport airport) { _airport = airport; }
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
+
+internal sealed class DelegateCommand : ICommand
+{
+ private readonly Func