From dcbbc07d2486fde5dd0cee8dc61e4bde1b06246f Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Sun, 10 Aug 2025 13:13:56 +0800
Subject: [PATCH 01/20] Refactor MainWindow and Introduce MVVM Pattern
- Removed the MainWindow code-behind logic and migrated to a dedicated MainWindowViewModel.
- Implemented ICommand for search and pagination functionality.
- Introduced AirportRowViewModel for better handling of individual airport data.
- Added PasswordBoxAssistant for two-way binding of PasswordBox.
- Created NullOrEmptyToVisibilityConverter for visibility management based on string content.
- Removed obsolete AirportService and ApiService classes.
- Added NearestAirportService for fetching nearest airport data based on coordinates.
- Improved API token handling and validation logic.
- Enhanced error handling and user feedback mechanisms.
---
App.xaml | 5 +-
App.xaml.cs | 74 +-
Application/IAirportRepository.cs | 11 +
Application/ISettingsStore.cs | 15 +
Application/SimulatorManager.cs | 86 ++
BARS-Client-V2.csproj | 7 +
Domain/Airport.cs | 8 +
Domain/FlightState.cs | 6 +
Domain/IPointStateListener.cs | 15 +
Domain/ISimulatorConnector.cs | 26 +
Domain/Point.cs | 23 +
.../InMemory/InMemoryAirportRepository.cs | 51 ++
.../AirportStreamMessageProcessor.cs | 244 ++++++
.../Networking/AirportWebSocketManager.cs | 321 +++++++
.../Networking/PointStateDispatcher.cs | 31 +
Infrastructure/Settings/JsonSettingsStore.cs | 103 +++
.../Simulators/MSFS/MsfsPointController.cs | 186 ++++
.../Simulators/MSFS/MsfsSimulatorConnector.cs | 100 +++
MainWindow.xaml | 797 +++++++++++-------
MainWindow.xaml.cs | 345 +-------
.../Behaviors/PasswordBoxAssistant.cs | 73 ++
.../NullOrEmptyToVisibilityConverter.cs | 18 +
.../ViewModels/MainWindowViewModel.cs | 348 ++++++++
Services/AirportService.cs | 219 -----
Services/ApiService.cs | 0
Services/NearestAirportService.cs | 88 ++
26 files changed, 2351 insertions(+), 849 deletions(-)
create mode 100644 Application/IAirportRepository.cs
create mode 100644 Application/ISettingsStore.cs
create mode 100644 Application/SimulatorManager.cs
create mode 100644 Domain/Airport.cs
create mode 100644 Domain/FlightState.cs
create mode 100644 Domain/IPointStateListener.cs
create mode 100644 Domain/ISimulatorConnector.cs
create mode 100644 Domain/Point.cs
create mode 100644 Infrastructure/InMemory/InMemoryAirportRepository.cs
create mode 100644 Infrastructure/Networking/AirportStreamMessageProcessor.cs
create mode 100644 Infrastructure/Networking/AirportWebSocketManager.cs
create mode 100644 Infrastructure/Networking/PointStateDispatcher.cs
create mode 100644 Infrastructure/Settings/JsonSettingsStore.cs
create mode 100644 Infrastructure/Simulators/MSFS/MsfsPointController.cs
create mode 100644 Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
create mode 100644 Presentation/Behaviors/PasswordBoxAssistant.cs
create mode 100644 Presentation/Converters/NullOrEmptyToVisibilityConverter.cs
create mode 100644 Presentation/ViewModels/MainWindowViewModel.cs
delete mode 100644 Services/AirportService.cs
delete mode 100644 Services/ApiService.cs
create mode 100644 Services/NearestAirportService.cs
diff --git a/App.xaml b/App.xaml
index 144e218..11c735b 100644
--- a/App.xaml
+++ b/App.xaml
@@ -1,9 +1,8 @@
+ xmlns:local="clr-namespace:BARS_Client_V2">
-
+
diff --git a/App.xaml.cs b/App.xaml.cs
index 8a9341f..ba5b398 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -1,14 +1,80 @@
-using System.Configuration;
-using System.Data;
-using System.Windows;
+using System.Windows;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Http;
+using BARS_Client_V2.Domain;
+using BARS_Client_V2.Application; // Contains SimulatorManager; no conflict if fully qualified below
+using BARS_Client_V2.Presentation.ViewModels;
+using BARS_Client_V2.Services;
namespace BARS_Client_V2
{
///
/// Interaction logic for App.xaml
///
- public partial class App : Application
+ public partial class App : System.Windows.Application
{
+ private IHost? _host;
+
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+ _host = Host.CreateDefaultBuilder()
+ .ConfigureLogging(lb => lb.AddConsole())
+ .ConfigureServices(services =>
+ {
+ // Register simulator connectors (order: real MSFS then mock fallback)
+ services.AddSingleton();
+ // Repositories / stores
+ services.AddSingleton();
+ services.AddSingleton();
+ // Core manager
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService()); // background stream
+ // HTTP + nearest airport service
+ services.AddHttpClient();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddSingleton();
+ services.AddSingleton();
+ // Point state listeners (add MsfsPointController which also runs background loop)
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddHostedService(sp => sp.GetRequiredService());
+ // ViewModels
+ services.AddSingleton();
+ // Windows
+ services.AddTransient();
+ })
+ .Build();
+
+ _host.Start();
+
+ var mainWindow = _host.Services.GetRequiredService();
+ var vm = _host.Services.GetRequiredService();
+ mainWindow.DataContext = vm;
+ // Wire server connection events
+ var wsMgr = _host.Services.GetRequiredService();
+ var processor = _host.Services.GetRequiredService();
+ // Force creation of dispatcher so it subscribes before messages arrive
+ _ = _host.Services.GetRequiredService();
+ wsMgr.Connected += () => vm.NotifyServerConnected();
+ wsMgr.ConnectionError += code => vm.NotifyServerError(code);
+ wsMgr.MessageReceived += msg => { vm.NotifyServerMessage(); _ = processor.ProcessAsync(msg); };
+ mainWindow.Show();
+ }
+
+ protected override async void OnExit(ExitEventArgs e)
+ {
+ if (_host != null)
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+ base.OnExit(e);
+ }
}
}
diff --git a/Application/IAirportRepository.cs b/Application/IAirportRepository.cs
new file mode 100644
index 0000000..4dc8a5c
--- /dev/null
+++ b/Application/IAirportRepository.cs
@@ -0,0 +1,11 @@
+using BARS_Client_V2.Domain;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BARS_Client_V2.Application;
+
+public interface IAirportRepository
+{
+ Task<(IReadOnlyList Items, int TotalCount)> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default);
+}
diff --git a/Application/ISettingsStore.cs b/Application/ISettingsStore.cs
new file mode 100644
index 0000000..68ff406
--- /dev/null
+++ b/Application/ISettingsStore.cs
@@ -0,0 +1,15 @@
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+namespace BARS_Client_V2.Application;
+
+public interface ISettingsStore
+{
+ Task LoadAsync();
+ Task SaveAsync(ClientSettings settings);
+}
+
+public sealed record ClientSettings(string? ApiToken, IDictionary? AirportPackages = null)
+{
+ public static ClientSettings Empty => new(null, new Dictionary());
+}
diff --git a/Application/SimulatorManager.cs b/Application/SimulatorManager.cs
new file mode 100644
index 0000000..25bc64c
--- /dev/null
+++ b/Application/SimulatorManager.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BARS_Client_V2.Domain;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace BARS_Client_V2.Application;
+
+///
+/// Manages available simulator connectors and maintains current flight state.
+/// Runs as background hosted service streaming state.
+///
+public sealed class SimulatorManager : BackgroundService
+{
+ private readonly IEnumerable _connectors;
+ private readonly ILogger _logger;
+ private readonly object _lock = new();
+ private ISimulatorConnector? _active;
+ private FlightState? _latest;
+
+ public SimulatorManager(IEnumerable connectors, ILogger logger)
+ {
+ _connectors = connectors;
+ _logger = logger;
+ }
+
+ public FlightState? LatestState { get { lock (_lock) return _latest; } }
+ public ISimulatorConnector? ActiveConnector { get { lock (_lock) return _active; } }
+
+ public async Task ActivateAsync(string simulatorId, CancellationToken ct = default)
+ {
+ var connector = _connectors.FirstOrDefault(c => string.Equals(c.SimulatorId, simulatorId, StringComparison.OrdinalIgnoreCase));
+ if (connector == null) return false;
+ if (connector == _active && connector.IsConnected) return true;
+
+ if (_active != null && _active.IsConnected)
+ {
+ try { await _active.DisconnectAsync(ct); } catch (Exception ex) { _logger.LogWarning(ex, "Error disconnecting previous simulator"); }
+ }
+
+ if (await connector.ConnectAsync(ct))
+ {
+ lock (_lock) _active = connector;
+ _logger.LogInformation("Activated simulator {sim}", connector.DisplayName);
+ return true;
+ }
+ return false;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Auto select first connector for now
+ var first = _connectors.FirstOrDefault();
+ if (first != null)
+ {
+ await ActivateAsync(first.SimulatorId, stoppingToken);
+ }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var active = ActiveConnector;
+ if (active == null || !active.IsConnected)
+ {
+ await Task.Delay(1000, stoppingToken);
+ continue;
+ }
+ try
+ {
+ await foreach (var raw in active.StreamRawAsync(stoppingToken))
+ {
+ lock (_lock) _latest = new FlightState(raw.Latitude, raw.Longitude, raw.OnGround);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error streaming flight state");
+ // small backoff
+ await Task.Delay(2000, stoppingToken);
+ }
+ }
+ }
+}
diff --git a/BARS-Client-V2.csproj b/BARS-Client-V2.csproj
index 095b471..4b2d7dc 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -14,6 +14,13 @@
+
+
+
+
+
+
+
True
diff --git a/Domain/Airport.cs b/Domain/Airport.cs
new file mode 100644
index 0000000..b170acd
--- /dev/null
+++ b/Domain/Airport.cs
@@ -0,0 +1,8 @@
+namespace BARS_Client_V2.Domain;
+
+///
+/// Basic airport domain model containing ICAO code and available scenery packages.
+///
+public sealed record SceneryPackage(string Name);
+
+public sealed record Airport(string ICAO, IReadOnlyList SceneryPackages);
diff --git a/Domain/FlightState.cs b/Domain/FlightState.cs
new file mode 100644
index 0000000..0c1e8fa
--- /dev/null
+++ b/Domain/FlightState.cs
@@ -0,0 +1,6 @@
+namespace BARS_Client_V2.Domain;
+
+///
+/// Raw flight state (position + ground flag) before nearest-airport lookup.
+///
+public sealed record FlightState(double Latitude, double Longitude, bool OnGround);
diff --git a/Domain/IPointStateListener.cs b/Domain/IPointStateListener.cs
new file mode 100644
index 0000000..63e5896
--- /dev/null
+++ b/Domain/IPointStateListener.cs
@@ -0,0 +1,15 @@
+using System.Threading.Tasks;
+
+namespace BARS_Client_V2.Domain;
+
+///
+/// Consumer of point state updates (e.g. a simulator-specific controller).
+///
+public interface IPointStateListener
+{
+ ///
+ /// Called for every point state update (initial + deltas).
+ /// Should be non-blocking; heavy work should be queued internally.
+ ///
+ void OnPointStateChanged(PointState state);
+}
diff --git a/Domain/ISimulatorConnector.cs b/Domain/ISimulatorConnector.cs
new file mode 100644
index 0000000..d7f0110
--- /dev/null
+++ b/Domain/ISimulatorConnector.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BARS_Client_V2.Domain;
+
+///
+/// Raw data emitted directly from simulator before nearest-airport resolution.
+///
+public sealed record RawFlightSample(double Latitude, double Longitude, bool OnGround);
+
+///
+/// Abstraction for any flight simulator connector.
+///
+public interface ISimulatorConnector
+{
+ string SimulatorId { get; }
+ string DisplayName { get; }
+ bool IsConnected { get; }
+ Task ConnectAsync(CancellationToken ct = default);
+ Task DisconnectAsync(CancellationToken ct = default);
+ ///
+ /// Stream raw flight samples (lat, lon, onGround). Yields at sensible intervals or when state changes.
+ ///
+ IAsyncEnumerable StreamRawAsync(CancellationToken ct = default);
+}
diff --git a/Domain/Point.cs b/Domain/Point.cs
new file mode 100644
index 0000000..4d98bb9
--- /dev/null
+++ b/Domain/Point.cs
@@ -0,0 +1,23 @@
+namespace BARS_Client_V2.Domain;
+
+///
+/// Metadata for a controllable airfield object (stopbar / light / etc.).
+///
+public sealed record PointMetadata(
+ string Id,
+ string AirportId,
+ string Type,
+ string Name,
+ double Latitude,
+ double Longitude,
+ string? Directionality,
+ string? Orientation,
+ string? Color,
+ bool Elevated,
+ bool Ihp
+);
+
+///
+/// Current dynamic state of a point (on/off) plus metadata.
+///
+public sealed record PointState(PointMetadata Metadata, bool IsOn, long TimestampMs);
diff --git a/Infrastructure/InMemory/InMemoryAirportRepository.cs b/Infrastructure/InMemory/InMemoryAirportRepository.cs
new file mode 100644
index 0000000..ff804b7
--- /dev/null
+++ b/Infrastructure/InMemory/InMemoryAirportRepository.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BARS_Client_V2.Application;
+using BARS_Client_V2.Domain;
+
+namespace BARS_Client_V2.Infrastructure.InMemory;
+
+///
+/// Temporary in-memory airport repository until real backend integration.
+///
+internal sealed class InMemoryAirportRepository : IAirportRepository
+{
+ private readonly List _airports;
+
+ public InMemoryAirportRepository()
+ {
+ _airports = new List
+ {
+ new("KJFK", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("Drzewiecki Design KJFK"), }),
+ new("EGLL", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("IniBuilds EGLL"), }),
+ new("KLAX", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("IniBuilds KLAX"), }),
+ new("KSEA", new []{ new SceneryPackage("Asobo Default") }),
+ new("LFPG", new []{ new SceneryPackage("Asobo Default") }),
+ new("EDDF", new []{ new SceneryPackage("Aerosoft EDDF") }),
+ new("EDDM", new []{ new SceneryPackage("SimWings EDDM") }),
+ new("CYYZ", new []{ new SceneryPackage("FSimStudios CYYZ") }),
+ new("YSSY", new []{ new SceneryPackage("FlyTampa YSSY") }),
+ new("KSFO", new []{ new SceneryPackage("FlightBeam KSFO"), new SceneryPackage("Asobo Default") }),
+ };
+ }
+
+ public Task<(IReadOnlyList Items, int TotalCount)> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default)
+ {
+ IEnumerable q = _airports;
+ if (!string.IsNullOrWhiteSpace(search))
+ {
+ search = search.Trim().ToUpperInvariant();
+ q = q.Where(a => a.ICAO.Contains(search, StringComparison.OrdinalIgnoreCase) || a.SceneryPackages.Any(p => p.Name.Contains(search, StringComparison.OrdinalIgnoreCase)));
+ }
+ var total = q.Count();
+ var items = q
+ .OrderBy(a => a.ICAO)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToList();
+ return Task.FromResult(((IReadOnlyList)items, total));
+ }
+}
diff --git a/Infrastructure/Networking/AirportStreamMessageProcessor.cs b/Infrastructure/Networking/AirportStreamMessageProcessor.cs
new file mode 100644
index 0000000..102fa1a
--- /dev/null
+++ b/Infrastructure/Networking/AirportStreamMessageProcessor.cs
@@ -0,0 +1,244 @@
+using System;
+using System.Collections.Concurrent;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using BARS_Client_V2.Domain;
+using Microsoft.Extensions.Logging;
+
+namespace BARS_Client_V2.Infrastructure.Networking;
+
+///
+/// Parses messages from and maintains an in-memory cache
+/// of point metadata + state. Fires events when point state changes are received.
+///
+internal sealed class AirportStreamMessageProcessor
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _points = new(); // key: point id -> last state
+ private readonly ConcurrentDictionary _metadata = new(); // key: point id -> metadata only (for quick lookup)
+
+ public event Action? PointStateChanged; // fired for each STATE_UPDATE (after metadata fetch if needed)
+ public event Action? InitialStateLoaded; // airport ICAO when initial state processed
+
+ public AirportStreamMessageProcessor(IHttpClientFactory httpFactory, ILogger logger)
+ {
+ _httpClient = httpFactory.CreateClient();
+ _logger = logger;
+ }
+
+ public PointState? TryGetPoint(string id) => _points.TryGetValue(id, out var ps) ? ps : null;
+
+ ///
+ /// Entry point invoked by websocket manager for each raw JSON message.
+ ///
+ public async Task ProcessAsync(string json, CancellationToken ct = default)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return;
+ if (!root.TryGetProperty("type", out var typeProp)) return;
+ var type = typeProp.GetString();
+ switch (type)
+ {
+ case "INITIAL_STATE":
+ await HandleInitialStateAsync(root, ct);
+ break;
+ case "STATE_UPDATE":
+ await HandleStateUpdateAsync(root, ct);
+ break;
+ case "HEARTBEAT_ACK":
+ // nothing to do;
+ break;
+ default:
+ _logger.LogDebug("Unhandled stream message type {type}", type);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to process stream message");
+ }
+ }
+
+ private async Task HandleInitialStateAsync(JsonElement root, CancellationToken ct)
+ {
+ if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return;
+ if (!data.TryGetProperty("objects", out var objects) || objects.ValueKind != JsonValueKind.Array) return;
+
+ // Collect raw entries first
+ var tempList = new System.Collections.Generic.List<(string id, bool state, long ts)>();
+ int idx = 0;
+ foreach (var obj in objects.EnumerateArray())
+ {
+ if (obj.ValueKind != JsonValueKind.Object) continue;
+ var id = obj.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(id)) continue;
+ var state = obj.TryGetProperty("state", out var stateProp) && stateProp.ValueKind == JsonValueKind.True;
+ var ts = obj.TryGetProperty("timestamp", out var tsProp) && tsProp.TryGetInt64(out var lts) ? lts : 0L;
+ tempList.Add((id!, state, ts));
+ if (idx++ < 5) // sample a few for visibility
+ {
+ _logger.LogInformation("Initial object {id} state={state} ts={ts}", id, state, ts);
+ }
+ }
+
+ if (tempList.Count == 0) return;
+ _logger.LogInformation("INITIAL_STATE contained {count} objects", tempList.Count);
+
+ // Determine which IDs lack metadata
+ var missing = new System.Collections.Generic.List();
+ foreach (var entry in tempList)
+ {
+ if (!_metadata.ContainsKey(entry.id)) missing.Add(entry.id);
+ }
+ if (missing.Count > 0)
+ {
+ _logger.LogInformation("Fetching metadata batch for {missingCount} new points", missing.Count);
+ // Batch in chunks of 100 (API limit)
+ const int batchSize = 100;
+ for (int i = 0; i < missing.Count; i += batchSize)
+ {
+ var slice = missing.GetRange(i, Math.Min(batchSize, missing.Count - i));
+ await FetchMetadataBatchAsync(slice, ct);
+ _logger.LogInformation("Fetched metadata batch size {batch}", slice.Count);
+ }
+ }
+
+ // Now create states
+ foreach (var (id, state, ts) in tempList)
+ {
+ if (_metadata.TryGetValue(id, out var meta))
+ {
+ var ps = new PointState(meta, state, ts);
+ _points[id] = ps;
+ try { PointStateChanged?.Invoke(ps); } catch { }
+ }
+ else
+ {
+ // Fallback single fetch if batch missed or failed
+ var ps = await EnsureMetadataAndCreateStateAsync(id, state, ts, ct);
+ if (ps != null)
+ {
+ _points[id] = ps;
+ try { PointStateChanged?.Invoke(ps); } catch { }
+ }
+ }
+ }
+
+ var airport = root.TryGetProperty("airport", out var aProp) ? aProp.GetString() : null;
+ if (!string.IsNullOrWhiteSpace(airport))
+ {
+ try { InitialStateLoaded?.Invoke(airport!); } catch { }
+ }
+ }
+
+ private async Task HandleStateUpdateAsync(JsonElement root, CancellationToken ct)
+ {
+ if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return;
+ var id = data.TryGetProperty("objectId", out var idProp) ? idProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(id)) return;
+ var newState = data.TryGetProperty("state", out var stateProp) && stateProp.ValueKind == JsonValueKind.True;
+ var ts = root.TryGetProperty("timestamp", out var tsProp) && tsProp.TryGetInt64(out var lts) ? lts : 0L;
+ var ps = await EnsureMetadataAndCreateStateAsync(id!, newState, ts, ct);
+ if (ps != null)
+ {
+ _points[id!] = ps;
+ _logger.LogInformation("STATE_UPDATE {id} -> {state} ts={ts}", id, newState, ts);
+ try { PointStateChanged?.Invoke(ps); } catch { }
+ }
+ }
+
+ private async Task EnsureMetadataAndCreateStateAsync(string id, bool isOn, long ts, CancellationToken ct)
+ {
+ if (_metadata.TryGetValue(id, out var metaFromCache))
+ {
+ return new PointState(metaFromCache, isOn, ts);
+ }
+ try
+ {
+ var url = $"https://v2.stopbars.com/points/{id}";
+ using var resp = await _httpClient.GetAsync(url, ct);
+ if (!resp.IsSuccessStatusCode)
+ {
+ _logger.LogDebug("Point metadata fetch failed for {id} status {status}", id, resp.StatusCode);
+ return null;
+ }
+ var json = await resp.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(json);
+ if (TryParsePointMetadata(doc.RootElement, out var meta))
+ {
+ _metadata[id] = meta!;
+ return new PointState(meta!, isOn, ts);
+ }
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error fetching point metadata for {id}", id);
+ return null;
+ }
+ }
+
+ private async Task FetchMetadataBatchAsync(System.Collections.Generic.IReadOnlyCollection ids, CancellationToken ct)
+ {
+ if (ids.Count == 0) return;
+ try
+ {
+ var url = $"https://v2.stopbars.com/points?ids={string.Join(",", ids)}"; // API expects comma separated
+ using var resp = await _httpClient.GetAsync(url, ct);
+ if (!resp.IsSuccessStatusCode)
+ {
+ _logger.LogDebug("Batch metadata fetch failed status {status}", resp.StatusCode);
+ return;
+ }
+ var json = await resp.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(json);
+ if (!doc.RootElement.TryGetProperty("points", out var pts) || pts.ValueKind != JsonValueKind.Array) return;
+ foreach (var p in pts.EnumerateArray())
+ {
+ if (TryParsePointMetadata(p, out var meta))
+ {
+ _metadata[meta!.Id] = meta!;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Batch metadata fetch error");
+ }
+ }
+
+ private bool TryParsePointMetadata(JsonElement root, out PointMetadata? meta)
+ {
+ try
+ {
+ var id = root.TryGetProperty("id", out var idp) ? idp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(id)) { meta = null; return false; }
+ string airportId = root.TryGetProperty("airportId", out var ap) ? ap.GetString() ?? string.Empty : string.Empty;
+ string type = root.TryGetProperty("type", out var tp) ? tp.GetString() ?? string.Empty : string.Empty;
+ string name = root.TryGetProperty("name", out var np) ? np.GetString() ?? string.Empty : string.Empty;
+ double lat = 0, lon = 0;
+ if (root.TryGetProperty("coordinates", out var coord) && coord.ValueKind == JsonValueKind.Object)
+ {
+ lat = coord.TryGetProperty("lat", out var latProp) && latProp.TryGetDouble(out var dlat) ? dlat : 0;
+ lon = coord.TryGetProperty("lng", out var lonProp) && lonProp.TryGetDouble(out var dlon) ? dlon : 0;
+ }
+ string? directionality = root.TryGetProperty("directionality", out var dir) ? dir.GetString() : null;
+ string? orientation = root.TryGetProperty("orientation", out var ori) ? ori.GetString() : null;
+ string? color = root.TryGetProperty("color", out var col) ? col.GetString() : null;
+ bool elevated = root.TryGetProperty("elevated", out var el) && el.ValueKind == JsonValueKind.True;
+ bool ihp = root.TryGetProperty("ihp", out var ihpProp) && ihpProp.ValueKind == JsonValueKind.True;
+ meta = new PointMetadata(id, airportId, type, name, lat, lon, directionality, orientation, color, elevated, ihp);
+ return true;
+ }
+ catch
+ {
+ meta = null; return false;
+ }
+ }
+}
diff --git a/Infrastructure/Networking/AirportWebSocketManager.cs b/Infrastructure/Networking/AirportWebSocketManager.cs
new file mode 100644
index 0000000..93830b4
--- /dev/null
+++ b/Infrastructure/Networking/AirportWebSocketManager.cs
@@ -0,0 +1,321 @@
+using System;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using BARS_Client_V2.Application;
+using BARS_Client_V2.Services;
+
+namespace BARS_Client_V2.Infrastructure.Networking;
+
+///
+/// Maintains a WebSocket connection to the backend airport stream endpoint when conditions are met.
+/// Conditions:
+/// - Simulator connected
+/// - Aircraft on ground
+/// - Nearby airport resolved (ICAO length 4)
+/// - Valid API token (starts with BARS_)
+/// Disconnects when any condition no longer holds or airport changes.
+///
+internal sealed class AirportWebSocketManager : BackgroundService
+{
+ private readonly SimulatorManager _simManager;
+ private readonly INearestAirportService _nearestAirportService;
+ private readonly ISettingsStore _settingsStore;
+ private readonly ILogger _logger;
+ private readonly object _sync = new();
+
+ private ClientWebSocket? _ws;
+ private string? _connectedAirport;
+ private string? _apiToken; // cached
+ private DateTime _lastTokenLoadUtc = DateTime.MinValue;
+ private Task? _receiveLoopTask;
+ private CancellationTokenSource? _receiveCts;
+ private DateTime _nextConnectAttemptUtc = DateTime.MinValue; // backoff gate
+ private Task? _heartbeatTask;
+ private string? _tokenUsedForConnection;
+
+ public string? ConnectedAirport { get { lock (_sync) return _connectedAirport; } }
+ public bool IsConnected { get { lock (_sync) return _ws?.State == WebSocketState.Open; } }
+ public event Action? MessageReceived;
+ public event Action? Connected;
+ public event Action? ConnectionError; // status code (e.g. 401, 403)
+
+ public AirportWebSocketManager(
+ SimulatorManager simManager,
+ INearestAirportService nearestAirportService,
+ ISettingsStore settingsStore,
+ ILogger logger)
+ {
+ _simManager = simManager;
+ _nearestAirportService = nearestAirportService;
+ _settingsStore = settingsStore;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await EvaluateAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in airport WebSocket manager loop");
+ }
+ await Task.Delay(2000, stoppingToken);
+ }
+ await DisconnectAsync("Service stopping");
+ }
+
+ private async Task EvaluateAsync(CancellationToken ct)
+ {
+ var flight = _simManager.LatestState;
+ var connector = _simManager.ActiveConnector;
+ if (flight == null || connector == null || !connector.IsConnected)
+ {
+ await DisconnectAsync("No active simulator");
+ return;
+ }
+
+ if (!flight.OnGround)
+ {
+ await DisconnectAsync("Airborne");
+ return;
+ }
+
+ string? icao = _nearestAirportService.GetCachedNearest(flight.Latitude, flight.Longitude);
+ if (icao == null)
+ {
+ try { icao = await _nearestAirportService.ResolveAndCacheAsync(flight.Latitude, flight.Longitude, ct); } catch { }
+ }
+
+ if (string.IsNullOrWhiteSpace(icao) || icao.Length != 4)
+ {
+ await DisconnectAsync("No nearby airport");
+ return;
+ }
+
+ var token = await GetApiTokenAsync(ct);
+ if (!IsValidToken(token))
+ {
+ await DisconnectAsync("Missing/invalid API token");
+ return;
+ }
+
+ lock (_sync)
+ {
+ if (_ws != null && _ws.State == WebSocketState.Open &&
+ string.Equals(_connectedAirport, icao, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(_tokenUsedForConnection, token, StringComparison.Ordinal))
+ {
+ return; // already connected to desired airport with same token
+ }
+ }
+
+ // Respect backoff window after failures (e.g. 403 when user not authorized/connected)
+ if (DateTime.UtcNow < _nextConnectAttemptUtc)
+ {
+ return;
+ }
+
+ await ConnectAsync(icao, token!, ct);
+ }
+
+ private async Task GetApiTokenAsync(CancellationToken ct)
+ {
+ // Always reload to react quickly to user changes (cheap IO)
+ try
+ {
+ var settings = await _settingsStore.LoadAsync();
+ _apiToken = settings.ApiToken;
+ _lastTokenLoadUtc = DateTime.UtcNow;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to load settings for API token");
+ }
+ return _apiToken;
+ }
+
+ private static bool IsValidToken(string? token) => !string.IsNullOrWhiteSpace(token) && token.StartsWith("BARS_", StringComparison.Ordinal);
+
+ private async Task ConnectAsync(string icao, string token, CancellationToken ct)
+ {
+ await DisconnectAsync("Switching airport/token");
+ var uri = new Uri($"wss://v2.stopbars.com/connect?airport={icao.ToUpperInvariant()}&key={token}");
+ var ws = new ClientWebSocket();
+ try
+ {
+ _logger.LogInformation("Connecting airport WebSocket for {icao}", icao);
+ await ws.ConnectAsync(uri, ct);
+ if (ws.State != WebSocketState.Open)
+ {
+ _logger.LogWarning("Airport WebSocket not open after connect attempt (state {state})", ws.State);
+ ws.Dispose();
+ _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(10); // generic backoff
+ return;
+ }
+ lock (_sync)
+ {
+ _ws = ws;
+ _connectedAirport = icao;
+ _receiveCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ _receiveLoopTask = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token));
+ _tokenUsedForConnection = token;
+ _heartbeatTask = Task.Run(() => HeartbeatLoopAsync(_receiveCts.Token));
+ }
+ _logger.LogInformation("Airport WebSocket connected for {icao}", icao);
+ _nextConnectAttemptUtc = DateTime.MinValue; // reset on success
+ try { Connected?.Invoke(); } catch { }
+ }
+ catch (OperationCanceledException)
+ {
+ ws.Dispose();
+ }
+ catch (WebSocketException wex)
+ {
+ _logger.LogWarning(wex, "Airport WebSocket connect failed for {icao}: {msg}", icao, wex.Message);
+ ws.Dispose();
+ // If 403 (user not connected to VATSIM / not authorized) apply longer backoff to avoid spam
+ if (wex.Message.Contains("403"))
+ {
+ _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(10);
+ try { ConnectionError?.Invoke(403); } catch { }
+ }
+ else
+ {
+ _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ if (wex.Message.Contains("401"))
+ {
+ try { ConnectionError?.Invoke(401); } catch { }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error connecting airport WebSocket for {icao}", icao);
+ ws.Dispose();
+ _nextConnectAttemptUtc = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ try { ConnectionError?.Invoke(0); } catch { }
+ }
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken ct)
+ {
+ var localWs = _ws;
+ if (localWs == null) return;
+ var buffer = new byte[8192];
+ try
+ {
+ while (!ct.IsCancellationRequested && localWs.State == WebSocketState.Open)
+ {
+ var sb = new StringBuilder();
+ WebSocketReceiveResult? result;
+ do
+ {
+ result = await localWs.ReceiveAsync(buffer, ct);
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ _logger.LogInformation("Airport WebSocket closed by server: {status} {desc}", result.CloseStatus, result.CloseStatusDescription);
+ await DisconnectAsync("Server closed");
+ return;
+ }
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
+ }
+ } while (!result.EndOfMessage);
+
+ if (sb.Length > 0)
+ {
+ var msg = sb.ToString();
+ try { MessageReceived?.Invoke(msg); } catch { }
+ }
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (WebSocketException wex)
+ {
+ _logger.LogWarning(wex, "Airport WebSocket receive error");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error in Airport WebSocket receive loop");
+ }
+ finally
+ {
+ await DisconnectAsync("Receive loop ended");
+ }
+ }
+
+ private async Task DisconnectAsync(string reason)
+ {
+ ClientWebSocket? ws;
+ CancellationTokenSource? rcts;
+ lock (_sync)
+ {
+ ws = _ws;
+ rcts = _receiveCts;
+ _ws = null;
+ _receiveCts = null;
+ _receiveLoopTask = null;
+ _heartbeatTask = null;
+ if (_connectedAirport != null)
+ {
+ _logger.LogInformation("Disconnecting airport WebSocket ({airport}) - {reason}", _connectedAirport, reason);
+ }
+ _connectedAirport = null;
+ _tokenUsedForConnection = null;
+ }
+ try { rcts?.Cancel(); } catch { }
+ if (ws != null)
+ {
+ try
+ {
+ if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived)
+ {
+ // Attempt to send CLOSE message before closing websocket
+ try
+ {
+ var payload = Encoding.UTF8.GetBytes("{ \"type\": \"CLOSE\" }");
+ using var sendCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ await ws.SendAsync(payload, WebSocketMessageType.Text, true, sendCts.Token);
+ }
+ catch { }
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, reason, cts.Token);
+ }
+ }
+ catch { }
+ finally { ws.Dispose(); }
+ }
+ }
+
+ private async Task HeartbeatLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try { await Task.Delay(TimeSpan.FromSeconds(60), ct); } catch { break; }
+ if (ct.IsCancellationRequested) break;
+ ClientWebSocket? ws;
+ lock (_sync) ws = _ws;
+ if (ws == null || ws.State != WebSocketState.Open) continue;
+ try
+ {
+ var hb = Encoding.UTF8.GetBytes("{ \"type\": \"HEARTBEAT\" }");
+ using var sendCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await ws.SendAsync(hb, WebSocketMessageType.Text, true, sendCts.Token);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Heartbeat send failed");
+ }
+ }
+ }
+}
diff --git a/Infrastructure/Networking/PointStateDispatcher.cs b/Infrastructure/Networking/PointStateDispatcher.cs
new file mode 100644
index 0000000..e5e50e1
--- /dev/null
+++ b/Infrastructure/Networking/PointStateDispatcher.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+using BARS_Client_V2.Domain;
+using Microsoft.Extensions.Logging;
+
+namespace BARS_Client_V2.Infrastructure.Networking;
+
+///
+/// Subscribes once to AirportStreamMessageProcessor and forwards to all registered IPointStateListener instances.
+///
+internal sealed class PointStateDispatcher
+{
+ private readonly IEnumerable _listeners;
+ private readonly ILogger _logger;
+
+ public PointStateDispatcher(AirportStreamMessageProcessor processor, IEnumerable listeners, ILogger logger)
+ {
+ _listeners = listeners;
+ _logger = logger;
+ processor.PointStateChanged += OnPointStateChanged;
+ _logger.LogInformation("PointStateDispatcher initialized with {count} listeners", _listeners.Count());
+ }
+
+ private void OnPointStateChanged(PointState ps)
+ {
+ foreach (var l in _listeners)
+ {
+ try { l.OnPointStateChanged(ps); } catch (Exception ex) { _logger.LogDebug(ex, "Listener threw"); }
+ }
+ }
+}
diff --git a/Infrastructure/Settings/JsonSettingsStore.cs b/Infrastructure/Settings/JsonSettingsStore.cs
new file mode 100644
index 0000000..f5844c7
--- /dev/null
+++ b/Infrastructure/Settings/JsonSettingsStore.cs
@@ -0,0 +1,103 @@
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using BARS_Client_V2.Application;
+
+namespace BARS_Client_V2.Infrastructure.Settings;
+
+internal sealed class JsonSettingsStore : ISettingsStore
+{
+ private readonly string _path;
+
+ private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ // Additional entropy for DPAPI to slightly harden against trivial copy (still tied to user/machine scope)
+ private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("BARS.Client.V2|ApiToken|v1");
+
+ private sealed class Persisted
+ {
+ public string? ApiToken { get; set; }
+ public Dictionary? AirportPackages { get; set; }
+ }
+
+ public JsonSettingsStore()
+ {
+ var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var folder = Path.Combine(root, "BARS", "Client");
+ Directory.CreateDirectory(folder);
+ _path = Path.Combine(folder, "settings.json");
+ }
+
+ public async Task LoadAsync()
+ {
+ if (!File.Exists(_path)) return ClientSettings.Empty;
+ try
+ {
+ var json = await File.ReadAllTextAsync(_path);
+ var p = JsonSerializer.Deserialize(json, Options);
+ if (p == null) return ClientSettings.Empty;
+
+ string? token = null;
+
+ // Prefer encrypted token if present
+ if (!string.IsNullOrWhiteSpace(p.ApiToken))
+ {
+ try
+ {
+ var protectedBytes = Convert.FromBase64String(p.ApiToken);
+ var unprotected = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser);
+ token = Encoding.UTF8.GetString(unprotected);
+ }
+ catch
+ {
+ // If decryption fails, fall back to legacy plaintext if available
+ token = p.ApiToken;
+ }
+ }
+ else
+ {
+ // Legacy plaintext migration path
+ token = p.ApiToken;
+ }
+
+ return new ClientSettings(token, p.AirportPackages ?? new());
+ }
+ catch
+ {
+ return ClientSettings.Empty;
+ }
+ }
+
+ public async Task SaveAsync(ClientSettings settings)
+ {
+ var p = new Persisted
+ {
+ AirportPackages = settings.AirportPackages != null ? new Dictionary(settings.AirportPackages) : new()
+ };
+
+ if (!string.IsNullOrWhiteSpace(settings.ApiToken))
+ {
+ try
+ {
+ var plaintextBytes = Encoding.UTF8.GetBytes(settings.ApiToken);
+ var protectedBytes = ProtectedData.Protect(plaintextBytes, Entropy, DataProtectionScope.CurrentUser);
+ p.ApiToken = Convert.ToBase64String(protectedBytes);
+ }
+ catch
+ {
+ // Fallback: if encryption fails for some reason, we persist nothing rather than plaintext.
+ p.ApiToken = null;
+ }
+ }
+ var json = JsonSerializer.Serialize(p, Options);
+ await File.WriteAllTextAsync(_path, json);
+ }
+}
diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
new file mode 100644
index 0000000..436a4cc
--- /dev/null
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -0,0 +1,186 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BARS_Client_V2.Domain;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace BARS_Client_V2.Infrastructure.Simulators.Msfs;
+
+///
+/// Queues point state changes and (eventually) reflects them inside MSFS by spawning / updating custom SimObjects.
+/// Currently contains stubs for spawn/despawn until concrete SimObject titles & WASM variables are defined.
+///
+internal sealed class MsfsPointController : BackgroundService, IPointStateListener
+{
+ private readonly ILogger _logger;
+ private readonly ISimulatorConnector _connector; // rely on same DI instance (assumed MSFS)
+ private readonly ConcurrentQueue _queue = new();
+ private readonly Dictionary _spawned = new(); // pointId -> placeholder handle
+ private readonly SemaphoreSlim _semaphore = new(1, 1);
+
+ private readonly int _maxObjects;
+ private readonly int _spawnPerSecond;
+ private DateTime _nextSpawnWindow = DateTime.UtcNow;
+ private int _spawnedThisWindow;
+
+ // Stats
+ private long _totalReceived;
+ private long _totalSpawnAttempts;
+ private long _totalSpawned;
+ private long _totalDespawned;
+ private long _totalDeferredRate;
+ private long _totalSkippedCap;
+ private DateTime _lastSummary = DateTime.UtcNow;
+
+ public MsfsPointController(IEnumerable connectors, ILogger logger, MsfsPointControllerOptions? options = null)
+ {
+ // Select the MSFS connector (first one with SimulatorId == MSFS or just first)
+ _connector = connectors.FirstOrDefault(c => string.Equals(c.SimulatorId, "MSFS", StringComparison.OrdinalIgnoreCase))
+ ?? connectors.First();
+ _logger = logger;
+ options ??= new MsfsPointControllerOptions();
+ _maxObjects = options.MaxObjects;
+ _spawnPerSecond = options.SpawnPerSecond;
+ }
+
+ public void OnPointStateChanged(PointState state)
+ {
+ _queue.Enqueue(state);
+ var qLen = _queue.Count;
+ var meta = state.Metadata;
+ Interlocked.Increment(ref _totalReceived);
+ _logger.LogInformation("[Recv] {id} on={on} type={type} name={name} airport={apt} lat={lat:F6} lon={lon:F6} queue={q}",
+ meta.Id, state.IsOn, meta.Type, meta.Name, meta.AirportId, meta.Latitude, meta.Longitude, qLen);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("MsfsPointController started (max={max} rate/s={rate})", _maxObjects, _spawnPerSecond);
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ if (!_connector.IsConnected)
+ {
+ await Task.Delay(1000, stoppingToken);
+ continue;
+ }
+ if (_queue.TryDequeue(out var ps))
+ {
+ _logger.LogTrace("Dequeued {id} on={on}", ps.Metadata.Id, ps.IsOn);
+ await ProcessAsync(ps, stoppingToken);
+ }
+ else
+ {
+ await Task.Delay(50, stoppingToken); // idle backoff
+ }
+
+ // Periodic summary every 30s
+ if ((DateTime.UtcNow - _lastSummary) > TimeSpan.FromSeconds(30))
+ {
+ _lastSummary = DateTime.UtcNow;
+ _logger.LogInformation("[Summary] received={rec} spawned={sp} active={active} despawned={des} deferredRate={def} skippedCap={cap} queue={q}",
+ _totalReceived, _totalSpawned, _spawned.Count, _totalDespawned, _totalDeferredRate, _totalSkippedCap, _queue.Count);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error in MsfsPointController loop");
+ await Task.Delay(500, stoppingToken);
+ }
+ }
+ }
+
+ private async Task ProcessAsync(PointState ps, CancellationToken ct)
+ {
+ var id = ps.Metadata.Id;
+ var exists = _spawned.ContainsKey(id);
+ if (ps.IsOn)
+ {
+ if (!exists)
+ {
+ if (_spawned.Count >= _maxObjects)
+ {
+ // Skip spawning to respect cap
+ Interlocked.Increment(ref _totalSkippedCap);
+ _logger.LogWarning("[SpawnSkip:Cap] {id} cap={cap} active={active}", id, _maxObjects, _spawned.Count);
+ return;
+ }
+ if (!CanSpawnNow())
+ {
+ // requeue later to respect rate limit
+ Interlocked.Increment(ref _totalDeferredRate);
+ _logger.LogDebug("[SpawnDefer:Rate] {id} windowRemaining={rem}", id, Math.Max(0, _spawnPerSecond - _spawnedThisWindow));
+ _queue.Enqueue(ps);
+ return;
+ }
+ var handle = await SpawnStubAsync(ps, ct);
+ if (handle != null)
+ {
+ _spawned[id] = handle;
+ Interlocked.Increment(ref _totalSpawned);
+ _logger.LogInformation("[Spawn] {id} name={name} type={type} active={count}", id, ps.Metadata.Name, ps.Metadata.Type, _spawned.Count);
+ }
+ }
+ else
+ {
+ // Future: update existing object light state (stub)
+ _logger.LogDebug("[Update] {id} state={state}", id, ps.IsOn);
+ }
+ }
+ else
+ {
+ if (exists)
+ {
+ if (_spawned.TryGetValue(id, out var handle))
+ {
+ await DespawnStubAsync(handle, ct);
+ _spawned.Remove(id);
+ Interlocked.Increment(ref _totalDespawned);
+ _logger.LogInformation("[Despawn] {id} remaining={count}", id, _spawned.Count);
+ }
+ }
+ }
+ }
+
+ private bool CanSpawnNow()
+ {
+ var now = DateTime.UtcNow;
+ if (now > _nextSpawnWindow)
+ {
+ _nextSpawnWindow = now.AddSeconds(1);
+ _spawnedThisWindow = 0;
+ }
+ if (_spawnedThisWindow < _spawnPerSecond)
+ {
+ _spawnedThisWindow++;
+ return true;
+ }
+ return false;
+ }
+
+ private Task
diff --git a/Domain/Airport.cs b/Domain/Airport.cs
index b170acd..52a30f2 100644
--- a/Domain/Airport.cs
+++ b/Domain/Airport.cs
@@ -1,8 +1,5 @@
namespace BARS_Client_V2.Domain;
-///
-/// Basic airport domain model containing ICAO code and available scenery packages.
-///
public sealed record SceneryPackage(string Name);
public sealed record Airport(string ICAO, IReadOnlyList SceneryPackages);
diff --git a/Domain/FlightState.cs b/Domain/FlightState.cs
index 0c1e8fa..2e88e7b 100644
--- a/Domain/FlightState.cs
+++ b/Domain/FlightState.cs
@@ -1,6 +1,3 @@
namespace BARS_Client_V2.Domain;
-///
-/// Raw flight state (position + ground flag) before nearest-airport lookup.
-///
public sealed record FlightState(double Latitude, double Longitude, bool OnGround);
diff --git a/Domain/IPointStateListener.cs b/Domain/IPointStateListener.cs
index 63e5896..72d487d 100644
--- a/Domain/IPointStateListener.cs
+++ b/Domain/IPointStateListener.cs
@@ -2,14 +2,7 @@
namespace BARS_Client_V2.Domain;
-///
-/// Consumer of point state updates (e.g. a simulator-specific controller).
-///
public interface IPointStateListener
{
- ///
- /// Called for every point state update (initial + deltas).
- /// Should be non-blocking; heavy work should be queued internally.
- ///
void OnPointStateChanged(PointState state);
}
diff --git a/Domain/ISimulatorConnector.cs b/Domain/ISimulatorConnector.cs
index d7f0110..dd08445 100644
--- a/Domain/ISimulatorConnector.cs
+++ b/Domain/ISimulatorConnector.cs
@@ -4,14 +4,8 @@
namespace BARS_Client_V2.Domain;
-///
-/// Raw data emitted directly from simulator before nearest-airport resolution.
-///
public sealed record RawFlightSample(double Latitude, double Longitude, bool OnGround);
-///
-/// Abstraction for any flight simulator connector.
-///
public interface ISimulatorConnector
{
string SimulatorId { get; }
@@ -19,8 +13,5 @@ public interface ISimulatorConnector
bool IsConnected { get; }
Task ConnectAsync(CancellationToken ct = default);
Task DisconnectAsync(CancellationToken ct = default);
- ///
- /// Stream raw flight samples (lat, lon, onGround). Yields at sensible intervals or when state changes.
- ///
IAsyncEnumerable StreamRawAsync(CancellationToken ct = default);
}
diff --git a/Domain/Point.cs b/Domain/Point.cs
index 4d98bb9..ee0c4ad 100644
--- a/Domain/Point.cs
+++ b/Domain/Point.cs
@@ -1,8 +1,5 @@
namespace BARS_Client_V2.Domain;
-///
-/// Metadata for a controllable airfield object (stopbar / light / etc.).
-///
public sealed record PointMetadata(
string Id,
string AirportId,
@@ -17,7 +14,4 @@ public sealed record PointMetadata(
bool Ihp
);
-///
-/// Current dynamic state of a point (on/off) plus metadata.
-///
public sealed record PointState(PointMetadata Metadata, bool IsOn, long TimestampMs);
diff --git a/Infrastructure/InMemory/InMemoryAirportRepository.cs b/Infrastructure/InMemory/InMemoryAirportRepository.cs
index ff804b7..8e9da30 100644
--- a/Infrastructure/InMemory/InMemoryAirportRepository.cs
+++ b/Infrastructure/InMemory/InMemoryAirportRepository.cs
@@ -8,9 +8,6 @@
namespace BARS_Client_V2.Infrastructure.InMemory;
-///
-/// Temporary in-memory airport repository until real backend integration.
-///
internal sealed class InMemoryAirportRepository : IAirportRepository
{
private readonly List _airports;
diff --git a/Infrastructure/Networking/AirportStateHub.cs b/Infrastructure/Networking/AirportStateHub.cs
new file mode 100644
index 0000000..39cfd40
--- /dev/null
+++ b/Infrastructure/Networking/AirportStateHub.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+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;
+
+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
+
+ public AirportStateHub(IHttpClientFactory httpFactory, ILogger logger)
+ {
+ _httpClient = httpFactory.CreateClient();
+ _logger = logger;
+ }
+
+ public event Action? MapLoaded; // airport
+ public event Action? PointStateChanged; // fired for initial + updates
+
+ 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_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");
+ }
+ }
+
+ 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;
+ 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))
+ {
+ // Create placeholder if not in map (should be rare)
+ 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;
+ count++;
+ try { PointStateChanged?.Invoke(ps); } catch { }
+ }
+ _logger.LogInformation("INITIAL_STATE processed {count} points for {apt}", count, airport);
+ }
+
+ 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))
+ {
+ meta = new PointMetadata(id!, _mapAirport ?? string.Empty, "", id!, 0, 0, null, null, null, false, false);
+ _metadata[id!] = meta;
+ }
+ var ps = new PointState(meta, on, ts);
+ _states[id!] = ps;
+ 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;
+ _metadata.Clear();
+ _layouts.Clear();
+ var url = $"https://v2.stopbars.com/maps/{airport}/latest";
+ _logger.LogInformation("Fetching airport XML map {url}", url);
+ using var resp = await _httpClient.GetAsync(url, ct);
+ if (!resp.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Airport map fetch failed {status}", resp.StatusCode);
+ return;
+ }
+ var xml = await resp.Content.ReadAsStringAsync(ct);
+ try
+ {
+ var doc = XDocument.Parse(xml);
+ ParseMap(doc, airport);
+ _mapAirport = airport;
+ try { MapLoaded?.Invoke(airport); } catch { }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error parsing airport map {apt}", airport);
+ }
+ }
+ finally
+ {
+ _mapLock.Release();
+ }
+ }
+
+ private void ParseMap(XDocument doc, string airport)
+ {
+ var root = doc.Root;
+ if (root == null || root.Name.LocalName != "BarsLights") return;
+ int pointCount = 0, lightCount = 0;
+ foreach (var obj in root.Elements("BarsObject"))
+ {
+ 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;
+ var lightList = new List();
+ double sumLat = 0, sumLon = 0; int cnt = 0;
+ 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;
+ var stateAttr = le.Attribute("stateId")?.Value;
+ if (int.TryParse(stateAttr, out var sidVal)) stateId = sidVal;
+ lightList.Add(new LightLayout(lat, lon, hdg, lColor, stateId));
+ sumLat += lat; sumLon += lon; cnt++; lightCount++;
+ }
+ double repLat = 0, repLon = 0;
+ if (cnt > 0) { repLat = sumLat / cnt; repLon = sumLon / cnt; }
+ var meta = new PointMetadata(id!, airport, type, id!, repLat, repLon, null, orientation, color, false, false);
+ _metadata[id!] = meta;
+ if (lightList.Count > 0) _layouts[id!] = lightList;
+ pointCount++;
+ }
+ _logger.LogInformation("Parsed map {apt} points={pts} lights={lights}", airport, pointCount, 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);
+}
diff --git a/Infrastructure/Networking/AirportStreamMessageProcessor.cs b/Infrastructure/Networking/AirportStreamMessageProcessor.cs
deleted file mode 100644
index 102fa1a..0000000
--- a/Infrastructure/Networking/AirportStreamMessageProcessor.cs
+++ /dev/null
@@ -1,244 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Net.Http;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using BARS_Client_V2.Domain;
-using Microsoft.Extensions.Logging;
-
-namespace BARS_Client_V2.Infrastructure.Networking;
-
-///
-/// Parses messages from and maintains an in-memory cache
-/// of point metadata + state. Fires events when point state changes are received.
-///
-internal sealed class AirportStreamMessageProcessor
-{
- private readonly HttpClient _httpClient;
- private readonly ILogger _logger;
- private readonly ConcurrentDictionary _points = new(); // key: point id -> last state
- private readonly ConcurrentDictionary _metadata = new(); // key: point id -> metadata only (for quick lookup)
-
- public event Action? PointStateChanged; // fired for each STATE_UPDATE (after metadata fetch if needed)
- public event Action? InitialStateLoaded; // airport ICAO when initial state processed
-
- public AirportStreamMessageProcessor(IHttpClientFactory httpFactory, ILogger logger)
- {
- _httpClient = httpFactory.CreateClient();
- _logger = logger;
- }
-
- public PointState? TryGetPoint(string id) => _points.TryGetValue(id, out var ps) ? ps : null;
-
- ///
- /// Entry point invoked by websocket manager for each raw JSON message.
- ///
- public async Task ProcessAsync(string json, CancellationToken ct = default)
- {
- try
- {
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
- if (root.ValueKind != JsonValueKind.Object) return;
- if (!root.TryGetProperty("type", out var typeProp)) return;
- var type = typeProp.GetString();
- switch (type)
- {
- case "INITIAL_STATE":
- await HandleInitialStateAsync(root, ct);
- break;
- case "STATE_UPDATE":
- await HandleStateUpdateAsync(root, ct);
- break;
- case "HEARTBEAT_ACK":
- // nothing to do;
- break;
- default:
- _logger.LogDebug("Unhandled stream message type {type}", type);
- break;
- }
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Failed to process stream message");
- }
- }
-
- private async Task HandleInitialStateAsync(JsonElement root, CancellationToken ct)
- {
- if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return;
- if (!data.TryGetProperty("objects", out var objects) || objects.ValueKind != JsonValueKind.Array) return;
-
- // Collect raw entries first
- var tempList = new System.Collections.Generic.List<(string id, bool state, long ts)>();
- int idx = 0;
- foreach (var obj in objects.EnumerateArray())
- {
- if (obj.ValueKind != JsonValueKind.Object) continue;
- var id = obj.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
- if (string.IsNullOrWhiteSpace(id)) continue;
- var state = obj.TryGetProperty("state", out var stateProp) && stateProp.ValueKind == JsonValueKind.True;
- var ts = obj.TryGetProperty("timestamp", out var tsProp) && tsProp.TryGetInt64(out var lts) ? lts : 0L;
- tempList.Add((id!, state, ts));
- if (idx++ < 5) // sample a few for visibility
- {
- _logger.LogInformation("Initial object {id} state={state} ts={ts}", id, state, ts);
- }
- }
-
- if (tempList.Count == 0) return;
- _logger.LogInformation("INITIAL_STATE contained {count} objects", tempList.Count);
-
- // Determine which IDs lack metadata
- var missing = new System.Collections.Generic.List();
- foreach (var entry in tempList)
- {
- if (!_metadata.ContainsKey(entry.id)) missing.Add(entry.id);
- }
- if (missing.Count > 0)
- {
- _logger.LogInformation("Fetching metadata batch for {missingCount} new points", missing.Count);
- // Batch in chunks of 100 (API limit)
- const int batchSize = 100;
- for (int i = 0; i < missing.Count; i += batchSize)
- {
- var slice = missing.GetRange(i, Math.Min(batchSize, missing.Count - i));
- await FetchMetadataBatchAsync(slice, ct);
- _logger.LogInformation("Fetched metadata batch size {batch}", slice.Count);
- }
- }
-
- // Now create states
- foreach (var (id, state, ts) in tempList)
- {
- if (_metadata.TryGetValue(id, out var meta))
- {
- var ps = new PointState(meta, state, ts);
- _points[id] = ps;
- try { PointStateChanged?.Invoke(ps); } catch { }
- }
- else
- {
- // Fallback single fetch if batch missed or failed
- var ps = await EnsureMetadataAndCreateStateAsync(id, state, ts, ct);
- if (ps != null)
- {
- _points[id] = ps;
- try { PointStateChanged?.Invoke(ps); } catch { }
- }
- }
- }
-
- var airport = root.TryGetProperty("airport", out var aProp) ? aProp.GetString() : null;
- if (!string.IsNullOrWhiteSpace(airport))
- {
- try { InitialStateLoaded?.Invoke(airport!); } catch { }
- }
- }
-
- private async Task HandleStateUpdateAsync(JsonElement root, CancellationToken ct)
- {
- if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return;
- var id = data.TryGetProperty("objectId", out var idProp) ? idProp.GetString() : null;
- if (string.IsNullOrWhiteSpace(id)) return;
- var newState = data.TryGetProperty("state", out var stateProp) && stateProp.ValueKind == JsonValueKind.True;
- var ts = root.TryGetProperty("timestamp", out var tsProp) && tsProp.TryGetInt64(out var lts) ? lts : 0L;
- var ps = await EnsureMetadataAndCreateStateAsync(id!, newState, ts, ct);
- if (ps != null)
- {
- _points[id!] = ps;
- _logger.LogInformation("STATE_UPDATE {id} -> {state} ts={ts}", id, newState, ts);
- try { PointStateChanged?.Invoke(ps); } catch { }
- }
- }
-
- private async Task EnsureMetadataAndCreateStateAsync(string id, bool isOn, long ts, CancellationToken ct)
- {
- if (_metadata.TryGetValue(id, out var metaFromCache))
- {
- return new PointState(metaFromCache, isOn, ts);
- }
- try
- {
- var url = $"https://v2.stopbars.com/points/{id}";
- using var resp = await _httpClient.GetAsync(url, ct);
- if (!resp.IsSuccessStatusCode)
- {
- _logger.LogDebug("Point metadata fetch failed for {id} status {status}", id, resp.StatusCode);
- return null;
- }
- var json = await resp.Content.ReadAsStringAsync(ct);
- using var doc = JsonDocument.Parse(json);
- if (TryParsePointMetadata(doc.RootElement, out var meta))
- {
- _metadata[id] = meta!;
- return new PointState(meta!, isOn, ts);
- }
- return null;
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Error fetching point metadata for {id}", id);
- return null;
- }
- }
-
- private async Task FetchMetadataBatchAsync(System.Collections.Generic.IReadOnlyCollection ids, CancellationToken ct)
- {
- if (ids.Count == 0) return;
- try
- {
- var url = $"https://v2.stopbars.com/points?ids={string.Join(",", ids)}"; // API expects comma separated
- using var resp = await _httpClient.GetAsync(url, ct);
- if (!resp.IsSuccessStatusCode)
- {
- _logger.LogDebug("Batch metadata fetch failed status {status}", resp.StatusCode);
- return;
- }
- var json = await resp.Content.ReadAsStringAsync(ct);
- using var doc = JsonDocument.Parse(json);
- if (!doc.RootElement.TryGetProperty("points", out var pts) || pts.ValueKind != JsonValueKind.Array) return;
- foreach (var p in pts.EnumerateArray())
- {
- if (TryParsePointMetadata(p, out var meta))
- {
- _metadata[meta!.Id] = meta!;
- }
- }
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Batch metadata fetch error");
- }
- }
-
- private bool TryParsePointMetadata(JsonElement root, out PointMetadata? meta)
- {
- try
- {
- var id = root.TryGetProperty("id", out var idp) ? idp.GetString() : null;
- if (string.IsNullOrWhiteSpace(id)) { meta = null; return false; }
- string airportId = root.TryGetProperty("airportId", out var ap) ? ap.GetString() ?? string.Empty : string.Empty;
- string type = root.TryGetProperty("type", out var tp) ? tp.GetString() ?? string.Empty : string.Empty;
- string name = root.TryGetProperty("name", out var np) ? np.GetString() ?? string.Empty : string.Empty;
- double lat = 0, lon = 0;
- if (root.TryGetProperty("coordinates", out var coord) && coord.ValueKind == JsonValueKind.Object)
- {
- lat = coord.TryGetProperty("lat", out var latProp) && latProp.TryGetDouble(out var dlat) ? dlat : 0;
- lon = coord.TryGetProperty("lng", out var lonProp) && lonProp.TryGetDouble(out var dlon) ? dlon : 0;
- }
- string? directionality = root.TryGetProperty("directionality", out var dir) ? dir.GetString() : null;
- string? orientation = root.TryGetProperty("orientation", out var ori) ? ori.GetString() : null;
- string? color = root.TryGetProperty("color", out var col) ? col.GetString() : null;
- bool elevated = root.TryGetProperty("elevated", out var el) && el.ValueKind == JsonValueKind.True;
- bool ihp = root.TryGetProperty("ihp", out var ihpProp) && ihpProp.ValueKind == JsonValueKind.True;
- meta = new PointMetadata(id, airportId, type, name, lat, lon, directionality, orientation, color, elevated, ihp);
- return true;
- }
- catch
- {
- meta = null; return false;
- }
- }
-}
diff --git a/Infrastructure/Networking/AirportWebSocketManager.cs b/Infrastructure/Networking/AirportWebSocketManager.cs
index 93830b4..868d72d 100644
--- a/Infrastructure/Networking/AirportWebSocketManager.cs
+++ b/Infrastructure/Networking/AirportWebSocketManager.cs
@@ -10,15 +10,6 @@
namespace BARS_Client_V2.Infrastructure.Networking;
-///
-/// Maintains a WebSocket connection to the backend airport stream endpoint when conditions are met.
-/// Conditions:
-/// - Simulator connected
-/// - Aircraft on ground
-/// - Nearby airport resolved (ICAO length 4)
-/// - Valid API token (starts with BARS_)
-/// Disconnects when any condition no longer holds or airport changes.
-///
internal sealed class AirportWebSocketManager : BackgroundService
{
private readonly SimulatorManager _simManager;
@@ -42,6 +33,7 @@ internal sealed class AirportWebSocketManager : BackgroundService
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,
@@ -295,6 +287,7 @@ private async Task DisconnectAsync(string reason)
catch { }
finally { ws.Dispose(); }
}
+ try { Disconnected?.Invoke(reason); } catch { }
}
private async Task HeartbeatLoopAsync(CancellationToken ct)
diff --git a/Infrastructure/Networking/PointStateDispatcher.cs b/Infrastructure/Networking/PointStateDispatcher.cs
deleted file mode 100644
index e5e50e1..0000000
--- a/Infrastructure/Networking/PointStateDispatcher.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System;
-using System.Collections.Generic;
-using BARS_Client_V2.Domain;
-using Microsoft.Extensions.Logging;
-
-namespace BARS_Client_V2.Infrastructure.Networking;
-
-///
-/// Subscribes once to AirportStreamMessageProcessor and forwards to all registered IPointStateListener instances.
-///
-internal sealed class PointStateDispatcher
-{
- private readonly IEnumerable _listeners;
- private readonly ILogger _logger;
-
- public PointStateDispatcher(AirportStreamMessageProcessor processor, IEnumerable listeners, ILogger logger)
- {
- _listeners = listeners;
- _logger = logger;
- processor.PointStateChanged += OnPointStateChanged;
- _logger.LogInformation("PointStateDispatcher initialized with {count} listeners", _listeners.Count());
- }
-
- private void OnPointStateChanged(PointState ps)
- {
- foreach (var l in _listeners)
- {
- try { l.OnPointStateChanged(ps); } catch (Exception ex) { _logger.LogDebug(ex, "Listener threw"); }
- }
- }
-}
diff --git a/Infrastructure/Settings/JsonSettingsStore.cs b/Infrastructure/Settings/JsonSettingsStore.cs
index f5844c7..278fdaf 100644
--- a/Infrastructure/Settings/JsonSettingsStore.cs
+++ b/Infrastructure/Settings/JsonSettingsStore.cs
@@ -19,7 +19,6 @@ internal sealed class JsonSettingsStore : ISettingsStore
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
- // Additional entropy for DPAPI to slightly harden against trivial copy (still tied to user/machine scope)
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("BARS.Client.V2|ApiToken|v1");
private sealed class Persisted
@@ -47,7 +46,6 @@ public async Task LoadAsync()
string? token = null;
- // Prefer encrypted token if present
if (!string.IsNullOrWhiteSpace(p.ApiToken))
{
try
@@ -58,13 +56,11 @@ public async Task LoadAsync()
}
catch
{
- // If decryption fails, fall back to legacy plaintext if available
token = p.ApiToken;
}
}
else
{
- // Legacy plaintext migration path
token = p.ApiToken;
}
@@ -93,7 +89,6 @@ public async Task SaveAsync(ClientSettings settings)
}
catch
{
- // Fallback: if encryption fails for some reason, we persist nothing rather than plaintext.
p.ApiToken = null;
}
}
diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
index 436a4cc..9b9d963 100644
--- a/Infrastructure/Simulators/MSFS/MsfsPointController.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -2,11 +2,16 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
+using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
+// XML parsing handled centrally in AirportStateHub
using BARS_Client_V2.Domain;
+using BARS_Client_V2.Infrastructure.Networking;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Http;
+using SimConnect.NET.AI;
namespace BARS_Client_V2.Infrastructure.Simulators.Msfs;
@@ -19,13 +24,23 @@ internal sealed class MsfsPointController : BackgroundService, IPointStateListen
private readonly ILogger _logger;
private readonly ISimulatorConnector _connector; // rely on same DI instance (assumed MSFS)
private readonly ConcurrentQueue _queue = new();
- private readonly Dictionary _spawned = new(); // pointId -> placeholder handle
- private readonly SemaphoreSlim _semaphore = new(1, 1);
+ // pointId -> spawned group (stores sim objects and their layout positions)
+ private readonly Dictionary _spawned = new();
+ // Synchronize access to _spawned to allow external mass-despawn while background loop runs
+ private readonly object _spawnLock = new();
+
+ // Track latest states and reference to central hub for layouts
+ private readonly ConcurrentDictionary _latestStates = new();
+ private readonly AirportStateHub _hub;
private readonly int _maxObjects;
private readonly int _spawnPerSecond;
private DateTime _nextSpawnWindow = DateTime.UtcNow;
private int _spawnedThisWindow;
+ private readonly int _idleDelayMs;
+ private readonly int _disconnectedDelayMs;
+ private readonly int _errorBackoffMs;
+ private readonly int _overlapDespawnDelayMs;
// Stats
private long _totalReceived;
@@ -36,7 +51,18 @@ internal sealed class MsfsPointController : BackgroundService, IPointStateListen
private long _totalSkippedCap;
private DateTime _lastSummary = DateTime.UtcNow;
- public MsfsPointController(IEnumerable connectors, ILogger logger, MsfsPointControllerOptions? options = null)
+ // Track repeated spawn failures per point to apply a cooldown and avoid flooding SimConnect
+ private readonly ConcurrentDictionary _spawnFailures = new();
+ private readonly TimeSpan _failureCooldown = TimeSpan.FromSeconds(10); // per point pause after repeated failures
+ private const int FailureThresholdForCooldown = 3; // consecutive failures before we start cooldown
+ private readonly ConcurrentDictionary _nextAttemptUtc = new(); // per-point dynamic backoff
+ // For overlap transitions store old handles until replacement group fully spawned
+ private readonly ConcurrentDictionary> _pendingOverlapOld = new();
+
+ public MsfsPointController(IEnumerable connectors,
+ ILogger logger,
+ AirportStateHub hub,
+ MsfsPointControllerOptions? options = null)
{
// Select the MSFS connector (first one with SimulatorId == MSFS or just first)
_connector = connectors.FirstOrDefault(c => string.Equals(c.SimulatorId, "MSFS", StringComparison.OrdinalIgnoreCase))
@@ -45,10 +71,19 @@ public MsfsPointController(IEnumerable connectors, ILogger<
options ??= new MsfsPointControllerOptions();
_maxObjects = options.MaxObjects;
_spawnPerSecond = options.SpawnPerSecond;
+ _hub = hub;
+ _hub.PointStateChanged += OnPointStateChanged;
+ _hub.MapLoaded += _ => ResyncActivePointsAfterLayout();
+
+ _idleDelayMs = options.IdleDelayMs;
+ _disconnectedDelayMs = options.DisconnectedDelayMs;
+ _errorBackoffMs = options.ErrorBackoffMs;
+ _overlapDespawnDelayMs = options.OverlapDespawnDelayMs;
}
public void OnPointStateChanged(PointState state)
{
+ _latestStates[state.Metadata.Id] = state;
_queue.Enqueue(state);
var qLen = _queue.Count;
var meta = state.Metadata;
@@ -66,7 +101,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_connector.IsConnected)
{
- await Task.Delay(1000, stoppingToken);
+ if ((_totalReceived % 25) == 0) _logger.LogDebug("[Loop] Waiting for simulator connection. Queue={q}", _queue.Count);
+ await Task.Delay(_disconnectedDelayMs, stoppingToken);
continue;
}
if (_queue.TryDequeue(out var ps))
@@ -76,7 +112,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
else
{
- await Task.Delay(50, stoppingToken); // idle backoff
+ // Occasionally log idle state
+ if (DateTime.UtcNow.Second % 20 == 0)
+ _logger.LogTrace("[Idle] queue empty activePoints={points} totalLights={lights}", _spawned.Count, TotalActiveLightCount());
+ await Task.Delay(_idleDelayMs, stoppingToken); // reduced idle backoff
}
// Periodic summary every 30s
@@ -91,7 +130,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
catch (Exception ex)
{
_logger.LogDebug(ex, "Error in MsfsPointController loop");
- await Task.Delay(500, stoppingToken);
+ try { await Task.Delay(_errorBackoffMs, stoppingToken); } catch { }
}
}
}
@@ -99,52 +138,139 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
private async Task ProcessAsync(PointState ps, CancellationToken ct)
{
var id = ps.Metadata.Id;
- var exists = _spawned.ContainsKey(id);
- if (ps.IsOn)
+ bool exists; SpawnGroup? existingGroup;
+ lock (_spawnLock) exists = _spawned.TryGetValue(id, out existingGroup);
+
+ var layouts = GetOrBuildLayouts(ps, existingGroup?.Layouts);
+ if (layouts.Count == 0) return;
+ bool toOn = ps.IsOn;
+ bool isToggle = exists && existingGroup != null && existingGroup.IsOn != toOn;
+
+ if (!toOn)
{
- if (!exists)
+ // OFF transition behavior:
+ // 1. If currently ON: spawn a full set of OFF placeholders (stateId=0) overlapping existing ON lights, then delayed-despawn old lights.
+ // 2. If already OFF but incomplete: incrementally spawn missing OFF placeholders.
+ // 3. If already OFF and full: skip.
+ if (exists && existingGroup != null && !existingGroup.IsOn)
{
- if (_spawned.Count >= _maxObjects)
+ // Already off placeholders present
+ if (existingGroup.Objects.Count >= layouts.Count)
{
- // Skip spawning to respect cap
- Interlocked.Increment(ref _totalSkippedCap);
- _logger.LogWarning("[SpawnSkip:Cap] {id} cap={cap} active={active}", id, _maxObjects, _spawned.Count);
+ _logger.LogTrace("[ProcessSkip:OffFull] {id} lights={lights}", id, existingGroup.Objects.Count);
return;
}
- if (!CanSpawnNow())
- {
- // requeue later to respect rate limit
- Interlocked.Increment(ref _totalDeferredRate);
- _logger.LogDebug("[SpawnDefer:Rate] {id} windowRemaining={rem}", id, Math.Max(0, _spawnPerSecond - _spawnedThisWindow));
- _queue.Enqueue(ps);
- return;
- }
- var handle = await SpawnStubAsync(ps, ct);
- if (handle != null)
- {
- _spawned[id] = handle;
- Interlocked.Increment(ref _totalSpawned);
- _logger.LogInformation("[Spawn] {id} name={name} type={type} active={count}", id, ps.Metadata.Name, ps.Metadata.Type, _spawned.Count);
- }
+ // Need to top up missing placeholders
+ await SpawnMissingAsync(id, existingGroup, layouts, ct, isOn: false, useLayoutStateIds: false, forcedStateId: 0);
+ TryCompleteOverlap(id);
+ return;
}
- else
+
+ // Was ON -> overlap spawn OFF placeholders (stateId=0) but keep old ON lights until full set of OFF spawned
+ if (exists && existingGroup != null && existingGroup.IsOn)
{
- // Future: update existing object light state (stub)
- _logger.LogDebug("[Update] {id} state={state}", id, ps.IsOn);
+ // store old handles if first time in this overlap
+ _pendingOverlapOld.AddOrUpdate(id,
+ _ => existingGroup.Objects.ToList(),
+ (_, prev) => prev); // keep original list
}
+ await SpawnGroupFreshAsync(id, layouts, useLayoutStateIds: false, ct, ignoreCapForTransition: true, isOn: false, forcedStateId: 0);
+ TryCompleteOverlap(id);
+ return;
+ }
+
+ // ON flow
+ if (!isToggle && exists && existingGroup != null && existingGroup.IsOn && existingGroup.Objects.Count >= layouts.Count)
+ {
+ _logger.LogTrace("[ProcessSkip:OnFull] {id} lights={lights}", id, existingGroup.Objects.Count);
+ return;
}
- else
+
+ // Backoff (ON only)
+ if (_nextAttemptUtc.TryGetValue(id, out var next) && DateTime.UtcNow < next)
+ {
+ var remaining = (next - DateTime.UtcNow).TotalMilliseconds;
+ if (remaining > 0 && remaining < 1500)
+ _logger.LogTrace("[ProcessDefer:Backoff] {id} msLeft={ms:F0}", id, remaining);
+ if (remaining < _idleDelayMs * 4 && _latestStates.TryGetValue(id, out var latestBack))
+ _queue.Enqueue(latestBack);
+ return;
+ }
+
+ // Failure cooldown (ON only)
+ if (_spawnFailures.TryGetValue(id, out var failureInfo))
{
- if (exists)
+ var since = DateTime.UtcNow - failureInfo.LastFailureUtc;
+ if (failureInfo.Failures >= FailureThresholdForCooldown && since < _failureCooldown)
{
- if (_spawned.TryGetValue(id, out var handle))
+ if (since.TotalSeconds < 1)
+ _logger.LogDebug("[ProcessDefer:FailureCooldown] {id} remainingMs={ms:F0}", id, (_failureCooldown - since).TotalMilliseconds);
+ if ((_failureCooldown - since) < TimeSpan.FromMilliseconds(_idleDelayMs * 5) && _latestStates.TryGetValue(id, out var latestCool))
+ _queue.Enqueue(latestCool);
+ return;
+ }
+ }
+
+ if (exists && existingGroup != null)
+ {
+ // If coming from OFF -> ON toggle initiate overlap (store old off placeholders)
+ if (isToggle && !existingGroup.IsOn)
+ {
+ _pendingOverlapOld.AddOrUpdate(id,
+ _ => existingGroup.Objects.ToList(),
+ (_, prev) => prev);
+ }
+ if (existingGroup.IsOn && existingGroup.Objects.Count < layouts.Count)
+ {
+ await SpawnMissingAsync(id, existingGroup, layouts, ct, isOn: true, useLayoutStateIds: true, forcedStateId: null);
+ TryCompleteOverlap(id);
+ return;
+ }
+ }
+
+ // Fresh (new point or transitioning from OFF with no existing ON objects recorded yet)
+ await SpawnGroupFreshAsync(id, layouts, useLayoutStateIds: true, ct, ignoreCapForTransition: isToggle, isOn: true, forcedStateId: null);
+ TryCompleteOverlap(id);
+ }
+
+ private void TryCompleteOverlap(string pointId)
+ {
+ if (!_pendingOverlapOld.TryGetValue(pointId, out var oldHandles)) return;
+ SpawnGroup? current;
+ lock (_spawnLock)
+ {
+ if (!_spawned.TryGetValue(pointId, out current)) return;
+ }
+ if (current == null || current.Objects.Count < current.Layouts.Count) return; // not yet full
+ // Full new group spawned -> remove old
+ if (_pendingOverlapOld.TryRemove(pointId, out oldHandles))
+ {
+ _ = Task.Run(async () =>
+ {
+ // Validate that the current cached desired state matches the replacement group's IsOn to avoid race conditions.
+ if (_latestStates.TryGetValue(pointId, out var desired))
{
- await DespawnStubAsync(handle, ct);
- _spawned.Remove(id);
- Interlocked.Increment(ref _totalDespawned);
- _logger.LogInformation("[Despawn] {id} remaining={count}", id, _spawned.Count);
+ if (desired.IsOn != current.IsOn)
+ {
+ _logger.LogTrace("[OverlapAbort:StateChanged] {id} desiredOn={des} currentOn={cur}", pointId, desired.IsOn, current.IsOn);
+ // Re-store old handles (in case another transition will clean them up) and exit.
+ _pendingOverlapOld[pointId] = oldHandles;
+ return;
+ }
}
- }
+ int removed = 0;
+ foreach (var h in oldHandles)
+ {
+ try
+ {
+ await DespawnLightAsync(h, CancellationToken.None);
+ Interlocked.Increment(ref _totalDespawned);
+ removed++;
+ }
+ catch { }
+ }
+ _logger.LogDebug("[OverlapComplete] {id} oldRemoved={removed}", pointId, removed);
+ });
}
}
@@ -164,23 +290,298 @@ private bool CanSpawnNow()
return false;
}
- private Task SpawnStubAsync(PointState ps, CancellationToken ct)
+ private async Task SpawnLightAsync(string pointId, LightLayout layout, CancellationToken ct)
+ {
+ var connector = _connector as MsfsSimulatorConnector;
+ if (connector == null) return null;
+ _logger.LogTrace("[SpawnLightAsyncCall] point={id} stateId={sid} lat={lat:F6} lon={lon:F6} hdg={hdg:F1}", pointId, layout.StateId, layout.Latitude, layout.Longitude, layout.Heading ?? 0);
+ var simObj = await connector.SpawnLightAsync(pointId, layout.Latitude, layout.Longitude, layout.Heading, layout.StateId, ct);
+ if (simObj != null)
+ {
+ _logger.LogTrace("Spawned SimObject {obj} for {id}", simObj.ObjectId, pointId);
+ }
+ return simObj;
+ }
+
+ private Task DespawnLightAsync(SimObject simObject, CancellationToken ct)
{
- // Placeholder: integrate SimConnect AICreateSimulatedObject with custom SimObject title.
- _logger.LogTrace("Spawn stub for {id} at {lat}/{lon} state={state}", ps.Metadata.Id, ps.Metadata.Latitude, ps.Metadata.Longitude, ps.IsOn);
- return Task.FromResult(new { id = ps.Metadata.Id });
+ var connector = _connector as MsfsSimulatorConnector;
+ if (connector == null) return Task.CompletedTask;
+ return connector.DespawnLightAsync(simObject, ct);
}
- private Task DespawnStubAsync(object handle, CancellationToken ct)
+
+ private int TotalActiveLightCount()
{
- // Placeholder: integrate SimConnect AIDeleteObject.
- _logger.LogTrace("Despawn stub for {handle}", handle);
- return Task.CompletedTask;
+ lock (_spawnLock) return _spawned.Values.Sum(g => g.Objects.Count);
+ }
+
+ private sealed record LightLayout(double Latitude, double Longitude, double? Heading, string? Color, int? StateId);
+
+ private sealed record SpawnGroup(IReadOnlyList Objects, IReadOnlyList Layouts, bool IsOn);
+
+ private IReadOnlyList GetOrBuildLayouts(PointState ps, IReadOnlyList? existingLayouts)
+ {
+ if (existingLayouts != null && existingLayouts.Count > 0) return existingLayouts;
+ IReadOnlyList rawLights;
+ if (!_hub.TryGetLightLayout(ps.Metadata.Id, out var hubLights) || hubLights.Count == 0)
+ {
+ rawLights = new List { new AirportStateHub.LightLayout(ps.Metadata.Latitude, ps.Metadata.Longitude, null, ps.Metadata.Color, null) };
+ }
+ else rawLights = hubLights;
+ return rawLights.Select(l => new LightLayout(l.Latitude, l.Longitude, l.Heading, l.Color, l.StateId)).ToList();
+ }
+
+ // Full (fresh) spawn, used only for new points or layout reduction / state change requiring full replace
+ private async Task SpawnGroupFreshAsync(string pointId, IReadOnlyList layouts, bool useLayoutStateIds, CancellationToken ct, bool ignoreCapForTransition = false, bool isOn = true, int? forcedStateId = null)
+ {
+ var spawnedHandles = new List();
+ foreach (var layout in layouts)
+ {
+ if (!ignoreCapForTransition && TotalActiveLightCount() >= _maxObjects)
+ {
+ Interlocked.Increment(ref _totalSkippedCap);
+ _logger.LogWarning("[SpawnSkip:Cap] {id} cap={cap} active={active}", pointId, _maxObjects, TotalActiveLightCount());
+ break;
+ }
+ if (!CanSpawnNow())
+ {
+ Interlocked.Increment(ref _totalDeferredRate);
+ _logger.LogDebug("[SpawnDefer:Rate] {id} remainingLayoutsWillRetry", pointId);
+ // requeue a synthetic point state using latest cached state so we finish group later
+ if (_latestStates.TryGetValue(pointId, out var latest)) _queue.Enqueue(latest);
+ break;
+ }
+ int? stateId;
+ if (forcedStateId.HasValue)
+ stateId = forcedStateId.Value;
+ else if (useLayoutStateIds)
+ stateId = layout.StateId;
+ else
+ stateId = null; // base model (off)
+ SimObject? handle = null;
+ try
+ {
+ handle = await SpawnLightAsync(pointId, new LightLayout(layout.Latitude, layout.Longitude, layout.Heading, layout.Color, stateId), ct);
+ if (handle != null)
+ {
+ spawnedHandles.Add(handle);
+ // reset failure streak on success
+ _spawnFailures.TryRemove(pointId, out _);
+ }
+ else
+ {
+ // treat null as a failure (e.g., connector not MSFS)
+ RegisterSpawnFailure(pointId);
+ }
+ }
+ catch (Exception ex)
+ {
+ RegisterSpawnFailure(pointId);
+ _logger.LogDebug(ex, "[SpawnError] {id} stateId={sid}", pointId, stateId ?? -1);
+ // After a failure, break to avoid rapid-fire attempts this tick
+ break;
+ }
+ }
+ if (spawnedHandles.Count > 0)
+ {
+ lock (_spawnLock)
+ {
+ _spawned[pointId] = new SpawnGroup(spawnedHandles, layouts, isOn);
+ Interlocked.Add(ref _totalSpawned, spawnedHandles.Count);
+ _logger.LogInformation("[SpawnGroup] {id} lights={lights} activePoints={points} totalLights={total} overlapCapIgnore={ignoreCap} isOn={on}", pointId, spawnedHandles.Count, _spawned.Count, TotalActiveLightCount(), ignoreCapForTransition, isOn);
+ }
+ }
+ }
+
+ // Incrementally spawn only missing layouts (no overlap) for partially-complete points
+ private async Task SpawnMissingAsync(string pointId, SpawnGroup existing, IReadOnlyList layouts, CancellationToken ct, bool isOn, bool useLayoutStateIds, int? forcedStateId)
+ {
+ int already = existing.Objects.Count;
+ if (already >= layouts.Count) return;
+ var newHandles = new List();
+ for (int i = already; i < layouts.Count; i++)
+ {
+ var layout = layouts[i];
+ int? stateId;
+ if (forcedStateId.HasValue) stateId = forcedStateId.Value;
+ else if (useLayoutStateIds) stateId = layout.StateId;
+ else stateId = 0; // off placeholder
+ if (TotalActiveLightCount() >= _maxObjects)
+ {
+ Interlocked.Increment(ref _totalSkippedCap);
+ _logger.LogWarning("[SpawnSkip:Cap] {id} cap={cap} active={active} partialProgress={done}/{total}", pointId, _maxObjects, TotalActiveLightCount(), i, layouts.Count);
+ break;
+ }
+ if (!CanSpawnNow())
+ {
+ Interlocked.Increment(ref _totalDeferredRate);
+ _logger.LogDebug("[SpawnDefer:Rate] {id} partialProgress={done}/{total}", pointId, i, layouts.Count);
+ if (_latestStates.TryGetValue(pointId, out var latest)) _queue.Enqueue(latest);
+ break;
+ }
+ try
+ {
+ var handle = await SpawnLightAsync(pointId, new LightLayout(layout.Latitude, layout.Longitude, layout.Heading, layout.Color, stateId), ct);
+ if (handle != null)
+ {
+ newHandles.Add(handle);
+ _spawnFailures.TryRemove(pointId, out _); // reset on any success
+ }
+ else RegisterSpawnFailure(pointId);
+ }
+ catch (Exception ex)
+ {
+ RegisterSpawnFailure(pointId);
+ _logger.LogDebug(ex, "[SpawnError:Incremental] {id} index={idx}", pointId, i);
+ break; // stop this cycle on first exception
+ }
+ }
+ if (newHandles.Count > 0)
+ {
+ lock (_spawnLock)
+ {
+ // Merge with existing handles
+ var merged = existing.Objects.Concat(newHandles).ToList();
+ _spawned[pointId] = new SpawnGroup(merged, layouts, isOn);
+ Interlocked.Add(ref _totalSpawned, newHandles.Count);
+ _logger.LogInformation("[SpawnGroup:Incremental] {id} added={added} now={now}/{total} activePoints={points} totalLights={lights} isOn={on}", pointId, newHandles.Count, merged.Count, layouts.Count, _spawned.Count, TotalActiveLightCount(), isOn);
+ }
+ }
+ }
+
+ private void RegisterSpawnFailure(string pointId)
+ {
+ var now = DateTime.UtcNow;
+ var updated = _spawnFailures.AddOrUpdate(pointId,
+ _ => (1, now),
+ (_, prev) => (prev.Failures + 1, now));
+
+ // Dynamic backoff: base 500ms * failures^2 (capped) and extra fixed cooldown after threshold
+ var backoffMs = Math.Min(500 * updated.Failures * updated.Failures, 8000); // cap 8s
+ if (updated.Failures >= FailureThresholdForCooldown)
+ {
+ // ensure at least failureCooldown (e.g. 10s) after threshold reached
+ backoffMs = Math.Max(backoffMs, (int)_failureCooldown.TotalMilliseconds);
+ }
+ 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);
+ }
+
+ private async Task DelayedDespawnAsync(string pointId, List handles, int delayMs, CancellationToken ct)
+ {
+ try
+ {
+ await Task.Delay(delayMs, ct).ConfigureAwait(false);
+ _logger.LogTrace("[OverlapDespawn] {id} delayMs={delay} count={count}", pointId, delayMs, handles.Count);
+ foreach (var h in handles)
+ {
+ try
+ {
+ await DespawnLightAsync(h, ct).ConfigureAwait(false);
+ Interlocked.Increment(ref _totalDespawned);
+ }
+ catch { }
+ }
+ _logger.LogDebug("[OverlapDespawnDone] {id} removed={removed}", pointId, handles.Count);
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "[OverlapDespawnError] {id}", pointId);
+ }
+ }
+
+ private async Task DespawnGroupAsync(string pointId, SpawnGroup group, CancellationToken ct)
+ {
+ List handles;
+ lock (_spawnLock)
+ {
+ if (!_spawned.Remove(pointId, out var existing)) return;
+ handles = existing.Objects.ToList();
+ }
+ _logger.LogDebug("[DespawnGroupStart] {id} handles={count}", pointId, handles.Count);
+ foreach (var h in handles)
+ {
+ await DespawnLightAsync(h, ct);
+ Interlocked.Increment(ref _totalDespawned);
+ }
+ _logger.LogInformation("[DespawnGroup] {id} removedLights={removed} remainingPoints={points} totalLights={total}", pointId, handles.Count, _spawned.Count, TotalActiveLightCount());
+ }
+
+ 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;
+ bool alreadyFull = false;
+ lock (_spawnLock)
+ {
+ if (_spawned.TryGetValue(ps.Metadata.Id, out var existing) && existing.Objects.Count >= layout.Count) alreadyFull = true;
+ }
+ if (alreadyFull) continue; // already full
+ // Queue for reprocessing to spawn full light group
+ _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)
+ {
+ List all;
+ int pointCount;
+ lock (_spawnLock)
+ {
+ all = _spawned.Values.SelectMany(v => v.Objects).ToList();
+ pointCount = _spawned.Count;
+ _spawned.Clear();
+ }
+ if (all.Count == 0)
+ {
+ _logger.LogInformation("[DespawnAll] No active lights to remove");
+ return;
+ }
+ _logger.LogInformation("[DespawnAllStart] points={points} lights={lights}", pointCount, all.Count);
+ foreach (var obj in all)
+ {
+ try
+ {
+ await DespawnLightAsync(obj, ct);
+ Interlocked.Increment(ref _totalDespawned);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "DespawnAllAsync failed for {obj}", obj.ObjectId);
+ }
+ }
+ _logger.LogInformation("[DespawnAll] removedLights={removed} remainingPoints=0 totalLights=0", all.Count);
}
}
internal sealed class MsfsPointControllerOptions
{
public int MaxObjects { get; init; } = 1000;
- public int SpawnPerSecond { get; init; } = 15;
+ public int SpawnPerSecond { get; init; } = 20;
+ public int IdleDelayMs { get; init; } = 10;
+ public int DisconnectedDelayMs { get; init; } = 500;
+ public int ErrorBackoffMs { get; init; } = 200;
+ public int OverlapDespawnDelayMs { get; init; } = 1000;
}
diff --git a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
index a5fb2f4..544b5d3 100644
--- a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
@@ -1,27 +1,31 @@
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;
-///
-/// MSFS Connection implementation using SimConnect.NET.
-///
public sealed class MsfsSimulatorConnector : ISimulatorConnector, IDisposable
{
private readonly ILogger _logger;
private SimConnectClient? _client;
private const int PollDelayMs = 15_000;
+ 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();
- public MsfsSimulatorConnector(ILogger logger)
- {
- _logger = logger;
- }
+ public MsfsSimulatorConnector(ILogger logger) => _logger = logger;
public string SimulatorId => "MSFS";
public string DisplayName => "Microsoft Flight Simulator";
@@ -30,25 +34,52 @@ public MsfsSimulatorConnector(ILogger logger)
public async Task ConnectAsync(CancellationToken ct = default)
{
if (IsConnected) return true;
+
+ await _connectGate.WaitAsync(ct);
try
{
- var client = new SimConnectClient("BARS Client");
- await client.ConnectAsync();
- if (client.IsConnected)
- {
- _client = client;
- _logger.LogInformation("Connected to MSFS via SimConnect.NET");
- }
- else
+ if (IsConnected) return true;
+ int attempt = 0;
+ while (!ct.IsCancellationRequested && !IsConnected)
{
- client.Dispose();
- _logger.LogWarning("Failed to connect to MSFS (client not connected after ConnectAsync)");
+ 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; }
+ }
}
}
- catch (OperationCanceledException) { throw; }
- catch (Exception ex)
+ finally
{
- _logger.LogWarning(ex, "MSFS connection attempt failed");
+ _connectGate.Release();
}
return IsConnected;
}
@@ -66,9 +97,14 @@ public Task DisconnectAsync(CancellationToken ct = default)
public async IAsyncEnumerable StreamRawAsync([EnumeratorCancellation] CancellationToken ct = default)
{
- if (!IsConnected) yield break;
- while (!ct.IsCancellationRequested && IsConnected)
+ while (!ct.IsCancellationRequested)
{
+ if (!IsConnected)
+ {
+ try { await Task.Delay(1000, ct); } catch { yield break; }
+ continue;
+ }
+
var sample = await TryGetSampleAsync(ct);
if (sample is RawFlightSample s) yield return s;
try { await Task.Delay(PollDelayMs, ct); } catch { yield break; }
@@ -97,4 +133,129 @@ public async IAsyncEnumerable StreamRawAsync([EnumeratorCancell
}
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);
+ 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;
+ 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);
+ var candidate = mgr.ManagedObjects.Values
+ .Where(o => o.IsActive)
+ .OrderByDescending(o => o.ObjectId)
+ .FirstOrDefault();
+ if (candidate != null)
+ {
+ _lateAttachedPoints[pointId] = true;
+ _logger.LogInformation("[Connector.LateAttach] point={pointId} objectId={obj}", pointId, candidate.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
+ _lateAttachedPoints.TryRemove(pointId, out _);
+ }
+ }
+
+ 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 e159564..b86d286 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -553,62 +553,63 @@
+ Grid.Row="0"
+ Orientation="Horizontal"
+ Margin="0,0,0,4">
+ FontWeight="SemiBold"
+ Margin="0,0,5,0"/>
+ Height="10"
+ Margin="0,0,5,0"
+ VerticalAlignment="Center">
+ Foreground="{Binding SimulatorStatusColor}"/>
+ Grid.Row="1"
+ Orientation="Horizontal">
+ FontWeight="SemiBold"
+ Margin="0,0,5,0"/>
+ FontWeight="Bold"/>
+
-
-
-
-
+ Grid.Row="0"
+ Orientation="Horizontal"
+ HorizontalAlignment="Right"
+ Margin="0,0,0,4">
+ FontWeight="SemiBold"
+ Margin="0,0,5,0"/>
+ Height="10"
+ Margin="0,0,5,0"
+ VerticalAlignment="Center">
+ Foreground="{Binding ServerStatusColor}"/>
+
+
+
+
diff --git a/Services/NearestAirportService.cs b/Services/NearestAirportService.cs
index 64fe78f..58ff715 100644
--- a/Services/NearestAirportService.cs
+++ b/Services/NearestAirportService.cs
@@ -21,7 +21,7 @@ internal sealed class NearestAirportService : INearestAirportService
private string? _lastIcao;
private DateTime _lastFetchUtc = DateTime.MinValue;
- private const double MinDistanceNmForRefresh = 2.0; // refresh if moved more than 2nm
+ private const double MinDistanceNmForRefresh = 2.0;
private static readonly TimeSpan MaxAge = TimeSpan.FromSeconds(45);
public NearestAirportService(HttpClient httpClient)
From 7b1a4350072bf12382cd81162c2a87ba7ac1a666 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Mon, 11 Aug 2025 21:40:38 +0800
Subject: [PATCH 03/20] Enhance MSFS Simulator Connector and Scenery Service
- Added tracking for successfully created object IDs in MsfsSimulatorConnector to improve late attach diagnostics.
- Refined late attach logic to skip unnecessary attempts if the object was already spawned normally.
- Removed position-based heuristic for late attachment, relying solely on user data matching.
- Updated ContributionsResponse to initialize contributions list to avoid nullability warnings.
- Improved error handling and logging in SceneryService when fetching available packages from the API.
- Cleaned up formatting and ensured consistent code style across both files.
---
App.xaml.cs | 24 +-
.../InMemory/InMemoryAirportRepository.cs | 48 --
Infrastructure/Networking/AirportStateHub.cs | 117 ++-
.../Networking/AirportWebSocketManager.cs | 17 +
.../Networking/HttpAirportRepository.cs | 87 +++
.../Simulators/MSFS/MsfsPointController.cs | 710 +++++++++---------
.../Simulators/MSFS/MsfsSimulatorConnector.cs | 42 +-
Services/SceneryService.cs | 52 +-
8 files changed, 650 insertions(+), 447 deletions(-)
delete mode 100644 Infrastructure/InMemory/InMemoryAirportRepository.cs
create mode 100644 Infrastructure/Networking/HttpAirportRepository.cs
diff --git a/App.xaml.cs b/App.xaml.cs
index 548c174..624ffb7 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -18,11 +18,18 @@ protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host = Host.CreateDefaultBuilder()
- .ConfigureLogging(lb => lb.AddConsole())
+ .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.AddSingleton();
services.AddHostedService(sp => sp.GetRequiredService()); // background stream
@@ -31,7 +38,14 @@ protected override void OnStartup(StartupEventArgs e)
services.AddSingleton();
services.AddHostedService(sp => sp.GetRequiredService());
services.AddSingleton();
- 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();
@@ -45,11 +59,13 @@ protected override void OnStartup(StartupEventArgs e)
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.DespawnAllAsync(); };
+ wsMgr.Disconnected += reason => { pointController.Suspend(); _ = pointController.DespawnAllAsync(); };
+ wsMgr.Connected += () => pointController.Resume();
mainWindow.Show();
}
diff --git a/Infrastructure/InMemory/InMemoryAirportRepository.cs b/Infrastructure/InMemory/InMemoryAirportRepository.cs
deleted file mode 100644
index 8e9da30..0000000
--- a/Infrastructure/InMemory/InMemoryAirportRepository.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BARS_Client_V2.Application;
-using BARS_Client_V2.Domain;
-
-namespace BARS_Client_V2.Infrastructure.InMemory;
-
-internal sealed class InMemoryAirportRepository : IAirportRepository
-{
- private readonly List _airports;
-
- public InMemoryAirportRepository()
- {
- _airports = new List
- {
- new("KJFK", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("Drzewiecki Design KJFK"), }),
- new("EGLL", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("IniBuilds EGLL"), }),
- new("KLAX", new []{ new SceneryPackage("Asobo Default"), new SceneryPackage("IniBuilds KLAX"), }),
- new("KSEA", new []{ new SceneryPackage("Asobo Default") }),
- new("LFPG", new []{ new SceneryPackage("Asobo Default") }),
- new("EDDF", new []{ new SceneryPackage("Aerosoft EDDF") }),
- new("EDDM", new []{ new SceneryPackage("SimWings EDDM") }),
- new("CYYZ", new []{ new SceneryPackage("FSimStudios CYYZ") }),
- new("YSSY", new []{ new SceneryPackage("FlyTampa YSSY") }),
- new("KSFO", new []{ new SceneryPackage("FlightBeam KSFO"), new SceneryPackage("Asobo Default") }),
- };
- }
-
- public Task<(IReadOnlyList Items, int TotalCount)> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default)
- {
- IEnumerable q = _airports;
- if (!string.IsNullOrWhiteSpace(search))
- {
- search = search.Trim().ToUpperInvariant();
- q = q.Where(a => a.ICAO.Contains(search, StringComparison.OrdinalIgnoreCase) || a.SceneryPackages.Any(p => p.Name.Contains(search, StringComparison.OrdinalIgnoreCase)));
- }
- var total = q.Count();
- var items = q
- .OrderBy(a => a.ICAO)
- .Skip((page - 1) * pageSize)
- .Take(pageSize)
- .ToList();
- return Task.FromResult(((IReadOnlyList)items, total));
- }
-}
diff --git a/Infrastructure/Networking/AirportStateHub.cs b/Infrastructure/Networking/AirportStateHub.cs
index 39cfd40..b586951 100644
--- a/Infrastructure/Networking/AirportStateHub.cs
+++ b/Infrastructure/Networking/AirportStateHub.cs
@@ -21,15 +21,24 @@ internal sealed class AirportStateHub
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));
}
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)
@@ -53,6 +62,9 @@ public async Task ProcessAsync(string json, CancellationToken ct = default)
case "INITIAL_STATE":
await HandleInitialStateAsync(root, ct);
break;
+ case "STATE_SNAPSHOT":
+ await HandleSnapshotAsync(root, ct);
+ break;
case "STATE_UPDATE":
HandleStateUpdate(root);
break;
@@ -69,7 +81,7 @@ public async Task ProcessAsync(string json, CancellationToken ct = default)
}
}
- private async Task HandleInitialStateAsync(JsonElement root, CancellationToken ct)
+ private async Task HandleSnapshotAsync(JsonElement root, CancellationToken ct)
{
if (!root.TryGetProperty("airport", out var aProp) || aProp.ValueKind != JsonValueKind.String) return;
var airport = aProp.GetString();
@@ -77,26 +89,79 @@ private async Task HandleInitialStateAsync(JsonElement root, CancellationToken c
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 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))
{
- // Create placeholder if not in map (should be rare)
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 { }
}
- _logger.LogInformation("INITIAL_STATE processed {count} points for {apt}", count, airport);
+ _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)
@@ -108,11 +173,13 @@ private void HandleStateUpdate(JsonElement root)
var ts = root.TryGetProperty("timestamp", out var tsp) && tsp.TryGetInt64(out var lts) ? lts : 0L;
if (!_metadata.TryGetValue(id!, out var meta))
{
- meta = new PointMetadata(id!, _mapAirport ?? string.Empty, "", id!, 0, 0, null, null, null, false, false);
- _metadata[id!] = 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 { }
}
@@ -203,4 +270,42 @@ private bool TryParseLatLon(string? csv, out double lat, out double lon)
}
public sealed record LightLayout(double Latitude, double Longitude, double? Heading, string? Color, int? StateId);
+
+ 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
index 868d72d..31e4c3c 100644
--- a/Infrastructure/Networking/AirportWebSocketManager.cs
+++ b/Infrastructure/Networking/AirportWebSocketManager.cs
@@ -47,6 +47,14 @@ public AirportWebSocketManager(
_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)
@@ -246,6 +254,15 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
}
}
+ 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;
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/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
index 9b9d963..7800755 100644
--- a/Infrastructure/Simulators/MSFS/MsfsPointController.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -2,15 +2,13 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
-using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
-// XML parsing handled centrally in AirportStateHub
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 Microsoft.Extensions.Http;
using SimConnect.NET.AI;
namespace BARS_Client_V2.Infrastructure.Simulators.Msfs;
@@ -22,79 +20,106 @@ namespace BARS_Client_V2.Infrastructure.Simulators.Msfs;
internal sealed class MsfsPointController : BackgroundService, IPointStateListener
{
private readonly ILogger _logger;
- private readonly ISimulatorConnector _connector; // rely on same DI instance (assumed MSFS)
+ private readonly ISimulatorConnector _connector; // assumed MSFS
+ private readonly AirportStateHub _hub;
+ private readonly SimulatorManager _simManager;
private readonly ConcurrentQueue _queue = new();
- // pointId -> spawned group (stores sim objects and their layout positions)
- private readonly Dictionary _spawned = new();
- // Synchronize access to _spawned to allow external mass-despawn while background loop runs
- private readonly object _spawnLock = new();
-
- // Track latest states and reference to central hub for layouts
private readonly ConcurrentDictionary _latestStates = new();
- private readonly AirportStateHub _hub;
+ private readonly ConcurrentDictionary> _layoutCache = new();
+ private readonly System.Threading.SemaphoreSlim _spawnConcurrency = new(4, 4);
+ // 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 DateTime _nextSpawnWindow = DateTime.UtcNow;
- private int _spawnedThisWindow;
private readonly int _idleDelayMs;
private readonly int _disconnectedDelayMs;
private readonly int _errorBackoffMs;
- private readonly int _overlapDespawnDelayMs;
+ private readonly double _spawnRadiusMeters;
+ private readonly TimeSpan _proximitySweepInterval;
+ private DateTime _nextProximitySweepUtc = DateTime.UtcNow;
+
+ // Rate tracking
+ private DateTime _nextSpawnWindow = DateTime.UtcNow;
+ private int _spawnedThisWindow;
// Stats
private long _totalReceived;
private long _totalSpawnAttempts;
- private long _totalSpawned;
private long _totalDespawned;
private long _totalDeferredRate;
private long _totalSkippedCap;
private DateTime _lastSummary = DateTime.UtcNow;
- // Track repeated spawn failures per point to apply a cooldown and avoid flooding SimConnect
+ private volatile bool _suspended;
+
+ // Failure/backoff
private readonly ConcurrentDictionary _spawnFailures = new();
- private readonly TimeSpan _failureCooldown = TimeSpan.FromSeconds(10); // per point pause after repeated failures
- private const int FailureThresholdForCooldown = 3; // consecutive failures before we start cooldown
- private readonly ConcurrentDictionary _nextAttemptUtc = new(); // per-point dynamic backoff
- // For overlap transitions store old handles until replacement group fully spawned
- private readonly ConcurrentDictionary> _pendingOverlapOld = 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,
- MsfsPointControllerOptions? options = null)
+ ILogger logger,
+ AirportStateHub hub,
+ SimulatorManager simManager,
+ MsfsPointControllerOptions? options = null)
{
- // Select the MSFS connector (first one with SimulatorId == MSFS or just first)
- _connector = connectors.FirstOrDefault(c => string.Equals(c.SimulatorId, "MSFS", StringComparison.OrdinalIgnoreCase))
- ?? connectors.First();
+ _connector = connectors.FirstOrDefault(c => c.SimulatorId.Equals("MSFS", StringComparison.OrdinalIgnoreCase)) ?? connectors.First();
_logger = logger;
options ??= new MsfsPointControllerOptions();
- _maxObjects = options.MaxObjects;
- _spawnPerSecond = options.SpawnPerSecond;
_hub = hub;
+ _simManager = simManager;
_hub.PointStateChanged += OnPointStateChanged;
_hub.MapLoaded += _ => ResyncActivePointsAfterLayout();
-
+ _maxObjects = options.MaxObjects;
+ _spawnPerSecond = options.SpawnPerSecond;
_idleDelayMs = options.IdleDelayMs;
_disconnectedDelayMs = options.DisconnectedDelayMs;
_errorBackoffMs = options.ErrorBackoffMs;
- _overlapDespawnDelayMs = options.OverlapDespawnDelayMs;
+ _spawnRadiusMeters = options.SpawnRadiusMeters;
+ _proximitySweepInterval = TimeSpan.FromSeconds(options.ProximitySweepSeconds);
}
public void OnPointStateChanged(PointState state)
{
_latestStates[state.Metadata.Id] = state;
+ if (_suspended) return; // cache only
_queue.Enqueue(state);
- var qLen = _queue.Count;
- var meta = state.Metadata;
Interlocked.Increment(ref _totalReceived);
- _logger.LogInformation("[Recv] {id} on={on} type={type} name={name} airport={apt} lat={lat:F6} lon={lon:F6} queue={q}",
- meta.Id, state.IsOn, meta.Type, meta.Name, meta.AirportId, meta.Latitude, meta.Longitude, qLen);
+ var m = state.Metadata;
+ _logger.LogInformation("[Recv] {id} on={on} type={type} airport={apt} lat={lat:F6} lon={lon:F6} q={q}",
+ m.Id, state.IsOn, m.Type, m.AirportId, m.Latitude, m.Longitude, _queue.Count);
+ }
+
+ ///
+ /// 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 (max={max} rate/s={rate})", _maxObjects, _spawnPerSecond);
+ _logger.LogInformation("MsfsPointController started (manager-driven mode) max={max} rate/s={rate}", _maxObjects, _spawnPerSecond);
while (!stoppingToken.IsCancellationRequested)
{
try
@@ -105,31 +130,37 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
await Task.Delay(_disconnectedDelayMs, stoppingToken);
continue;
}
+ if (_suspended)
+ {
+ await Task.Delay(_idleDelayMs * 5, stoppingToken);
+ continue;
+ }
if (_queue.TryDequeue(out var ps))
{
- _logger.LogTrace("Dequeued {id} on={on}", ps.Metadata.Id, ps.IsOn);
await ProcessAsync(ps, stoppingToken);
}
else
{
- // Occasionally log idle state
if (DateTime.UtcNow.Second % 20 == 0)
- _logger.LogTrace("[Idle] queue empty activePoints={points} totalLights={lights}", _spawned.Count, TotalActiveLightCount());
- await Task.Delay(_idleDelayMs, stoppingToken); // reduced idle backoff
- }
- // Periodic summary every 30s
+ 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} spawned={sp} active={active} despawned={des} deferredRate={def} skippedCap={cap} queue={q}",
- _totalReceived, _totalSpawned, _spawned.Count, _totalDespawned, _totalDeferredRate, _totalSkippedCap, _queue.Count);
+ _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, "Error in MsfsPointController loop");
+ _logger.LogDebug(ex, "Loop error");
try { await Task.Delay(_errorBackoffMs, stoppingToken); } catch { }
}
}
@@ -137,318 +168,198 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
private async Task ProcessAsync(PointState ps, CancellationToken ct)
{
+ if (_suspended) return;
var id = ps.Metadata.Id;
- bool exists; SpawnGroup? existingGroup;
- lock (_spawnLock) exists = _spawned.TryGetValue(id, out existingGroup);
-
- var layouts = GetOrBuildLayouts(ps, existingGroup?.Layouts);
+ var layouts = GetOrBuildLayouts(ps);
if (layouts.Count == 0) return;
- bool toOn = ps.IsOn;
- bool isToggle = exists && existingGroup != null && existingGroup.IsOn != toOn;
-
- if (!toOn)
+ var flight = _simManager.LatestState;
+ if (flight != null)
{
- // OFF transition behavior:
- // 1. If currently ON: spawn a full set of OFF placeholders (stateId=0) overlapping existing ON lights, then delayed-despawn old lights.
- // 2. If already OFF but incomplete: incrementally spawn missing OFF placeholders.
- // 3. If already OFF and full: skip.
- if (exists && existingGroup != null && !existingGroup.IsOn)
+ var dist = DistanceMeters(flight.Latitude, flight.Longitude, ps.Metadata.Latitude, ps.Metadata.Longitude);
+ if (ps.IsOn && dist > _spawnRadiusMeters)
{
- // Already off placeholders present
- if (existingGroup.Objects.Count >= layouts.Count)
- {
- _logger.LogTrace("[ProcessSkip:OffFull] {id} lights={lights}", id, existingGroup.Objects.Count);
- return;
- }
- // Need to top up missing placeholders
- await SpawnMissingAsync(id, existingGroup, layouts, ct, isOn: false, useLayoutStateIds: false, forcedStateId: 0);
- TryCompleteOverlap(id);
+ await DespawnPointAsync(id, CancellationToken.None);
+ _logger.LogTrace("[ProcessSkip:OutOfRadius] {id} dist={dist:F0}m radius={radius}", id, dist, _spawnRadiusMeters);
return;
}
+ }
+ 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
+ }
- // Was ON -> overlap spawn OFF placeholders (stateId=0) but keep old ON lights until full set of OFF spawned
- if (exists && existingGroup != null && existingGroup.IsOn)
+ if (!ps.IsOn)
+ {
+ // OFF: Build full placeholder set, then remove ALL variants.
+ if (placeholders.Count < layouts.Count)
+ {
+ int need = layouts.Count - placeholders.Count;
+ await SpawnBatchAsync(id, layouts, need, isPlaceholder: true, ct);
+ if (_latestStates.TryGetValue(id, out var latestOff)) _queue.Enqueue(latestOff); // re-evaluate later
+ _logger.LogTrace("[OverlapPending] {id} placeholders={ph}/{need}", id, placeholders.Count, layouts.Count);
+ return;
+ }
+ if (variants.Count > 0)
{
- // store old handles if first time in this overlap
- _pendingOverlapOld.AddOrUpdate(id,
- _ => existingGroup.Objects.ToList(),
- (_, prev) => prev); // keep original list
+ await RemoveObjectsAsync(variants, id, ct, "[OverlapRemove:Variants]");
+ _logger.LogTrace("[OverlapRemovedVariants] {id}", id);
}
- await SpawnGroupFreshAsync(id, layouts, useLayoutStateIds: false, ct, ignoreCapForTransition: true, isOn: false, forcedStateId: 0);
- TryCompleteOverlap(id);
return;
}
- // ON flow
- if (!isToggle && exists && existingGroup != null && existingGroup.IsOn && existingGroup.Objects.Count >= layouts.Count)
+ // ON path: Build variants first then remove placeholders.
+ if (variants.Count < layouts.Count)
{
- _logger.LogTrace("[ProcessSkip:OnFull] {id} lights={lights}", id, existingGroup.Objects.Count);
+ int need = layouts.Count - variants.Count;
+ await SpawnBatchAsync(id, layouts, need, isPlaceholder: false, ct);
+ if (_latestStates.TryGetValue(id, out var latestOn)) _queue.Enqueue(latestOn);
+ _logger.LogTrace("[OverlapPendingVariants] {id} variants={var}/{need}", id, variants.Count, layouts.Count);
return;
}
-
- // Backoff (ON only)
- if (_nextAttemptUtc.TryGetValue(id, out var next) && DateTime.UtcNow < next)
+ if (placeholders.Count > 0)
{
- var remaining = (next - DateTime.UtcNow).TotalMilliseconds;
- if (remaining > 0 && remaining < 1500)
- _logger.LogTrace("[ProcessDefer:Backoff] {id} msLeft={ms:F0}", id, remaining);
- if (remaining < _idleDelayMs * 4 && _latestStates.TryGetValue(id, out var latestBack))
- _queue.Enqueue(latestBack);
- return;
+ await RemoveObjectsAsync(placeholders, id, ct, "[OverlapRemove:Placeholders]");
+ _logger.LogTrace("[OverlapRemovedPlaceholders] {id}", id);
}
+ }
- // Failure cooldown (ON only)
- if (_spawnFailures.TryGetValue(id, out var failureInfo))
+ 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++)
{
- var since = DateTime.UtcNow - failureInfo.LastFailureUtc;
- if (failureInfo.Failures >= FailureThresholdForCooldown && since < _failureCooldown)
+ if (TotalActiveLightCount() >= _maxObjects)
{
- if (since.TotalSeconds < 1)
- _logger.LogDebug("[ProcessDefer:FailureCooldown] {id} remainingMs={ms:F0}", id, (_failureCooldown - since).TotalMilliseconds);
- if ((_failureCooldown - since) < TimeSpan.FromMilliseconds(_idleDelayMs * 5) && _latestStates.TryGetValue(id, out var latestCool))
- _queue.Enqueue(latestCool);
- return;
+ Interlocked.Increment(ref _totalSkippedCap);
+ if (_latestStates.TryGetValue(pointId, out var latestCap)) _queue.Enqueue(latestCap);
+ break;
}
- }
-
- if (exists && existingGroup != null)
- {
- // If coming from OFF -> ON toggle initiate overlap (store old off placeholders)
- if (isToggle && !existingGroup.IsOn)
+ if (!CanSpawnNow())
{
- _pendingOverlapOld.AddOrUpdate(id,
- _ => existingGroup.Objects.ToList(),
- (_, prev) => prev);
+ Interlocked.Increment(ref _totalDeferredRate);
+ if (_latestStates.TryGetValue(pointId, out var latestRate)) _queue.Enqueue(latestRate);
+ break;
}
- if (existingGroup.IsOn && existingGroup.Objects.Count < layouts.Count)
+ var layout = layouts[i];
+ int? variantState = layout.StateId;
+ if (!isPlaceholder)
{
- await SpawnMissingAsync(id, existingGroup, layouts, ct, isOn: true, useLayoutStateIds: true, forcedStateId: null);
- TryCompleteOverlap(id);
- return;
+ // 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;
}
}
-
- // Fresh (new point or transitioning from OFF with no existing ON objects recorded yet)
- await SpawnGroupFreshAsync(id, layouts, useLayoutStateIds: true, ct, ignoreCapForTransition: isToggle, isOn: true, forcedStateId: null);
- TryCompleteOverlap(id);
}
- private void TryCompleteOverlap(string pointId)
+ private async Task RemoveObjectsAsync(List objects, string pointId, CancellationToken ct, string contextTag)
{
- if (!_pendingOverlapOld.TryGetValue(pointId, out var oldHandles)) return;
- SpawnGroup? current;
- lock (_spawnLock)
- {
- if (!_spawned.TryGetValue(pointId, out current)) return;
- }
- if (current == null || current.Objects.Count < current.Layouts.Count) return; // not yet full
- // Full new group spawned -> remove old
- if (_pendingOverlapOld.TryRemove(pointId, out oldHandles))
+ foreach (var obj in objects)
{
- _ = Task.Run(async () =>
- {
- // Validate that the current cached desired state matches the replacement group's IsOn to avoid race conditions.
- if (_latestStates.TryGetValue(pointId, out var desired))
- {
- if (desired.IsOn != current.IsOn)
- {
- _logger.LogTrace("[OverlapAbort:StateChanged] {id} desiredOn={des} currentOn={cur}", pointId, desired.IsOn, current.IsOn);
- // Re-store old handles (in case another transition will clean them up) and exit.
- _pendingOverlapOld[pointId] = oldHandles;
- return;
- }
- }
- int removed = 0;
- foreach (var h in oldHandles)
- {
- try
- {
- await DespawnLightAsync(h, CancellationToken.None);
- Interlocked.Increment(ref _totalDespawned);
- removed++;
- }
- catch { }
- }
- _logger.LogDebug("[OverlapComplete] {id} oldRemoved={removed}", pointId, removed);
- });
+ 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 bool CanSpawnNow()
{
var now = DateTime.UtcNow;
- if (now > _nextSpawnWindow)
- {
- _nextSpawnWindow = now.AddSeconds(1);
- _spawnedThisWindow = 0;
- }
- if (_spawnedThisWindow < _spawnPerSecond)
- {
- _spawnedThisWindow++;
- return true;
- }
+ if (now > _nextSpawnWindow) { _nextSpawnWindow = now.AddSeconds(1); _spawnedThisWindow = 0; }
+ if (_spawnedThisWindow < _spawnPerSecond) { _spawnedThisWindow++; return true; }
return false;
}
private async Task SpawnLightAsync(string pointId, LightLayout layout, CancellationToken ct)
{
- var connector = _connector as MsfsSimulatorConnector;
- if (connector == null) return null;
- _logger.LogTrace("[SpawnLightAsyncCall] point={id} stateId={sid} lat={lat:F6} lon={lon:F6} hdg={hdg:F1}", pointId, layout.StateId, layout.Latitude, layout.Longitude, layout.Heading ?? 0);
- var simObj = await connector.SpawnLightAsync(pointId, layout.Latitude, layout.Longitude, layout.Heading, layout.StateId, ct);
- if (simObj != null)
+ 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.LogTrace("Spawned SimObject {obj} for {id}", simObj.ObjectId, pointId);
+ _logger.LogWarning(ex, "[Connector.Spawn.Fail] point={pointId} stateId={sid}", pointId, layout.StateId);
+ throw;
}
- return simObj;
+ finally { _spawnConcurrency.Release(); }
}
private Task DespawnLightAsync(SimObject simObject, CancellationToken ct)
{
- var connector = _connector as MsfsSimulatorConnector;
- if (connector == null) return Task.CompletedTask;
- return connector.DespawnLightAsync(simObject, 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()
{
- lock (_spawnLock) return _spawned.Values.Sum(g => g.Objects.Count);
+ 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);
- private sealed record SpawnGroup(IReadOnlyList Objects, IReadOnlyList Layouts, bool IsOn);
-
- private IReadOnlyList GetOrBuildLayouts(PointState ps, IReadOnlyList? existingLayouts)
+ private IReadOnlyList GetOrBuildLayouts(PointState ps) => _layoutCache.GetOrAdd(ps.Metadata.Id, _ =>
{
- if (existingLayouts != null && existingLayouts.Count > 0) return existingLayouts;
- IReadOnlyList rawLights;
+ IReadOnlyList raw;
if (!_hub.TryGetLightLayout(ps.Metadata.Id, out var hubLights) || hubLights.Count == 0)
- {
- rawLights = new List { new AirportStateHub.LightLayout(ps.Metadata.Latitude, ps.Metadata.Longitude, null, ps.Metadata.Color, null) };
- }
- else rawLights = hubLights;
- return rawLights.Select(l => new LightLayout(l.Latitude, l.Longitude, l.Heading, l.Color, l.StateId)).ToList();
- }
+ raw = new List { new AirportStateHub.LightLayout(ps.Metadata.Latitude, ps.Metadata.Longitude, null, ps.Metadata.Color, null) };
+ else raw = hubLights;
+ return (IReadOnlyList)raw.Select(l => new LightLayout(l.Latitude, l.Longitude, l.Heading, l.Color, l.StateId)).ToList();
+ });
- // Full (fresh) spawn, used only for new points or layout reduction / state change requiring full replace
- private async Task SpawnGroupFreshAsync(string pointId, IReadOnlyList layouts, bool useLayoutStateIds, CancellationToken ct, bool ignoreCapForTransition = false, bool isOn = true, int? forcedStateId = null)
- {
- var spawnedHandles = new List();
- foreach (var layout in layouts)
- {
- if (!ignoreCapForTransition && TotalActiveLightCount() >= _maxObjects)
- {
- Interlocked.Increment(ref _totalSkippedCap);
- _logger.LogWarning("[SpawnSkip:Cap] {id} cap={cap} active={active}", pointId, _maxObjects, TotalActiveLightCount());
- break;
- }
- if (!CanSpawnNow())
- {
- Interlocked.Increment(ref _totalDeferredRate);
- _logger.LogDebug("[SpawnDefer:Rate] {id} remainingLayoutsWillRetry", pointId);
- // requeue a synthetic point state using latest cached state so we finish group later
- if (_latestStates.TryGetValue(pointId, out var latest)) _queue.Enqueue(latest);
- break;
- }
- int? stateId;
- if (forcedStateId.HasValue)
- stateId = forcedStateId.Value;
- else if (useLayoutStateIds)
- stateId = layout.StateId;
- else
- stateId = null; // base model (off)
- SimObject? handle = null;
- try
- {
- handle = await SpawnLightAsync(pointId, new LightLayout(layout.Latitude, layout.Longitude, layout.Heading, layout.Color, stateId), ct);
- if (handle != null)
- {
- spawnedHandles.Add(handle);
- // reset failure streak on success
- _spawnFailures.TryRemove(pointId, out _);
- }
- else
- {
- // treat null as a failure (e.g., connector not MSFS)
- RegisterSpawnFailure(pointId);
- }
- }
- catch (Exception ex)
- {
- RegisterSpawnFailure(pointId);
- _logger.LogDebug(ex, "[SpawnError] {id} stateId={sid}", pointId, stateId ?? -1);
- // After a failure, break to avoid rapid-fire attempts this tick
- break;
- }
- }
- if (spawnedHandles.Count > 0)
- {
- lock (_spawnLock)
- {
- _spawned[pointId] = new SpawnGroup(spawnedHandles, layouts, isOn);
- Interlocked.Add(ref _totalSpawned, spawnedHandles.Count);
- _logger.LogInformation("[SpawnGroup] {id} lights={lights} activePoints={points} totalLights={total} overlapCapIgnore={ignoreCap} isOn={on}", pointId, spawnedHandles.Count, _spawned.Count, TotalActiveLightCount(), ignoreCapForTransition, isOn);
- }
- }
- }
-
- // Incrementally spawn only missing layouts (no overlap) for partially-complete points
- private async Task SpawnMissingAsync(string pointId, SpawnGroup existing, IReadOnlyList layouts, CancellationToken ct, bool isOn, bool useLayoutStateIds, int? forcedStateId)
- {
- int already = existing.Objects.Count;
- if (already >= layouts.Count) return;
- var newHandles = new List();
- for (int i = already; i < layouts.Count; i++)
- {
- var layout = layouts[i];
- int? stateId;
- if (forcedStateId.HasValue) stateId = forcedStateId.Value;
- else if (useLayoutStateIds) stateId = layout.StateId;
- else stateId = 0; // off placeholder
- if (TotalActiveLightCount() >= _maxObjects)
- {
- Interlocked.Increment(ref _totalSkippedCap);
- _logger.LogWarning("[SpawnSkip:Cap] {id} cap={cap} active={active} partialProgress={done}/{total}", pointId, _maxObjects, TotalActiveLightCount(), i, layouts.Count);
- break;
- }
- if (!CanSpawnNow())
- {
- Interlocked.Increment(ref _totalDeferredRate);
- _logger.LogDebug("[SpawnDefer:Rate] {id} partialProgress={done}/{total}", pointId, i, layouts.Count);
- if (_latestStates.TryGetValue(pointId, out var latest)) _queue.Enqueue(latest);
- break;
- }
- try
- {
- var handle = await SpawnLightAsync(pointId, new LightLayout(layout.Latitude, layout.Longitude, layout.Heading, layout.Color, stateId), ct);
- if (handle != null)
- {
- newHandles.Add(handle);
- _spawnFailures.TryRemove(pointId, out _); // reset on any success
- }
- else RegisterSpawnFailure(pointId);
- }
- catch (Exception ex)
- {
- RegisterSpawnFailure(pointId);
- _logger.LogDebug(ex, "[SpawnError:Incremental] {id} index={idx}", pointId, i);
- break; // stop this cycle on first exception
- }
- }
- if (newHandles.Count > 0)
- {
- lock (_spawnLock)
- {
- // Merge with existing handles
- var merged = existing.Objects.Concat(newHandles).ToList();
- _spawned[pointId] = new SpawnGroup(merged, layouts, isOn);
- Interlocked.Add(ref _totalSpawned, newHandles.Count);
- _logger.LogInformation("[SpawnGroup:Incremental] {id} added={added} now={now}/{total} activePoints={points} totalLights={lights} isOn={on}", pointId, newHandles.Count, merged.Count, layouts.Count, _spawned.Count, TotalActiveLightCount(), isOn);
- }
- }
- }
+ // group spawning logic removed in manager-driven mode
private void RegisterSpawnFailure(string pointId)
{
@@ -457,12 +368,13 @@ private void RegisterSpawnFailure(string pointId)
_ => (1, now),
(_, prev) => (prev.Failures + 1, now));
- // Dynamic backoff: base 500ms * failures^2 (capped) and extra fixed cooldown after threshold
- var backoffMs = Math.Min(500 * updated.Failures * updated.Failures, 8000); // cap 8s
+ // 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
+ // 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;
@@ -473,49 +385,97 @@ private void RegisterSpawnFailure(string pointId)
_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);
- }
- private async Task DelayedDespawnAsync(string pointId, List handles, int delayMs, CancellationToken ct)
- {
- try
+ // Escalate to hard cooldown if failures very high (likely persistent model issue)
+ if (updated.Failures == 6)
{
- await Task.Delay(delayMs, ct).ConfigureAwait(false);
- _logger.LogTrace("[OverlapDespawn] {id} delayMs={delay} count={count}", pointId, delayMs, handles.Count);
- foreach (var h in handles)
- {
- try
- {
- await DespawnLightAsync(h, ct).ConfigureAwait(false);
- Interlocked.Increment(ref _totalDespawned);
- }
- catch { }
- }
- _logger.LogDebug("[OverlapDespawnDone] {id} removed={removed}", pointId, handles.Count);
+ var hardUntil = now.AddMinutes(1);
+ _hardCooldownUntil[pointId] = hardUntil;
+ _logger.LogWarning("[SpawnFail:HardCooldownStart] {id} failures={fail} pauseUntil={until:O}", pointId, updated.Failures, hardUntil);
}
- catch (OperationCanceledException) { }
- catch (Exception ex)
+ }
+
+ // 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 && o.UserData is string s && s == pointId).ToList();
+ if (list.Count == 0) return;
+ _logger.LogDebug("[DespawnPointStart] {id} count={count}", pointId, list.Count);
+ foreach (var obj in list)
{
- _logger.LogDebug(ex, "[OverlapDespawnError] {id}", pointId);
+ 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());
}
- private async Task DespawnGroupAsync(string pointId, SpawnGroup group, CancellationToken ct)
+ // Perform ordering & pruning based on aircraft proximity.
+ private async Task ProximitySweepAsync(CancellationToken ct)
{
- List handles;
- lock (_spawnLock)
+ var flight = _simManager.LatestState;
+ if (flight == null) return;
+ // Build active point set via manager
+ var activePointIds = new HashSet(StringComparer.Ordinal);
+ var mgr = GetManager();
+ if (mgr != null)
{
- if (!_spawned.Remove(pointId, out var existing)) return;
- handles = existing.Objects.ToList();
+ foreach (var o in mgr.ManagedObjects.Values)
+ if (o.IsActive && o.UserData is string sid)
+ activePointIds.Add(sid);
}
- _logger.LogDebug("[DespawnGroupStart] {id} handles={count}", pointId, handles.Count);
- foreach (var h in handles)
+ // Despawn far ones
+ foreach (var pid in activePointIds)
{
- await DespawnLightAsync(h, ct);
- Interlocked.Increment(ref _totalDespawned);
+ if (!_latestStates.TryGetValue(pid, out var latest)) continue;
+ var dist = DistanceMeters(flight.Latitude, flight.Longitude, latest.Metadata.Latitude, latest.Metadata.Longitude);
+ if (dist > _spawnRadiusMeters * 1.05)
+ {
+ _logger.LogTrace("[ProximityDespawn] {id} dist={dist:F0}m radius={radius}", pid, dist, _spawnRadiusMeters);
+ await DespawnPointAsync(pid, ct);
+ }
+ }
+ // 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);
+ if (dist > _spawnRadiusMeters) continue;
+ 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;
+ // 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.LogInformation("[DespawnGroup] {id} removedLights={removed} remainingPoints={points} totalLights={total}", pointId, handles.Count, _spawned.Count, TotalActiveLightCount());
+ _logger.LogTrace("[ProximityEnqueue] added={count} queue={q}", candidates.Count, _queue.Count);
}
+ 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;
@@ -524,20 +484,12 @@ private void ResyncActivePointsAfterLayout()
var ps = kv.Value;
if (!ps.IsOn) continue;
if (!_hub.TryGetLightLayout(ps.Metadata.Id, out var layout) || layout.Count == 0) continue;
- bool alreadyFull = false;
- lock (_spawnLock)
- {
- if (_spawned.TryGetValue(ps.Metadata.Id, out var existing) && existing.Objects.Count >= layout.Count) alreadyFull = true;
- }
- if (alreadyFull) continue; // already full
- // Queue for reprocessing to spawn full light group
+ 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);
- }
+ if (queued > 0) _logger.LogInformation("Resync queued {count} active points for full layout spawn", queued);
}
///
@@ -546,42 +498,82 @@ private void ResyncActivePointsAfterLayout()
///
public async Task DespawnAllAsync(CancellationToken ct = default)
{
- List all;
- int pointCount;
- lock (_spawnLock)
+ var mgr = GetManager();
+ if (mgr == null)
{
- all = _spawned.Values.SelectMany(v => v.Objects).ToList();
- pointCount = _spawned.Count;
- _spawned.Clear();
+ _logger.LogInformation("[DespawnAll] AI manager not available");
+ return;
}
- if (all.Count == 0)
+ 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] points={points} lights={lights}", pointCount, all.Count);
- foreach (var obj in all)
+ _logger.LogInformation("[DespawnAllStart] lights={lights}", ours.Count);
+ foreach (var obj in ours)
{
- try
- {
- await DespawnLightAsync(obj, ct);
- Interlocked.Increment(ref _totalDespawned);
- }
- catch (Exception ex)
+ 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 && o.UserData is string s && s == pointId).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 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 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))
{
- _logger.LogDebug(ex, "DespawnAllAsync failed for {obj}", obj.ObjectId);
+ // 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);
}
- _logger.LogInformation("[DespawnAll] removedLights={removed} remainingPoints=0 totalLights=0", all.Count);
}
}
internal sealed class MsfsPointControllerOptions
{
- public int MaxObjects { get; init; } = 1000;
+ public int MaxObjects { get; init; } = 900;
public int SpawnPerSecond { get; init; } = 20;
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;
}
diff --git a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
index 544b5d3..06db884 100644
--- a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
@@ -24,6 +24,8 @@ public sealed class MsfsSimulatorConnector : ISimulatorConnector, IDisposable
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();
public MsfsSimulatorConnector(ILogger logger) => _logger = logger;
@@ -191,6 +193,8 @@ public async IAsyncEnumerable StreamRawAsync([EnumeratorCancell
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)
@@ -219,7 +223,7 @@ internal async Task DespawnLightAsync(SimObject simObject, CancellationToken ct)
private async Task TryLateAttachAsync(string pointId, double lat, double lon, SimConnectClient client, CancellationToken cancellationToken)
{
- if (!_lateAttachedPoints.TryAdd(pointId, false)) return;
+ if (!_lateAttachedPoints.TryAdd(pointId, false)) return; // already attempting
try
{
var mgr = client.AIObjects;
@@ -227,14 +231,33 @@ private async Task TryLateAttachAsync(string pointId, double lat, double lon, Si
for (int i = 0; i < maxSeconds && !cancellationToken.IsCancellationRequested; i++)
{
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
- var candidate = mgr.ManagedObjects.Values
- .Where(o => o.IsActive)
- .OrderByDescending(o => o.ObjectId)
- .FirstOrDefault();
- if (candidate != null)
+ // If create eventually succeeded normally, association already recorded
+ if (_createdObjectIds.ContainsKey(pointId))
{
_lateAttachedPoints[pointId] = true;
- _logger.LogInformation("[Connector.LateAttach] point={pointId} objectId={obj}", pointId, candidate.ObjectId);
+ _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;
}
}
@@ -247,10 +270,13 @@ private async Task TryLateAttachAsync(string pointId, double lat, double lon, Si
finally
{
// Allow future attempts if we never succeeded
- _lateAttachedPoints.TryRemove(pointId, out _);
+ 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
diff --git a/Services/SceneryService.cs b/Services/SceneryService.cs
index 59a154d..17a0088 100644
--- a/Services/SceneryService.cs
+++ b/Services/SceneryService.cs
@@ -13,27 +13,32 @@ public class SceneryContribution
public string UserId { get; set; } = string.Empty;
public string UserDisplayName { get; set; } = string.Empty;
public string AirportIcao { get; set; } = string.Empty;
- public string PackageName { get; set; } = string.Empty;
+ public string PackageName { get; set; } = string.Empty;
public string SubmittedXml { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public DateTime SubmissionDate { get; set; }
public string Status { get; set; } = string.Empty;
public string RejectionReason { get; set; } = string.Empty;
public DateTime? DecisionDate { get; set; }
- }public class ContributionsResponse
+ }
+
+ public class ContributionsResponse
{
- public List contributions { get; set; }
- } public class SceneryService
+ // Initialize to empty list to satisfy non-nullable warning
+ public List contributions { get; set; } = new();
+ }
+
+ public class SceneryService
{
private const string API_URL = "https://v2.stopbars.com/contributions?status=approved";
private const string SETTINGS_FILE = "scenerySelections.json";
private readonly HttpClient _httpClient;
private Dictionary _selectedPackages;
private static SceneryService? _instance;
-
- public static SceneryService Instance
+
+ public static SceneryService Instance
{
- get
+ get
{
_instance ??= new SceneryService();
return _instance;
@@ -49,28 +54,29 @@ private SceneryService()
public async Task>> GetAvailablePackagesAsync()
{
try
- { var response = await _httpClient.GetStringAsync(API_URL);
-
+ {
+ var response = await _httpClient.GetStringAsync(API_URL);
+
// Use case-insensitive JSON options
- var options = new JsonSerializerOptions
- {
- PropertyNameCaseInsensitive = true
+ var options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
};
-
+
var data = JsonSerializer.Deserialize(response, options);
-
+
var packages = new Dictionary>();
-
+
// Print debug info
Console.WriteLine($"API Response received, contributions count: {data?.contributions?.Count ?? 0}");
-
+
if (data?.contributions != null && data.contributions.Count > 0)
{
foreach (var contribution in data.contributions)
{
if (string.IsNullOrEmpty(contribution.AirportIcao) || string.IsNullOrEmpty(contribution.PackageName))
continue;
-
+
if (!packages.ContainsKey(contribution.AirportIcao))
{
packages[contribution.AirportIcao] = new List();
@@ -81,13 +87,13 @@ public async Task>> GetAvailablePackagesAsync()
packages[contribution.AirportIcao].Add(contribution.PackageName);
}
}
-
+
Console.WriteLine($"Processed contributions into {packages.Count} airports with scenery packages");
-
+
// No need to add "Default Scenery" - the first item will be selected by default
return packages;
}
-
+
// If data?.contributions is null or empty, return an empty dictionary
Console.WriteLine("No contributions found in API response");
return new Dictionary>();
@@ -98,7 +104,8 @@ public async Task>> GetAvailablePackagesAsync()
Console.WriteLine($"Error fetching scenery packages: {ex.Message}");
return new Dictionary>();
}
- } public string GetSelectedPackage(string icao)
+ }
+ public string GetSelectedPackage(string icao)
{
return _selectedPackages.TryGetValue(icao, out string? package) ? package : string.Empty;
}
@@ -107,7 +114,8 @@ public void SetSelectedPackage(string icao, string packageName)
{
_selectedPackages[icao] = packageName;
SaveSelectedPackages();
- } private Dictionary LoadSelectedPackages()
+ }
+ private Dictionary LoadSelectedPackages()
{
try
{
From ae65241bfd107e0d83c87d20524e327d4aecdb9e Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Mon, 11 Aug 2025 21:55:06 +0800
Subject: [PATCH 04/20] Enhance airport package selection logic in
AirportStateHub and MainWindowViewModel; auto-select first available package
if none is stored, improving user experience and error handling.
---
Infrastructure/Networking/AirportStateHub.cs | 38 +++++++++++++++++--
.../ViewModels/MainWindowViewModel.cs | 12 ++++++
2 files changed, 46 insertions(+), 4 deletions(-)
diff --git a/Infrastructure/Networking/AirportStateHub.cs b/Infrastructure/Networking/AirportStateHub.cs
index b586951..379f3e6 100644
--- a/Infrastructure/Networking/AirportStateHub.cs
+++ b/Infrastructure/Networking/AirportStateHub.cs
@@ -9,6 +9,7 @@
using System.Xml.Linq;
using BARS_Client_V2.Domain;
using Microsoft.Extensions.Logging;
+using BARS_Client_V2.Services;
namespace BARS_Client_V2.Infrastructure.Networking;
@@ -192,12 +193,41 @@ private async Task EnsureMapLoadedAsync(string airport, CancellationToken ct)
if (string.Equals(_mapAirport, airport, StringComparison.OrdinalIgnoreCase)) return;
_metadata.Clear();
_layouts.Clear();
- var url = $"https://v2.stopbars.com/maps/{airport}/latest";
- _logger.LogInformation("Fetching airport XML map {url}", url);
+ // Determine currently selected scenery package for this airport (if any). If none selected yet, auto-select first available.
+ string package = string.Empty;
+ try
+ {
+ package = SceneryService.Instance.GetSelectedPackage(airport);
+ if (string.IsNullOrWhiteSpace(package))
+ {
+ // Fetch contributions (packages) and choose first for this airport.
+ var all = await SceneryService.Instance.GetAvailablePackagesAsync();
+ if (all.TryGetValue(airport, out var pkgList) && pkgList.Count > 0)
+ {
+ package = pkgList.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).First();
+ // Persist selection so UI stays consistent.
+ SceneryService.Instance.SetSelectedPackage(airport, package);
+ _logger.LogInformation("Auto-selected first package '{pkg}' for airport {apt}", package, airport);
+ }
+ else
+ {
+ _logger.LogWarning("No packages found for airport {apt} when attempting to auto-select; aborting map load", airport);
+ return; // Without a package the server cannot return a map.
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed determining package for airport {apt}", airport);
+ return; // Can't proceed without a valid package.
+ }
+ var safePkg = Uri.EscapeDataString(package);
+ var url = $"https://v2.stopbars.com/maps/{airport}/packages/{safePkg}/latest";
+ _logger.LogInformation("Fetching airport XML map {apt} package={pkg} url={url}", airport, package, url);
using var resp = await _httpClient.GetAsync(url, ct);
if (!resp.IsSuccessStatusCode)
{
- _logger.LogWarning("Airport map fetch failed {status}", resp.StatusCode);
+ _logger.LogWarning("Airport map fetch failed {status} apt={apt} package={pkg}", resp.StatusCode, airport, package);
return;
}
var xml = await resp.Content.ReadAsStringAsync(ct);
@@ -210,7 +240,7 @@ private async Task EnsureMapLoadedAsync(string airport, CancellationToken ct)
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Error parsing airport map {apt}", airport);
+ _logger.LogWarning(ex, "Error parsing airport map {apt} package={pkg}", airport, package);
}
}
finally
diff --git a/Presentation/ViewModels/MainWindowViewModel.cs b/Presentation/ViewModels/MainWindowViewModel.cs
index 7ae25f4..59d0282 100644
--- a/Presentation/ViewModels/MainWindowViewModel.cs
+++ b/Presentation/ViewModels/MainWindowViewModel.cs
@@ -166,6 +166,18 @@ private async Task RunSearchAsync(bool resetPage = false)
var match = a.SceneryPackages.FirstOrDefault(p => p.Name == pkgName);
if (match != null) row.SelectedPackage = match;
}
+ // Auto-select first package if none stored/selected (always at least one per requirements)
+ if (row.SelectedPackage == null && a.SceneryPackages.Count > 0)
+ {
+ row.SelectedPackage = a.SceneryPackages.First();
+ if (!_savedPackages.ContainsKey(a.ICAO))
+ {
+ _savedPackages[a.ICAO] = row.SelectedPackage.Name;
+ // Fire and forget save (debounced vs per-change not critical given infrequent list rebuild)
+ _ = _settingsStore.SaveAsync(new ClientSettings(ApiToken, _savedPackages));
+ }
+ // AirportRowOnPropertyChanged handler will persist selection via PropertyChanged event
+ }
row.PropertyChanged += AirportRowOnPropertyChanged;
Airports.Add(row);
}
From b34d321912ec6586a42c862daca7edafa364251b Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Tue, 12 Aug 2025 15:57:46 +0800
Subject: [PATCH 05/20] Add dynamic pruning capability to MsfsPointController;
enable conditional point despawning based on active object count, improving
resource management.
---
.../Simulators/MSFS/MsfsPointController.cs | 89 +++++++++++++------
1 file changed, 61 insertions(+), 28 deletions(-)
diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
index 7800755..0fc9d02 100644
--- a/Infrastructure/Simulators/MSFS/MsfsPointController.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -39,6 +39,7 @@ internal sealed class MsfsPointController : BackgroundService, IPointStateListen
private readonly double _spawnRadiusMeters;
private readonly TimeSpan _proximitySweepInterval;
private DateTime _nextProximitySweepUtc = DateTime.UtcNow;
+ private readonly bool _dynamicPruneEnabled;
// Rate tracking
private DateTime _nextSpawnWindow = DateTime.UtcNow;
@@ -81,6 +82,7 @@ public MsfsPointController(IEnumerable connectors,
_errorBackoffMs = options.ErrorBackoffMs;
_spawnRadiusMeters = options.SpawnRadiusMeters;
_proximitySweepInterval = TimeSpan.FromSeconds(options.ProximitySweepSeconds);
+ _dynamicPruneEnabled = options.DynamicPruneEnabled;
}
public void OnPointStateChanged(PointState state)
@@ -173,16 +175,6 @@ private async Task ProcessAsync(PointState ps, CancellationToken ct)
var layouts = GetOrBuildLayouts(ps);
if (layouts.Count == 0) return;
var flight = _simManager.LatestState;
- if (flight != null)
- {
- var dist = DistanceMeters(flight.Latitude, flight.Longitude, ps.Metadata.Latitude, ps.Metadata.Longitude);
- if (ps.IsOn && dist > _spawnRadiusMeters)
- {
- await DespawnPointAsync(id, CancellationToken.None);
- _logger.LogTrace("[ProcessSkip:OutOfRadius] {id} dist={dist:F0}m radius={radius}", id, dist, _spawnRadiusMeters);
- return;
- }
- }
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;
@@ -242,9 +234,17 @@ private async Task SpawnBatchAsync(string pointId, IReadOnlyList la
{
if (TotalActiveLightCount() >= _maxObjects)
{
- Interlocked.Increment(ref _totalSkippedCap);
- if (_latestStates.TryGetValue(pointId, out var latestCap)) _queue.Enqueue(latestCap);
- break;
+ 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;
+ }
}
if (!CanSpawnNow())
{
@@ -413,10 +413,10 @@ private async Task DespawnPointAsync(string pointId, CancellationToken ct)
}
// Perform ordering & pruning based on aircraft proximity.
- private async Task ProximitySweepAsync(CancellationToken ct)
+ private Task ProximitySweepAsync(CancellationToken ct)
{
var flight = _simManager.LatestState;
- if (flight == null) return;
+ if (flight == null) return Task.CompletedTask;
// Build active point set via manager
var activePointIds = new HashSet(StringComparer.Ordinal);
var mgr = GetManager();
@@ -426,17 +426,7 @@ private async Task ProximitySweepAsync(CancellationToken ct)
if (o.IsActive && o.UserData is string sid)
activePointIds.Add(sid);
}
- // Despawn far ones
- foreach (var pid in activePointIds)
- {
- if (!_latestStates.TryGetValue(pid, out var latest)) continue;
- var dist = DistanceMeters(flight.Latitude, flight.Longitude, latest.Metadata.Latitude, latest.Metadata.Longitude);
- if (dist > _spawnRadiusMeters * 1.05)
- {
- _logger.LogTrace("[ProximityDespawn] {id} dist={dist:F0}m radius={radius}", pid, dist, _spawnRadiusMeters);
- await DespawnPointAsync(pid, ct);
- }
- }
+ // 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)
@@ -444,13 +434,13 @@ private async Task ProximitySweepAsync(CancellationToken ct)
var st = kv.Value;
if (!st.IsOn) continue;
var dist = DistanceMeters(flight.Latitude, flight.Longitude, st.Metadata.Latitude, st.Metadata.Longitude);
- if (dist > _spawnRadiusMeters) continue;
+ // 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;
+ if (candidates.Count == 0) return Task.CompletedTask;
// Order by distance (closest first)
foreach (var c in candidates.OrderBy(c => c.Dist))
{
@@ -459,6 +449,7 @@ private async Task ProximitySweepAsync(CancellationToken ct)
_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)
@@ -540,6 +531,47 @@ 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 || o.UserData is not string pid) 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)
{
@@ -576,4 +608,5 @@ internal sealed class MsfsPointControllerOptions
public int OverlapDespawnDelayMs { get; init; } = 1000;
public double SpawnRadiusMeters { get; init; } = 8000;
public int ProximitySweepSeconds { get; init; } = 5;
+ public bool DynamicPruneEnabled { get; init; } = true;
}
From 150f95d165aeecd8ea2b427ea48c8f7a399e6409 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Tue, 12 Aug 2025 16:13:55 +0800
Subject: [PATCH 06/20] Refactor logging in MsfsPointController; implement
conditional logging for received states to reduce log verbosity and improve
performance.
---
.../Simulators/MSFS/MsfsPointController.cs | 23 ++++++++++++-------
1 file changed, 15 insertions(+), 8 deletions(-)
diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
index 0fc9d02..6961c06 100644
--- a/Infrastructure/Simulators/MSFS/MsfsPointController.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -90,10 +90,19 @@ public void OnPointStateChanged(PointState state)
_latestStates[state.Metadata.Id] = state;
if (_suspended) return; // cache only
_queue.Enqueue(state);
- Interlocked.Increment(ref _totalReceived);
- var m = state.Metadata;
- _logger.LogInformation("[Recv] {id} on={on} type={type} airport={apt} lat={lat:F6} lon={lon:F6} q={q}",
- m.Id, state.IsOn, m.Type, m.AirportId, m.Latitude, m.Longitude, _queue.Count);
+ 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);
+ }
}
///
@@ -128,7 +137,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_connector.IsConnected)
{
- if ((_totalReceived % 25) == 0) _logger.LogDebug("[Loop] Waiting for simulator connection. Queue={q}", _queue.Count);
+ if ((_totalReceived % 25) == 0) _logger.LogDebug("[Loop] Waiting for simulator connection.");
await Task.Delay(_disconnectedDelayMs, stoppingToken);
continue;
}
@@ -143,9 +152,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
else
{
- if (DateTime.UtcNow.Second % 20 == 0)
-
- await Task.Delay(_idleDelayMs, stoppingToken);
+ await Task.Delay(_idleDelayMs, stoppingToken);
}
if (DateTime.UtcNow >= _nextProximitySweepUtc)
{
From 6d9cf04a467016f411da710e21b3bacfa5ae6ca4 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 21 Aug 2025 13:22:14 +0800
Subject: [PATCH 07/20] Add Discord presence service; integrate with existing
services for enhanced user engagement and status updates.
---
App.xaml.cs | 1 +
BARS-Client-V2.csproj | 3 +-
.../Simulators/MSFS/MsfsSimulatorConnector.cs | 16 +-
.../ViewModels/MainWindowViewModel.cs | 2 +-
Services/DiscordPresenceService.cs | 222 ++++++++++++++++++
5 files changed, 241 insertions(+), 3 deletions(-)
create mode 100644 Services/DiscordPresenceService.cs
diff --git a/App.xaml.cs b/App.xaml.cs
index 624ffb7..1ba04df 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -37,6 +37,7 @@ protected override void OnStartup(StartupEventArgs e)
services.AddSingleton();
services.AddSingleton();
services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddHostedService();
services.AddSingleton();
services.AddSingleton(sp =>
{
diff --git a/BARS-Client-V2.csproj b/BARS-Client-V2.csproj
index 1a7c841..053ef13 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -18,7 +18,8 @@
-
+
+
diff --git a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
index 06db884..cb00fc4 100644
--- a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
@@ -30,8 +30,22 @@ public sealed class MsfsSimulatorConnector : ISimulatorConnector, IDisposable
public MsfsSimulatorConnector(ILogger logger) => _logger = logger;
public string SimulatorId => "MSFS";
- public string DisplayName => "Microsoft Flight Simulator";
+ 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)
{
diff --git a/Presentation/ViewModels/MainWindowViewModel.cs b/Presentation/ViewModels/MainWindowViewModel.cs
index 59d0282..47b4658 100644
--- a/Presentation/ViewModels/MainWindowViewModel.cs
+++ b/Presentation/ViewModels/MainWindowViewModel.cs
@@ -19,7 +19,7 @@ public class MainWindowViewModel : INotifyPropertyChanged
private readonly Timer _uiPoll;
private string _closestAirport = "ZZZZ";
private bool _onGround;
- private string _simulatorName = "(none)";
+ private string _simulatorName = "Not Connected";
private bool _simConnected;
private double _latitude;
private double _longitude;
diff --git a/Services/DiscordPresenceService.cs b/Services/DiscordPresenceService.cs
new file mode 100644
index 0000000..b6d9a84
--- /dev/null
+++ b/Services/DiscordPresenceService.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using DiscordRPC;
+using DiscordRPC.Logging;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using BARS_Client_V2.Application;
+using BARS_Client_V2.Infrastructure.Networking;
+
+namespace BARS_Client_V2.Services;
+
+///
+/// Background hosted service that manages Discord Rich Presence for the BARS Pilot Client.
+///
+internal sealed class DiscordPresenceService : BackgroundService
+{
+ private const string DiscordAppId = "1396344027560804373";
+ private readonly ILogger _logger;
+ private readonly SimulatorManager _simManager;
+ private readonly AirportWebSocketManager _wsManager;
+ private readonly INearestAirportService _nearestAirportService;
+ private DiscordRpcClient? _client;
+ private DateTime _appStartUtc = DateTime.UtcNow;
+ private readonly object _stateLock = new();
+ private string? _lastDetails;
+ private string? _lastState;
+ private string? _lastSmallKey;
+ private string? _lastSmallText;
+ private string? _lastLargeText;
+ private string _serverStatus = "Disconnected";
+ private DateTime _lastSendUtc = DateTime.MinValue;
+ private bool _forceUpdate;
+
+ // Rate limiting thresholds
+ private static readonly TimeSpan MinUpdateInterval = TimeSpan.FromSeconds(12);
+
+ public DiscordPresenceService(ILogger logger,
+ SimulatorManager simManager,
+ AirportWebSocketManager wsManager,
+ INearestAirportService nearestAirportService)
+ {
+ _logger = logger;
+ _simManager = simManager;
+ _wsManager = wsManager;
+ _nearestAirportService = nearestAirportService;
+ HookWsEvents();
+ }
+
+ private void HookWsEvents()
+ {
+ _wsManager.Connected += () => { _serverStatus = "Connected"; QueueImmediate(); };
+ _wsManager.Disconnected += reason => { _serverStatus = reason.Contains("Error", StringComparison.OrdinalIgnoreCase) ? "Error" : "Disconnected"; QueueImmediate(); };
+ _wsManager.ConnectionError += code => { _serverStatus = code == 0 ? "Error" : code.ToString(); QueueImmediate(); };
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ _client = new DiscordRpcClient(DiscordAppId, autoEvents: false)
+ {
+ Logger = new ConsoleLogger() { Level = DiscordRPC.Logging.LogLevel.None } // silence library logs (we log ourselves)
+ };
+ _client.Initialize();
+ _appStartUtc = DateTime.UtcNow;
+ _logger.LogInformation("Discord Rich Presence initialized");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to initialize Discord Rich Presence client");
+ return; // abort background loop to avoid spam
+ }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
+ }
+ catch (OperationCanceledException) { break; }
+ if (stoppingToken.IsCancellationRequested) break;
+ try { PublishIfChanged(); } catch (Exception ex) { _logger.LogDebug(ex, "PublishIfChanged error"); }
+ }
+ }
+
+ private void QueueImmediate()
+ {
+ lock (_stateLock) _forceUpdate = true;
+ }
+
+ private void PublishIfChanged()
+ {
+ var client = _client;
+ if (client == null || !client.IsInitialized) return;
+ string airport = "";
+ var latest = _simManager.LatestState;
+ if (latest != null)
+ {
+ try
+ {
+ var cached = _nearestAirportService.GetCachedNearest(latest.Latitude, latest.Longitude);
+ if (!string.IsNullOrWhiteSpace(cached))
+ {
+ airport = cached.ToUpperInvariant();
+ }
+ else
+ {
+ // async resolve to populate cache; trigger update when done
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var resolved = await _nearestAirportService.ResolveAndCacheAsync(latest.Latitude, latest.Longitude, CancellationToken.None);
+ if (!string.IsNullOrWhiteSpace(resolved)) QueueImmediate();
+ }
+ catch { }
+ });
+ }
+ }
+ catch { }
+ }
+ var connector = _simManager.ActiveConnector;
+ string simCode = connector?.SimulatorId ?? "None";
+ bool simConnected = connector?.IsConnected == true;
+ bool? is2024 = null;
+ if (connector is BARS_Client_V2.Infrastructure.Simulators.Msfs.MsfsSimulatorConnector msfsConn)
+ {
+ is2024 = msfsConn.IsMsfs2024;
+ if (simConnected)
+ {
+ simCode = is2024 == true ? "MSFS 2024" : (is2024 == false ? "MSFS 2020" : "MSFS");
+ }
+ }
+
+ // Choose small image key for MSFS variants (prefer 2024 if ID indicates such in future; using msfs2020 for now)
+ string? smallKey = null;
+ if (simConnected)
+ {
+ if (is2024 == true) smallKey = "msfs2024"; else if (is2024 == false) smallKey = "msfs2020"; else smallKey = "msfs2020"; // default/fallback
+ }
+ string? smallText = simConnected ? simCode : null;
+
+ string serverSegment = _serverStatus switch
+ {
+ "Connected" => "Server: Connected",
+ "Disconnected" => "Server: Disconnected",
+ "Error" => "Server: Error",
+ "403" => "Server: No VATSIM Connection",
+ var other => other.StartsWith("4") || other.StartsWith("5") ? $"Server: {other}" : "Server: Reconnecting"
+ };
+
+ // Details now only show airport + simulator; server status moved to state line per request
+ string details = $"Airport: {airport}"; // simulator removed (small image already conveys it)
+ string state = serverSegment; // server state shown on second line
+ string largeText = "BARS Pilot Client";
+
+ bool changed;
+ bool force;
+ lock (_stateLock)
+ {
+ force = _forceUpdate;
+ changed = force || details != _lastDetails || state != _lastState || smallKey != _lastSmallKey || smallText != _lastSmallText || largeText != _lastLargeText;
+ if (changed)
+ {
+ _lastDetails = details;
+ _lastState = state;
+ _lastSmallKey = smallKey;
+ _lastSmallText = smallText;
+ _lastLargeText = largeText;
+ _forceUpdate = false;
+ }
+ }
+
+ var now = DateTime.UtcNow;
+ if (!changed && (now - _lastSendUtc) < TimeSpan.FromMinutes(5)) return; // periodic keepalive every 5 min
+ if (!force && (now - _lastSendUtc) < MinUpdateInterval) return;
+
+ _lastSendUtc = now;
+
+ try
+ {
+ var presence = new RichPresence
+ {
+ Details = details.Length > 128 ? details.Substring(0, 128) : details,
+ State = state,
+ Assets = new Assets
+ {
+ LargeImageKey = "bars_logo",
+ LargeImageText = largeText,
+ SmallImageKey = smallKey,
+ SmallImageText = smallText
+ },
+ Timestamps = new Timestamps(_appStartUtc),
+ Buttons = new[]
+ {
+ new Button { Label = "What is BARS?", Url = "https://stopbars.com" },
+ new Button { Label = "Join Discord", Url = "https://stopbars.com/discord" }
+ }
+ };
+ client.SetPresence(presence);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to set Discord presence");
+ }
+ }
+
+ public override Task StopAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ _client?.ClearPresence();
+ _client?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error disposing Discord RPC client");
+ }
+ return base.StopAsync(cancellationToken);
+ }
+}
From 8f42a7d129a864348b38667bba45e70de49d9eff Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 21 Aug 2025 13:58:34 +0800
Subject: [PATCH 08/20] Enhance project configuration and build scripts; add
versioning, update airport default, and implement framework-dependent
publishing.
---
.gitignore | 3 +
BARS-Client-V2.csproj | 20 +++
.../ViewModels/MainWindowViewModel.cs | 2 +-
Services/DiscordPresenceService.cs | 10 +-
build/publish-framework-dependent.ps1 | 129 ++++++++++++++++++
build/publish.cmd | 10 ++
6 files changed, 169 insertions(+), 5 deletions(-)
create mode 100644 build/publish-framework-dependent.ps1
create mode 100644 build/publish.cmd
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/BARS-Client-V2.csproj b/BARS-Client-V2.csproj
index 053ef13..9d866dd 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -8,12 +8,32 @@
enabletrueappicon.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
+
+
+
+
+
diff --git a/Presentation/ViewModels/MainWindowViewModel.cs b/Presentation/ViewModels/MainWindowViewModel.cs
index 47b4658..1757910 100644
--- a/Presentation/ViewModels/MainWindowViewModel.cs
+++ b/Presentation/ViewModels/MainWindowViewModel.cs
@@ -17,7 +17,7 @@ public class MainWindowViewModel : INotifyPropertyChanged
{
private readonly SimulatorManager _simManager;
private readonly Timer _uiPoll;
- private string _closestAirport = "ZZZZ";
+ private string _closestAirport = "Unknown";
private bool _onGround;
private string _simulatorName = "Not Connected";
private bool _simConnected;
diff --git a/Services/DiscordPresenceService.cs b/Services/DiscordPresenceService.cs
index b6d9a84..7ca4067 100644
--- a/Services/DiscordPresenceService.cs
+++ b/Services/DiscordPresenceService.cs
@@ -149,10 +149,12 @@ private void PublishIfChanged()
"403" => "Server: No VATSIM Connection",
var other => other.StartsWith("4") || other.StartsWith("5") ? $"Server: {other}" : "Server: Reconnecting"
};
-
- // Details now only show airport + simulator; server status moved to state line per request
- string details = $"Airport: {airport}"; // simulator removed (small image already conveys it)
- string state = serverSegment; // server state shown on second line
+ if (string.IsNullOrWhiteSpace(airport))
+ {
+ airport = "Unknown";
+ }
+ string details = $"Airport: {airport}";
+ string state = serverSegment;
string largeText = "BARS Pilot Client";
bool changed;
diff --git a/build/publish-framework-dependent.ps1 b/build/publish-framework-dependent.ps1
new file mode 100644
index 0000000..ad0c1c3
--- /dev/null
+++ b/build/publish-framework-dependent.ps1
@@ -0,0 +1,129 @@
+param(
+ [string]$Runtime = "win-x64",
+ [string]$Configuration = "Release",
+ [switch]$SkipBuild,
+ [string]$Project = "..\BARS-Client-V2.csproj",
+ [string]$OutRoot = "..\dist",
+ [string]$Version
+)
+
+$ErrorActionPreference = 'Stop'
+
+Write-Host "=== BARS Client Publish (Framework-Dependent) ===" -ForegroundColor Cyan
+
+# Resolve paths
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+Set-Location $scriptDir
+
+$projectPath = Resolve-Path $Project
+$distRoot = Resolve-Path $OutRoot -ErrorAction SilentlyContinue
+if (-not $distRoot) { New-Item -ItemType Directory -Path $OutRoot | Out-Null; $distRoot = Resolve-Path $OutRoot }
+
+# Determine version (priority: param -> -> -> )
+if ($Version) {
+ $resolvedVersion = $Version
+} else {
+ $raw = Get-Content $projectPath -Raw
+ try {
+ [xml]$csproj = $raw
+ } catch {
+ Write-Warning "XML parse warning: $($_.Exception.Message)"
+ }
+ if ($csproj) {
+ $pgs = @($csproj.Project.PropertyGroup)
+ $candidates = @()
+ foreach ($pg in $pgs) {
+ if ($pg.Version) { $candidates += $pg.Version.InnerText }
+ if ($pg.FileVersion) { $candidates += $pg.FileVersion.InnerText }
+ if ($pg.AssemblyVersion) { $candidates += $pg.AssemblyVersion.InnerText }
+ }
+ $resolvedVersion = ($candidates | Where-Object { $_ -and $_.Trim().Length -gt 0 } | Select-Object -First 1)
+ }
+ if (-not $resolvedVersion) {
+ # Regex fallback: match ...
+ $match = [regex]::Match($raw, '([^<]+)', 'IgnoreCase')
+ if ($match.Success) { $resolvedVersion = $match.Groups[1].Value.Trim() }
+ }
+}
+if (-not $resolvedVersion) {
+ Write-Host "--- DEBUG VERSION EXTRACTION ---" -ForegroundColor Yellow
+ Get-Content $projectPath | Select-Object -First 40 | ForEach-Object { Write-Host $_ }
+ throw "Unable to resolve version: supply -Version or add to csproj (debug above)."
+}
+if ($resolvedVersion -match "\s") { throw "Version contains whitespace: '$resolvedVersion'" }
+$version = $resolvedVersion
+
+Write-Host "Project: $projectPath" -ForegroundColor DarkGray
+Write-Host "Version: $version" -ForegroundColor DarkGray
+Write-Host "Runtime: $Runtime" -ForegroundColor DarkGray
+Write-Host "Configuration: $Configuration" -ForegroundColor DarkGray
+
+if (-not $SkipBuild) {
+ Write-Host "-- dotnet publish" -ForegroundColor Yellow
+ dotnet restore $projectPath
+ dotnet publish $projectPath `
+ -c $Configuration `
+ -r $Runtime `
+ --self-contained false `
+ -p:PublishReadyToRun=true `
+ -p:PublishSingleFile=false `
+ -p:DebugType=none
+}
+
+$publishDir = Join-Path (Split-Path $projectPath -Parent) "bin\\$Configuration\\net8.0-windows\\$Runtime\\publish"
+if (-not (Test-Path $publishDir)) { throw "Publish directory not found: $publishDir" }
+
+# Staging folder versioned
+$stageDir = Join-Path $distRoot "BARSClient-$version-$Runtime"
+if (Test-Path $stageDir) { Remove-Item $stageDir -Recurse -Force }
+Copy-Item $publishDir $stageDir -Recurse
+
+# Remove unwanted files (patterns)
+Get-ChildItem -Path $stageDir -Recurse -Include *.pdb,*.xml | ForEach-Object { Remove-Item $_.FullName -Force }
+
+# Generate file hash list
+$files = Get-ChildItem -Path $stageDir -File -Recurse | Sort-Object FullName
+$hashEntries = @()
+foreach ($f in $files) {
+ $rel = (Resolve-Path $f.FullName) -replace [regex]::Escape((Resolve-Path $stageDir)), ''
+ if ($rel.StartsWith('\')) { $rel = $rel.Substring(1) }
+ $h = Get-FileHash $f.FullName -Algorithm SHA256
+ $hashEntries += [PSCustomObject]@{ path = $rel; sha256 = $h.Hash; size = $f.Length }
+}
+
+$manifest = [PSCustomObject]@{
+ appId = 'com.stopbars.barsclient'
+ name = 'BARS Client'
+ version = $version
+ runtime = $Runtime
+ frameworkDependent = $true
+ publishedAt = (Get-Date).ToString('o')
+ files = $hashEntries
+}
+
+$manifestPath = Join-Path $stageDir 'manifest.json'
+$manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath $manifestPath -Encoding UTF8
+
+# Create zip
+$zipName = "BARSClient-$version-$Runtime.zip"
+$zipPath = Join-Path $distRoot $zipName
+if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
+Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $zipPath -Force
+
+# latest.json (pointer)
+$latest = [PSCustomObject]@{
+ version = $version
+ runtime = $Runtime
+ zip = $zipName
+ sha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash
+ manifest = 'manifest.json'
+ generatedAt = (Get-Date).ToString('o')
+}
+$latest | ConvertTo-Json -Depth 4 | Out-File (Join-Path $distRoot 'latest.json') -Encoding UTF8
+
+Write-Host "-- Output --" -ForegroundColor Green
+Write-Host "Zip: $zipPath" -ForegroundColor Green
+Write-Host "Manifest inside staging folder: $manifestPath" -ForegroundColor Green
+Write-Host "latest.json: $(Join-Path $distRoot 'latest.json')" -ForegroundColor Green
+
+Write-Host "Publish complete." -ForegroundColor Cyan
diff --git a/build/publish.cmd b/build/publish.cmd
new file mode 100644
index 0000000..5fa1fca
--- /dev/null
+++ b/build/publish.cmd
@@ -0,0 +1,10 @@
+@echo off
+set RUNTIME=win-x64
+set SCRIPT_DIR=%~dp0
+powershell -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish-framework-dependent.ps1" -Runtime %RUNTIME% %*
+if %errorlevel% neq 0 (
+ echo Publish failed.
+ exit /b %errorlevel%
+)
+echo.
+echo Output artifacts in dist\ folder.
From 5b51ca96a780cf5716f0133605752d65811f54e7 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 4 Sep 2025 19:42:05 +0800
Subject: [PATCH 09/20] Enhance stopbar crossing detection; update
SimConnect.NET version, add SendStopbarCrossing method, and improve polling
frequency for better accuracy.
---
BARS-Client-V2.csproj | 2 +-
Infrastructure/Networking/AirportStateHub.cs | 13 ++
.../Simulators/MSFS/MsfsPointController.cs | 129 +++++++++++++++++-
.../Simulators/MSFS/MsfsSimulatorConnector.cs | 2 +-
4 files changed, 143 insertions(+), 3 deletions(-)
diff --git a/BARS-Client-V2.csproj b/BARS-Client-V2.csproj
index 9d866dd..7833458 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -39,7 +39,7 @@
-
+
diff --git a/Infrastructure/Networking/AirportStateHub.cs b/Infrastructure/Networking/AirportStateHub.cs
index 379f3e6..71fec02 100644
--- a/Infrastructure/Networking/AirportStateHub.cs
+++ b/Infrastructure/Networking/AirportStateHub.cs
@@ -82,6 +82,19 @@ public async Task ProcessAsync(string json, CancellationToken ct = default)
}
}
+ ///
+ /// 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;
diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
index 6961c06..f1621a7 100644
--- a/Infrastructure/Simulators/MSFS/MsfsPointController.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -55,6 +55,13 @@ internal sealed class MsfsPointController : BackgroundService, IPointStateListen
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);
@@ -74,7 +81,7 @@ public MsfsPointController(IEnumerable connectors,
_hub = hub;
_simManager = simManager;
_hub.PointStateChanged += OnPointStateChanged;
- _hub.MapLoaded += _ => ResyncActivePointsAfterLayout();
+ _hub.MapLoaded += _ => { _stopbarSegments.Clear(); ResyncActivePointsAfterLayout(); };
_maxObjects = options.MaxObjects;
_spawnPerSecond = options.SpawnPerSecond;
_idleDelayMs = options.IdleDelayMs;
@@ -146,6 +153,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
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);
@@ -175,6 +185,123 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
}
+ 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;
diff --git a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
index cb00fc4..8ebead0 100644
--- a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
@@ -17,7 +17,7 @@ public sealed class MsfsSimulatorConnector : ISimulatorConnector, IDisposable
{
private readonly ILogger _logger;
private SimConnectClient? _client;
- private const int PollDelayMs = 15_000;
+ 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;
From 516c95358f832c8f2730a1a7eb5457cb5747c1e8 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 4 Sep 2025 20:00:52 +0800
Subject: [PATCH 10/20] Update SimConnect.NET version to 0.1.14-beta for
improved functionality and compatibility.
---
BARS-Client-V2.csproj | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/BARS-Client-V2.csproj b/BARS-Client-V2.csproj
index 7833458..80d3e91 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -39,7 +39,7 @@
-
+
From 0b54438caa71c65d6fbc1af019b728b942e40395 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Sat, 6 Sep 2025 14:55:17 +0800
Subject: [PATCH 11/20] Enhance scenery package management; add event for
package changes, improve package selection logic, and update settings file
handling.
---
Infrastructure/Networking/AirportStateHub.cs | 147 +++++---
.../Simulators/MSFS/MsfsPointController.cs | 357 ++++++++++++++++--
Services/SceneryService.cs | 93 ++++-
3 files changed, 494 insertions(+), 103 deletions(-)
diff --git a/Infrastructure/Networking/AirportStateHub.cs b/Infrastructure/Networking/AirportStateHub.cs
index 71fec02..47061d6 100644
--- a/Infrastructure/Networking/AirportStateHub.cs
+++ b/Infrastructure/Networking/AirportStateHub.cs
@@ -35,6 +35,8 @@ public AirportStateHub(IHttpClientFactory httpFactory, ILogger
_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
@@ -204,61 +206,109 @@ private async Task EnsureMapLoadedAsync(string airport, CancellationToken ct)
try
{
if (string.Equals(_mapAirport, airport, StringComparison.OrdinalIgnoreCase)) return;
- _metadata.Clear();
- _layouts.Clear();
- // Determine currently selected scenery package for this airport (if any). If none selected yet, auto-select first available.
- string package = string.Empty;
+ 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
{
- package = SceneryService.Instance.GetSelectedPackage(airport);
- if (string.IsNullOrWhiteSpace(package))
+ // 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;
+ try
+ {
+ package = SceneryService.Instance.GetSelectedPackage(airport);
+ var all = await SceneryService.Instance.GetAvailablePackagesAsync();
+ if (string.IsNullOrWhiteSpace(package))
+ {
+ if (all.TryGetValue(airport, out var pkgList) && pkgList.Count > 0)
{
- // Fetch contributions (packages) and choose first for this airport.
- var all = await SceneryService.Instance.GetAvailablePackagesAsync();
- if (all.TryGetValue(airport, out var pkgList) && pkgList.Count > 0)
- {
- package = pkgList.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).First();
- // Persist selection so UI stays consistent.
- SceneryService.Instance.SetSelectedPackage(airport, package);
- _logger.LogInformation("Auto-selected first package '{pkg}' for airport {apt}", package, airport);
- }
+ package = pkgList.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).First();
+ SceneryService.Instance.SetSelectedPackage(airport, package);
+ _logger.LogInformation("Auto-selected first package '{pkg}' for airport {apt}", package, airport);
+ }
+ else
+ {
+ _logger.LogWarning("No packages found for airport {apt} when attempting to auto-select; aborting map load", airport);
+ return;
+ }
+ }
+ else
+ {
+ // Resolve selection to one of the available package names (case-insensitive, supports substring like "2024").
+ if (all.TryGetValue(airport, out var pkgList) && pkgList.Count > 0)
+ {
+ var exact = pkgList.FirstOrDefault(p => string.Equals(p, package, StringComparison.OrdinalIgnoreCase));
+ if (!string.IsNullOrEmpty(exact)) package = exact;
else
{
- _logger.LogWarning("No packages found for airport {apt} when attempting to auto-select; aborting map load", airport);
- return; // Without a package the server cannot return a map.
+ var partial = pkgList.FirstOrDefault(p => p.IndexOf(package, StringComparison.OrdinalIgnoreCase) >= 0);
+ if (!string.IsNullOrEmpty(partial)) package = partial;
}
}
}
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed determining package for airport {apt}", airport);
- return; // Can't proceed without a valid package.
- }
- var safePkg = Uri.EscapeDataString(package);
- var url = $"https://v2.stopbars.com/maps/{airport}/packages/{safePkg}/latest";
- _logger.LogInformation("Fetching airport XML map {apt} package={pkg} url={url}", airport, package, url);
- using var resp = await _httpClient.GetAsync(url, ct);
- if (!resp.IsSuccessStatusCode)
- {
- _logger.LogWarning("Airport map fetch failed {status} apt={apt} package={pkg}", resp.StatusCode, airport, package);
- return;
- }
- var xml = await resp.Content.ReadAsStringAsync(ct);
- try
- {
- var doc = XDocument.Parse(xml);
- ParseMap(doc, airport);
- _mapAirport = airport;
- try { MapLoaded?.Invoke(airport); } catch { }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error parsing airport map {apt} package={pkg}", airport, package);
- }
}
- finally
+ catch (Exception ex)
{
- _mapLock.Release();
+ _logger.LogWarning(ex, "Failed determining package for airport {apt}", airport);
+ return;
+ }
+
+ var safePkg = Uri.EscapeDataString(package);
+ var url = $"https://v2.stopbars.com/maps/{airport}/packages/{safePkg}/latest";
+ _logger.LogInformation("Fetching airport XML map {apt} package={pkg} url={url}", airport, package, url);
+ using var resp = await _httpClient.GetAsync(url, ct);
+ if (!resp.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Airport map fetch failed {status} apt={apt} package={pkg}", resp.StatusCode, airport, package);
+ return;
+ }
+ var xml = await resp.Content.ReadAsStringAsync(ct);
+ try
+ {
+ var doc = XDocument.Parse(xml);
+ ParseMap(doc, airport);
+ _mapAirport = airport;
+ _lastSnapshotUtc = DateTime.MinValue; // force fresh snapshot soon
+ try { MapLoaded?.Invoke(airport); } catch { }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error parsing airport map {apt} package={pkg}", airport, package);
}
}
@@ -288,7 +338,10 @@ private void ParseMap(XDocument doc, string airport)
int? stateId = null;
var stateAttr = le.Attribute("stateId")?.Value;
if (int.TryParse(stateAttr, out var sidVal)) stateId = sidVal;
- lightList.Add(new LightLayout(lat, lon, hdg, lColor, stateId));
+ int? offStateId = null;
+ var offStateAttr = le.Attribute("offStateId")?.Value;
+ if (int.TryParse(offStateAttr, out var offSidVal)) offStateId = offSidVal;
+ lightList.Add(new LightLayout(lat, lon, hdg, lColor, stateId, offStateId));
sumLat += lat; sumLon += lon; cnt++; lightCount++;
}
double repLat = 0, repLon = 0;
@@ -312,7 +365,7 @@ private bool TryParseLatLon(string? csv, out double lat, out double lon)
return ok1 && ok2;
}
- public sealed record LightLayout(double Latitude, double Longitude, double? Heading, string? Color, int? StateId);
+ public sealed record LightLayout(double Latitude, double Longitude, double? Heading, string? Color, int? StateId, int? OffStateId);
private void ReconcileLoop()
{
diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
index f1621a7..cee954b 100644
--- a/Infrastructure/Simulators/MSFS/MsfsPointController.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -81,7 +81,7 @@ public MsfsPointController(IEnumerable connectors,
_hub = hub;
_simManager = simManager;
_hub.PointStateChanged += OnPointStateChanged;
- _hub.MapLoaded += _ => { _stopbarSegments.Clear(); ResyncActivePointsAfterLayout(); };
+ _hub.MapLoaded += OnMapLoaded;
_maxObjects = options.MaxObjects;
_spawnPerSecond = options.SpawnPerSecond;
_idleDelayMs = options.IdleDelayMs;
@@ -327,37 +327,13 @@ private async Task ProcessAsync(PointState ps, CancellationToken ct)
if (!ps.IsOn)
{
- // OFF: Build full placeholder set, then remove ALL variants.
- if (placeholders.Count < layouts.Count)
- {
- int need = layouts.Count - placeholders.Count;
- await SpawnBatchAsync(id, layouts, need, isPlaceholder: true, ct);
- if (_latestStates.TryGetValue(id, out var latestOff)) _queue.Enqueue(latestOff); // re-evaluate later
- _logger.LogTrace("[OverlapPending] {id} placeholders={ph}/{need}", id, placeholders.Count, layouts.Count);
- return;
- }
- if (variants.Count > 0)
- {
- await RemoveObjectsAsync(variants, id, ct, "[OverlapRemove:Variants]");
- _logger.LogTrace("[OverlapRemovedVariants] {id}", id);
- }
+ // OFF: Ensure per-light off variant (offStateId) if provided; otherwise fallback to placeholder (stateId=0).
+ await EnsureOffStateAsync(id, layouts, variants, placeholders, ct);
return;
}
- // ON path: Build variants first then remove placeholders.
- if (variants.Count < layouts.Count)
- {
- int need = layouts.Count - variants.Count;
- await SpawnBatchAsync(id, layouts, need, isPlaceholder: false, ct);
- if (_latestStates.TryGetValue(id, out var latestOn)) _queue.Enqueue(latestOn);
- _logger.LogTrace("[OverlapPendingVariants] {id} variants={var}/{need}", id, variants.Count, layouts.Count);
- return;
- }
- if (placeholders.Count > 0)
- {
- await RemoveObjectsAsync(placeholders, id, ct, "[OverlapRemove:Placeholders]");
- _logger.LogTrace("[OverlapRemovedPlaceholders] {id}", id);
- }
+ // 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)
@@ -426,6 +402,26 @@ private async Task RemoveObjectsAsync(List objects, string pointId, C
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 bool CanSpawnNow()
{
var now = DateTime.UtcNow;
@@ -464,6 +460,275 @@ private bool CanSpawnNow()
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;
+ }
+ }
+ if (!CanSpawnNow())
+ {
+ Interlocked.Increment(ref _totalDeferredRate);
+ if (_latestStates.TryGetValue(pointId, out var latestRate)) _queue.Enqueue(latestRate);
+ break;
+ }
+
+ 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;
+ }
+ }
+ if (!CanSpawnNow())
+ {
+ Interlocked.Increment(ref _totalDeferredRate);
+ if (_latestStates.TryGetValue(pointId, out var latestRate)) _queue.Enqueue(latestRate);
+ break;
+ }
+
+ 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;
@@ -482,15 +747,15 @@ private int TotalActiveLightCount()
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);
+ 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) };
+ 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)).ToList();
+ 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
@@ -535,7 +800,7 @@ private async Task DespawnPointAsync(string pointId, CancellationToken ct)
{
var mgr = GetManager();
if (mgr == null) return;
- var list = mgr.ManagedObjects.Values.Where(o => o.IsActive && o.UserData is string s && s == pointId).ToList();
+ 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)
@@ -557,8 +822,11 @@ private Task ProximitySweepAsync(CancellationToken ct)
if (mgr != null)
{
foreach (var o in mgr.ManagedObjects.Values)
- if (o.IsActive && o.UserData is string sid)
- activePointIds.Add(sid);
+ {
+ 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
@@ -648,7 +916,7 @@ public async Task DespawnAllAsync(CancellationToken ct = default)
{
var mgr = GetManager();
if (mgr == null) return (new List(), 0);
- var list = mgr.ManagedObjects.Values.Where(o => o.IsActive && o.UserData is string s && s == pointId).ToList();
+ 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);
}
@@ -660,6 +928,20 @@ public async Task DespawnAllAsync(CancellationToken ct = default)
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";
@@ -677,7 +959,8 @@ private async Task EnsureCapacityForSpawnAsync(string priorityPointId, int
var pointCounts = new Dictionary(StringComparer.Ordinal);
foreach (var o in mgr.ManagedObjects.Values)
{
- if (!o.IsActive || o.UserData is not string pid) continue;
+ 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;
diff --git a/Services/SceneryService.cs b/Services/SceneryService.cs
index 17a0088..a4e37fe 100644
--- a/Services/SceneryService.cs
+++ b/Services/SceneryService.cs
@@ -31,11 +31,15 @@ public class ContributionsResponse
public class SceneryService
{
private const string API_URL = "https://v2.stopbars.com/contributions?status=approved";
- private const string SETTINGS_FILE = "scenerySelections.json";
+ private const string SETTINGS_FILENAME = "settings.json";
private readonly HttpClient _httpClient;
private Dictionary _selectedPackages;
private static SceneryService? _instance;
+ // Fired when a user changes the selected scenery package for an airport.
+ // Args: (icao, newPackageName)
+ public event Action? PackageChanged;
+
public static SceneryService Instance
{
get
@@ -107,46 +111,76 @@ public async Task>> GetAvailablePackagesAsync()
}
public string GetSelectedPackage(string icao)
{
- return _selectedPackages.TryGetValue(icao, out string? package) ? package : string.Empty;
+ if (string.IsNullOrWhiteSpace(icao)) return string.Empty;
+ // Case-insensitive lookup to avoid mismatches like "yssy" vs "YSSY"
+ return _selectedPackages.TryGetValue(icao, out string? package)
+ ? package
+ : _selectedPackages.TryGetValue(icao.ToUpperInvariant(), out package)
+ ? package
+ : _selectedPackages.TryGetValue(icao.ToLowerInvariant(), out package)
+ ? package
+ : string.Empty;
}
public void SetSelectedPackage(string icao, string packageName)
{
+ if (string.IsNullOrWhiteSpace(icao)) return;
+ icao = icao.Trim();
+ packageName = packageName?.Trim() ?? string.Empty;
+ // Avoid redundant writes/events if unchanged
+ if (_selectedPackages.TryGetValue(icao, out var existing) &&
+ string.Equals(existing, packageName, StringComparison.Ordinal))
+ {
+ return;
+ }
+
_selectedPackages[icao] = packageName;
SaveSelectedPackages();
+
+ try { PackageChanged?.Invoke(icao, packageName); } catch { }
}
private Dictionary LoadSelectedPackages()
{
try
{
string appDataPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? "",
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty,
"BARS",
"Client"
);
- string filePath = Path.Combine(appDataPath, SETTINGS_FILE);
-
if (!Directory.Exists(appDataPath))
{
Directory.CreateDirectory(appDataPath);
}
- if (File.Exists(filePath))
+ string settingsPath = Path.Combine(appDataPath, SETTINGS_FILENAME);
+ var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
+
+ // Local type matching JsonSettingsStore serialization shape
+ var persisted = new SettingsPersisted();
+
+ if (File.Exists(settingsPath))
{
- string json = File.ReadAllText(filePath);
- var result = JsonSerializer.Deserialize>(json);
- if (result != null)
+ try
+ {
+ string json = File.ReadAllText(settingsPath);
+ var loaded = JsonSerializer.Deserialize(json, options);
+ if (loaded != null) persisted = loaded;
+ }
+ catch (Exception ex)
{
- return result;
+ Console.WriteLine($"Failed to read settings.json; {ex.Message}");
}
}
+
+ var result = persisted.AirportPackages ?? new Dictionary();
+ return new Dictionary(result, StringComparer.OrdinalIgnoreCase);
}
catch (Exception ex)
{
- Console.WriteLine($"Error loading scenery selections: {ex.Message}");
+ Console.WriteLine($"Error loading scenery selections from settings.json: {ex.Message}");
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
}
-
- return new Dictionary();
}
private void SaveSelectedPackages()
@@ -154,24 +188,45 @@ private void SaveSelectedPackages()
try
{
string appDataPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty,
"BARS",
"Client"
);
- string filePath = Path.Combine(appDataPath, SETTINGS_FILE);
-
if (!Directory.Exists(appDataPath))
{
Directory.CreateDirectory(appDataPath);
}
- string json = JsonSerializer.Serialize(_selectedPackages);
- File.WriteAllText(filePath, json);
+ string settingsPath = Path.Combine(appDataPath, SETTINGS_FILENAME);
+ var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
+
+ // Load existing to preserve unrelated fields (e.g., apiToken)
+ var persisted = new SettingsPersisted();
+ if (File.Exists(settingsPath))
+ {
+ try
+ {
+ var current = JsonSerializer.Deserialize(File.ReadAllText(settingsPath), options);
+ if (current != null) persisted = current;
+ }
+ catch { /* ignore and overwrite minimal */ }
+ }
+
+ persisted.AirportPackages = new Dictionary(_selectedPackages, StringComparer.OrdinalIgnoreCase);
+
+ var json = JsonSerializer.Serialize(persisted, options);
+ File.WriteAllText(settingsPath, json);
}
catch (Exception ex)
{
- Console.WriteLine($"Error saving scenery selections: {ex.Message}");
+ Console.WriteLine($"Error saving scenery selections to settings.json: {ex.Message}");
}
}
+
+ private sealed class SettingsPersisted
+ {
+ public string? ApiToken { get; set; }
+ public Dictionary? AirportPackages { get; set; }
+ }
}
}
From 91bf074de2513ac486634ccc1138df79ea0e2034 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Sat, 6 Sep 2025 15:00:54 +0800
Subject: [PATCH 12/20] Notify SceneryService of selected package changes for
hot-reloading the map
---
Presentation/ViewModels/MainWindowViewModel.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/Presentation/ViewModels/MainWindowViewModel.cs b/Presentation/ViewModels/MainWindowViewModel.cs
index 1757910..f4a9a99 100644
--- a/Presentation/ViewModels/MainWindowViewModel.cs
+++ b/Presentation/ViewModels/MainWindowViewModel.cs
@@ -234,6 +234,8 @@ private void AirportRowOnPropertyChanged(object? sender, PropertyChangedEventArg
_savedPackages[row.ICAO] = row.SelectedPackage.Name;
// Fire and forget save to persist selection quickly without blocking UI
_ = _settingsStore.SaveAsync(new ClientSettings(ApiToken, _savedPackages));
+ // Also notify SceneryService so the running session hot-reloads the map for this airport (if connected)
+ try { SceneryService.Instance.SetSelectedPackage(row.ICAO, row.SelectedPackage.Name); } catch { }
}
}
From 27f6ba453c144642fba0eca82a310cac810ebd95 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Mon, 15 Sep 2025 14:39:54 +0800
Subject: [PATCH 13/20] Refactor spawn management in MsfsPointController;
reduce concurrency limit, implement smooth per-spawn pacing, and update
SpawnPerSecond setting for improved performance.
---
.../Simulators/MSFS/MsfsPointController.cs | 61 +++++++++++--------
1 file changed, 35 insertions(+), 26 deletions(-)
diff --git a/Infrastructure/Simulators/MSFS/MsfsPointController.cs b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
index cee954b..8bad947 100644
--- a/Infrastructure/Simulators/MSFS/MsfsPointController.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsPointController.cs
@@ -26,7 +26,7 @@ internal sealed class MsfsPointController : BackgroundService, IPointStateListen
private readonly ConcurrentQueue _queue = new();
private readonly ConcurrentDictionary _latestStates = new();
private readonly ConcurrentDictionary> _layoutCache = new();
- private readonly System.Threading.SemaphoreSlim _spawnConcurrency = new(4, 4);
+ 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();
@@ -42,8 +42,9 @@ internal sealed class MsfsPointController : BackgroundService, IPointStateListen
private readonly bool _dynamicPruneEnabled;
// Rate tracking
- private DateTime _nextSpawnWindow = DateTime.UtcNow;
- private int _spawnedThisWindow;
+ private readonly object _rateLock = new();
+ private TimeSpan _perSpawnInterval = TimeSpan.FromMilliseconds(100);
+ private DateTime _nextAllowedSpawnUtc = DateTime.MinValue;
// Stats
private long _totalReceived;
@@ -90,6 +91,17 @@ public MsfsPointController(IEnumerable connectors,
_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)
@@ -356,12 +368,7 @@ private async Task SpawnBatchAsync(string pointId, IReadOnlyList la
break;
}
}
- if (!CanSpawnNow())
- {
- Interlocked.Increment(ref _totalDeferredRate);
- if (_latestStates.TryGetValue(pointId, out var latestRate)) _queue.Enqueue(latestRate);
- break;
- }
+ await WaitForSpawnSlotAsync(ct);
var layout = layouts[i];
int? variantState = layout.StateId;
if (!isPlaceholder)
@@ -422,12 +429,24 @@ private async void OnMapLoaded(string _)
}
}
- private bool CanSpawnNow()
+ private async Task WaitForSpawnSlotAsync(CancellationToken ct)
{
+ if (_spawnPerSecond <= 0 || _perSpawnInterval <= TimeSpan.Zero) return;
var now = DateTime.UtcNow;
- if (now > _nextSpawnWindow) { _nextSpawnWindow = now.AddSeconds(1); _spawnedThisWindow = 0; }
- if (_spawnedThisWindow < _spawnPerSecond) { _spawnedThisWindow++; return true; }
- return false;
+ 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)
@@ -577,12 +596,7 @@ private async Task EnsureOffStateAsync(string pointId, IReadOnlyList
break;
}
}
- if (!CanSpawnNow())
- {
- Interlocked.Increment(ref _totalDeferredRate);
- if (_latestStates.TryGetValue(pointId, out var latestRate)) _queue.Enqueue(latestRate);
- break;
- }
+ await WaitForSpawnSlotAsync(ct);
var layout = layouts[i];
var spawnLayout = layout with { StateId = desiredState };
@@ -1018,7 +1027,7 @@ private void ClassifyPointObjects(string pointId, out List placeholde
internal sealed class MsfsPointControllerOptions
{
public int MaxObjects { get; init; } = 900;
- public int SpawnPerSecond { get; init; } = 20;
+ public int SpawnPerSecond { get; init; } = 10;
public int IdleDelayMs { get; init; } = 10;
public int DisconnectedDelayMs { get; init; } = 500;
public int ErrorBackoffMs { get; init; } = 200;
From 80ed4be0050539a61e79ab1fa13a88e3cb9a7a2d Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Tue, 23 Sep 2025 16:56:17 +0800
Subject: [PATCH 14/20] Enhance airport package selection logic; cache
available packages for fallback, improve auto-selection and retry mechanisms,
and refine logging for better traceability.
---
Infrastructure/Networking/AirportStateHub.cs | 164 +++++++++++++------
1 file changed, 115 insertions(+), 49 deletions(-)
diff --git a/Infrastructure/Networking/AirportStateHub.cs b/Infrastructure/Networking/AirportStateHub.cs
index 47061d6..12a2fd2 100644
--- a/Infrastructure/Networking/AirportStateHub.cs
+++ b/Infrastructure/Networking/AirportStateHub.cs
@@ -2,6 +2,7 @@
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;
@@ -249,36 +250,50 @@ 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 (all.TryGetValue(airport, out var pkgList) && pkgList.Count > 0)
- {
- package = pkgList.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).First();
- SceneryService.Instance.SetSelectedPackage(airport, package);
- _logger.LogInformation("Auto-selected first package '{pkg}' for airport {apt}", package, airport);
- }
- else
+ 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 (all.TryGetValue(airport, out var pkgList) && pkgList.Count > 0)
+ if (airportPackages != null && airportPackages.Count > 0)
{
- var exact = pkgList.FirstOrDefault(p => string.Equals(p, package, StringComparison.OrdinalIgnoreCase));
- if (!string.IsNullOrEmpty(exact)) package = exact;
+ 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 = pkgList.FirstOrDefault(p => p.IndexOf(package, StringComparison.OrdinalIgnoreCase) >= 0);
+ 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 { }
+ }
}
}
}
@@ -288,45 +303,69 @@ private async Task LoadMapInternalAsync(string airport, CancellationToken ct)
return;
}
- var safePkg = Uri.EscapeDataString(package);
- var url = $"https://v2.stopbars.com/maps/{airport}/packages/{safePkg}/latest";
- _logger.LogInformation("Fetching airport XML map {apt} package={pkg} url={url}", airport, package, url);
- using var resp = await _httpClient.GetAsync(url, ct);
- if (!resp.IsSuccessStatusCode)
- {
- _logger.LogWarning("Airport map fetch failed {status} apt={apt} package={pkg}", resp.StatusCode, airport, package);
- return;
- }
- var xml = await resp.Content.ReadAsStringAsync(ct);
- try
+ async Task TryFetchAsync(string pkg, bool isRetry)
{
- var doc = XDocument.Parse(xml);
- ParseMap(doc, airport);
- _mapAirport = airport;
- _lastSnapshotUtc = DateTime.MinValue; // force fresh snapshot soon
- try { MapLoaded?.Invoke(airport); } catch { }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error parsing airport map {apt} package={pkg}", airport, package);
+ 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 pointCount = 0, lightCount = 0;
+ 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;
- var lightList = new List();
- double sumLat = 0, sumLon = 0; int cnt = 0;
+
+ // Parse lights for this element
+ var newLights = new List();
foreach (var le in obj.Elements("Light"))
{
var posText = le.Element("Position")?.Value;
@@ -335,23 +374,50 @@ private void ParseMap(XDocument doc, string airport)
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;
- var stateAttr = le.Attribute("stateId")?.Value;
- if (int.TryParse(stateAttr, out var sidVal)) stateId = sidVal;
- int? offStateId = null;
- var offStateAttr = le.Attribute("offStateId")?.Value;
- if (int.TryParse(offStateAttr, out var offSidVal)) offStateId = offSidVal;
- lightList.Add(new LightLayout(lat, lon, hdg, lColor, stateId, offStateId));
- sumLat += lat; sumLon += lon; cnt++; lightCount++;
+ 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));
}
- double repLat = 0, repLon = 0;
- if (cnt > 0) { repLat = sumLat / cnt; repLon = sumLon / cnt; }
- var meta = new PointMetadata(id!, airport, type, id!, repLat, repLon, null, orientation, color, false, false);
- _metadata[id!] = meta;
- if (lightList.Count > 0) _layouts[id!] = lightList;
- pointCount++;
+
+ 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} points={pts} lights={lights}", airport, pointCount, lightCount);
+
+ _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)
From 970b01586badc8b1fff8e6260aa3333bb998e5ed Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 25 Sep 2025 00:18:54 +0800
Subject: [PATCH 15/20] Update SimConnect.NET to version 0.1.15-beta
Upgraded the `SimConnect.NET` package reference in the `BARS-Client-V2.csproj` file from version `0.1.14-beta` to `0.1.15-beta`. This update may include bug fixes, new features, or other improvements provided by the library's maintainers.
---
BARS-Client-V2.csproj | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/BARS-Client-V2.csproj b/BARS-Client-V2.csproj
index 80d3e91..1fe27df 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -39,7 +39,7 @@
-
+
From 80458b0a5e4a92c4892397fb021d5c7acc5db052 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 25 Sep 2025 00:26:53 +0800
Subject: [PATCH 16/20] Improve app startup and shutdown robustness
Added `Dispatcher` usage to manage `_host.StartAsync()` on the UI
thread without blocking, ensuring smoother application startup.
Wrapped `_host.StopAsync()` in a `try-catch` block during shutdown
to handle exceptions gracefully and prevent crashes. Added the
`System.Windows.Threading` namespace to support these changes.
---
App.xaml.cs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/App.xaml.cs b/App.xaml.cs
index 1ba04df..a31dc22 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -7,6 +7,7 @@
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
{
@@ -53,7 +54,6 @@ protected override void OnStartup(StartupEventArgs e)
})
.Build();
- _host.Start();
var mainWindow = _host.Services.GetRequiredService();
var vm = _host.Services.GetRequiredService();
@@ -68,13 +68,27 @@ protected override void OnStartup(StartupEventArgs e)
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)
{
- await _host.StopAsync();
+ try { await _host.StopAsync(); } catch { }
_host.Dispose();
}
base.OnExit(e);
From cf7ed8928b154efaf2e4f97aef253d832aa073e9 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 25 Sep 2025 01:09:33 +0800
Subject: [PATCH 17/20] Enhance connection resilience and error handling in
simulator services; implement backoff strategy for transient errors and
improve reconnection logic.
---
Application/SimulatorManager.cs | 14 +++-
.../Simulators/MSFS/MsfsSimulatorConnector.cs | 66 ++++++++++++++--
.../ViewModels/MainWindowViewModel.cs | 5 +-
Services/NearestAirportService.cs | 76 +++++++++++++++++--
4 files changed, 146 insertions(+), 15 deletions(-)
diff --git a/Application/SimulatorManager.cs b/Application/SimulatorManager.cs
index 78955a2..479c9d1 100644
--- a/Application/SimulatorManager.cs
+++ b/Application/SimulatorManager.cs
@@ -59,7 +59,19 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
var active = ActiveConnector;
if (active == null || !active.IsConnected)
{
- await Task.Delay(1000, stoppingToken);
+ // 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
diff --git a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
index 8ebead0..ebe3893 100644
--- a/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
+++ b/Infrastructure/Simulators/MSFS/MsfsSimulatorConnector.cs
@@ -26,6 +26,9 @@ public sealed class MsfsSimulatorConnector : ISimulatorConnector, IDisposable
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;
@@ -117,8 +120,8 @@ public async IAsyncEnumerable StreamRawAsync([EnumeratorCancell
{
if (!IsConnected)
{
- try { await Task.Delay(1000, ct); } catch { yield break; }
- continue;
+ // Stop streaming so manager can observe disconnect and trigger reconnection.
+ yield break;
}
var sample = await TryGetSampleAsync(ct);
@@ -135,15 +138,64 @@ public async IAsyncEnumerable StreamRawAsync([EnumeratorCancell
{
var svm = client.SimVars;
if (svm == null) return null;
- double lat = await svm.GetAsync("PLANE LATITUDE", "degrees");
- double lon = await svm.GetAsync("PLANE LONGITUDE", "degrees");
- bool onGround = (await svm.GetAsync("SIM ON GROUND", "bool")) == 1;
+
+ 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) { throw; }
+ 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)
{
- _logger.LogDebug(ex, "MSFS sample retrieval failed (will retry)");
+ // 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;
}
}
diff --git a/Presentation/ViewModels/MainWindowViewModel.cs b/Presentation/ViewModels/MainWindowViewModel.cs
index f4a9a99..d858cbb 100644
--- a/Presentation/ViewModels/MainWindowViewModel.cs
+++ b/Presentation/ViewModels/MainWindowViewModel.cs
@@ -265,13 +265,14 @@ private void RefreshFromState()
});
}
}
- if (connector != null)
+ if (connector != null && connector.IsConnected)
{
SimulatorName = connector.DisplayName;
- SimulatorConnected = connector.IsConnected;
+ SimulatorConnected = true;
}
else
{
+ SimulatorName = "Not Connected";
SimulatorConnected = false;
}
}
diff --git a/Services/NearestAirportService.cs b/Services/NearestAirportService.cs
index 58ff715..611efa0 100644
--- a/Services/NearestAirportService.cs
+++ b/Services/NearestAirportService.cs
@@ -20,9 +20,18 @@ internal sealed class NearestAirportService : INearestAirportService
private double _lastLon;
private string? _lastIcao;
private DateTime _lastFetchUtc = DateTime.MinValue;
+ // Throttling/backoff state
+ private DateTime _nextAllowedAttemptUtc = DateTime.MinValue;
+ private int _consecutiveFailures = 0;
+ private Task? _inFlight;
+ private double _lastAttemptLat;
+ private double _lastAttemptLon;
private const double MinDistanceNmForRefresh = 2.0;
private static readonly TimeSpan MaxAge = TimeSpan.FromSeconds(45);
+ private static readonly TimeSpan BaseBackoff = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(5);
+ private const double MinDistanceNmForRetryAttempt = 0.1; // ~185m movement to re-attempt sooner than time-based backoff
public NearestAirportService(HttpClient httpClient)
{
@@ -40,15 +49,48 @@ public NearestAirportService(HttpClient httpClient)
}
}
- public async Task ResolveAndCacheAsync(double lat, double lon, CancellationToken ct = default)
+ public Task ResolveAndCacheAsync(double lat, double lon, CancellationToken ct = default)
+ {
+ Task? toAwait = null;
+ lock (_lock)
+ {
+ var now = DateTime.UtcNow;
+ var tooSoonByTime = now < _nextAllowedAttemptUtc;
+ var tooSoonByDistance = GreatCircleDistanceNm(lat, lon, _lastAttemptLat, _lastAttemptLon) < MinDistanceNmForRetryAttempt;
+ if ((tooSoonByTime && tooSoonByDistance))
+ {
+ return Task.FromResult(null);
+ }
+
+ if (_inFlight != null && !_inFlight.IsCompleted)
+ {
+ return _inFlight;
+ }
+
+ _lastAttemptLat = lat;
+ _lastAttemptLon = lon;
+
+ // Start a single in-flight request for de-duplication
+ _inFlight = DoResolveAsync(lat, lon, ct);
+ toAwait = _inFlight;
+ }
+
+ return toAwait!;
+ }
+
+ private async Task DoResolveAsync(double lat, double lon, CancellationToken ct)
{
try
{
var url = $"https://v2.stopbars.com/airports/nearest?lat={lat:F6}&lon={lon:F6}";
- using var resp = await _http.GetAsync(url, ct);
- if (!resp.IsSuccessStatusCode) return null;
- var json = await resp.Content.ReadAsStringAsync(ct);
- var doc = JsonDocument.Parse(json);
+ using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ {
+ ApplyFailureBackoff();
+ return null;
+ }
+ var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ using var doc = JsonDocument.Parse(json);
string? icao = null;
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
@@ -63,14 +105,38 @@ public NearestAirportService(HttpClient httpClient)
_lastLat = lat;
_lastLon = lon;
_lastFetchUtc = DateTime.UtcNow;
+ _consecutiveFailures = 0;
+ _nextAllowedAttemptUtc = DateTime.UtcNow; // reset backoff; cache age/distance gates future fetches
}
}
+ else
+ {
+ ApplyFailureBackoff();
+ }
return icao;
}
catch
{
+ ApplyFailureBackoff();
return null;
}
+ finally
+ {
+ lock (_lock)
+ {
+ _inFlight = null;
+ }
+ }
+ }
+
+ private void ApplyFailureBackoff()
+ {
+ lock (_lock)
+ {
+ _consecutiveFailures = Math.Min(_consecutiveFailures + 1, 10);
+ var backoff = TimeSpan.FromMilliseconds(Math.Min(MaxBackoff.TotalMilliseconds, BaseBackoff.TotalMilliseconds * Math.Pow(2, _consecutiveFailures - 1)));
+ _nextAllowedAttemptUtc = DateTime.UtcNow + backoff;
+ }
}
private static double GreatCircleDistanceNm(double lat1, double lon1, double lat2, double lon2)
From 4c7facf86b820b4415dfc88e916e1019c68d3511 Mon Sep 17 00:00:00 2001
From: AussieScorcher
Date: Thu, 25 Sep 2025 02:00:11 +0800
Subject: [PATCH 18/20] Refactor DiscordPresenceService for improved clarity
and maintainability
---
BARS-Client-V2.csproj | 6 +++---
Services/DiscordPresenceService.cs | 9 +++++++--
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/BARS-Client-V2.csproj b/BARS-Client-V2.csproj
index 1fe27df..909362e 100644
--- a/BARS-Client-V2.csproj
+++ b/BARS-Client-V2.csproj
@@ -35,9 +35,9 @@
-
-
-
+
+
+
diff --git a/Services/DiscordPresenceService.cs b/Services/DiscordPresenceService.cs
index 7ca4067..21dc3d0 100644
--- a/Services/DiscordPresenceService.cs
+++ b/Services/DiscordPresenceService.cs
@@ -58,13 +58,17 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
- _client = new DiscordRpcClient(DiscordAppId, autoEvents: false)
+ _client = new DiscordRpcClient(DiscordAppId, autoEvents: true)
{
Logger = new ConsoleLogger() { Level = DiscordRPC.Logging.LogLevel.None } // silence library logs (we log ourselves)
};
+ // Observe connection lifecycle for diagnostics
+ _client.OnReady += (_, e) => _logger.LogInformation("Discord RPC ready: user={User}", e.User.Username);
+ _client.OnClose += (_, e) => _logger.LogWarning("Discord RPC closed: code={Code} message={Message}", e.Code, e.Reason);
+ _client.OnError += (_, e) => _logger.LogWarning("Discord RPC error: code={Code} message={Message}", e.Code, e.Message);
_client.Initialize();
_appStartUtc = DateTime.UtcNow;
- _logger.LogInformation("Discord Rich Presence initialized");
+ _logger.LogInformation("Discord Rich Presence initialized (IsInitialized={Initialized})", _client.IsInitialized);
}
catch (Exception ex)
{
@@ -201,6 +205,7 @@ private void PublishIfChanged()
}
};
client.SetPresence(presence);
+ _logger.LogDebug("Discord presence updated: details='{details}', state='{state}', smallKey='{smallKey}'", details, state, smallKey);
}
catch (Exception ex)
{
From 6c089244ab0992fc0343b3eed24b846af25214cb Mon Sep 17 00:00:00 2001
From: frankeet11
Date: Wed, 24 Sep 2025 19:51:09 +0100
Subject: [PATCH 19/20] Update README.md (#9)
Update README to new format
---
README.md | 59 ++++++++++++++++++++++++++++++++-----------------------
1 file changed, 34 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index 632338b..5d7045b 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,41 @@
-
-
-
-
-**stopbars/Pilot-Client**
-
-[](https://github.com/stopbars/Pilot-Client/repo)
-
-[](https://stopbars.com/discord)
-
-The Pilot Client updates airport lighting in simulators by processing lighting state requests, enabling airport profile selection based on scenery preferences, updating lighting positions, and providing stopbar modal status and nearest airport detection.
-
-
+
+
+
+
+
stopbars/Pilot-Client
+
+
+ BARS Pilot Client brings real-time airport lighting updates to simulators, receiving lighting state updates from controllers, to reflect changes in the pilot's simulator. The client updates airport lighting positions based on selected scenery packages, provides status monitoring including simulator detection, nearest airport, server connection, and rich Discord presence integration.
+
+
+ stopbars.com »
+
+
## Contributing
-We encourage and appreciate contributions from the community! To get started, please review our [CONTRIBUTING.md](CONTRIBUTING.md) guide, which covers the following:
-
-- How to set up your development environment
-- Code style guidelines and best practices
-- Instructions for submitting pull requests
-- Testing and verification procedures
-
-Your contributions directly support the ongoing development and improvement of BARS. By getting involved, you help us build a more robust, and feature-rich product that benefits the entire flight sim community.
-
-## Bug Reports and Feature Requests
+We encourage and appreciate contributions from the community! To get started, please review our [CONTRIBUTING.md](CONTRIBUTING.md) guide, which covers how to set up your development environment, code style guidelines and best practices, instructions for submitting pull requests, and testing and verification procedures.
-If you find a bug or have a feature suggestion, please submit an issue [on our GitHub repository](https://github.com/stopbars/repo/issues/new). Please follow and fill in the issue template to the best of your ability to help us address your feedback efficiently.
+Your contributions directly support the ongoing development and improvement of BARS. By getting involved, you help us build a more robust, and feature-rich product that benefits the entire flight sim community. All contributors are acknowledged on our [credits page](https://stopbars.com/credits).
## Disclaimer
-BARS is an **independent third-party** software project. **We are not affiliated** with, endorsed by, or connected to VATSIM, vatSys, Microsoft Flight Simulator, or any other simulation, controller client supported by our software.
+BARS is an **independent third-party** software project. **We are not affiliated** with, **endorsed by**, or **connected to** VATSIM, vatSys, EuroScope, Microsoft Flight Simulator, or any other simulation, controller client supported by our software.
From 97f2dd2206ab7699a0e4ccd01e87163d517f7807 Mon Sep 17 00:00:00 2001
From: Scorcher
Date: Thu, 25 Sep 2025 02:56:46 +0800
Subject: [PATCH 20/20] Update build/publish-framework-dependent.ps1
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
build/publish-framework-dependent.ps1 | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build/publish-framework-dependent.ps1 b/build/publish-framework-dependent.ps1
index ad0c1c3..3c65a25 100644
--- a/build/publish-framework-dependent.ps1
+++ b/build/publish-framework-dependent.ps1
@@ -67,7 +67,7 @@ if (-not $SkipBuild) {
--self-contained false `
-p:PublishReadyToRun=true `
-p:PublishSingleFile=false `
- -p:DebugType=none
+ -p:DebugType=portable
}
$publishDir = Join-Path (Split-Path $projectPath -Parent) "bin\\$Configuration\\net8.0-windows\\$Runtime\\publish"