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 @@ - + - + - + - - + + - - - - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - + + - + - - - - - -